From 83916f667dbdc00b24f6a6da5bda5c085dbe1903 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 4 Jun 2026 14:38:31 -0400 Subject: [PATCH 01/17] feat(core): persist v2 session context epochs --- .../migration.sql | 7 + .../snapshot.json | 2026 ++++++++++++++++ .../migration.sql | 10 + .../snapshot.json | 2113 ++++++++++++++++ .../migration.sql | 1 + .../snapshot.json | 2123 ++++++++++++++++ .../migration.sql | 1 + .../snapshot.json | 2133 +++++++++++++++++ packages/core/src/database/migration.gen.ts | 4 + ...0260604180746_add_session_context_epoch.ts | 19 + ...60604181329_add_session_context_updates.ts | 23 + ...4181706_add_session_context_replacement.ts | 11 + ...dd_session_context_replacement_sequence.ts | 11 + packages/core/src/location-layer.ts | 2 + packages/core/src/session/context-epoch.ts | 184 ++ packages/core/src/session/context.ts | 99 +- packages/core/src/session/event.ts | 39 + packages/core/src/session/message-updater.ts | 3 + packages/core/src/session/projector.ts | 38 +- packages/core/src/session/runner/llm.ts | 18 +- .../core/src/session/runner/to-llm-message.ts | 8 +- packages/core/src/session/sql.ts | 30 + packages/core/src/session/store.ts | 6 + packages/core/src/system-context.ts | 24 +- packages/core/test/database-migration.test.ts | 11 +- .../openai-chat-streams-text.json | 2 +- .../core/test/session-runner-message.test.ts | 10 + .../core/test/session-runner-recorded.test.ts | 23 + packages/core/test/session-runner.test.ts | 196 ++ packages/core/test/system-context.test.ts | 4 +- .../llm/src/protocols/anthropic-messages.ts | 43 +- .../llm/src/protocols/bedrock-converse.ts | 3 +- packages/llm/src/protocols/gemini.ts | 3 +- packages/llm/src/protocols/openai-chat.ts | 3 +- .../llm/src/protocols/openai-responses.ts | 3 +- packages/llm/src/protocols/shared.ts | 14 + .../test/provider/anthropic-messages.test.ts | 48 +- packages/llm/test/schema.test.ts | 25 +- specs/v2/schema-changelog.md | 62 + 39 files changed, 9303 insertions(+), 80 deletions(-) create mode 100644 packages/core/migration/20260604180746_add_session_context_epoch/migration.sql create mode 100644 packages/core/migration/20260604180746_add_session_context_epoch/snapshot.json create mode 100644 packages/core/migration/20260604181329_add_session_context_updates/migration.sql create mode 100644 packages/core/migration/20260604181329_add_session_context_updates/snapshot.json create mode 100644 packages/core/migration/20260604181706_add_session_context_replacement/migration.sql create mode 100644 packages/core/migration/20260604181706_add_session_context_replacement/snapshot.json create mode 100644 packages/core/migration/20260604181807_add_session_context_replacement_sequence/migration.sql create mode 100644 packages/core/migration/20260604181807_add_session_context_replacement_sequence/snapshot.json create mode 100644 packages/core/src/database/migration/20260604180746_add_session_context_epoch.ts create mode 100644 packages/core/src/database/migration/20260604181329_add_session_context_updates.ts create mode 100644 packages/core/src/database/migration/20260604181706_add_session_context_replacement.ts create mode 100644 packages/core/src/database/migration/20260604181807_add_session_context_replacement_sequence.ts create mode 100644 packages/core/src/session/context-epoch.ts diff --git a/packages/core/migration/20260604180746_add_session_context_epoch/migration.sql b/packages/core/migration/20260604180746_add_session_context_epoch/migration.sql new file mode 100644 index 0000000000..7aaf9ecb79 --- /dev/null +++ b/packages/core/migration/20260604180746_add_session_context_epoch/migration.sql @@ -0,0 +1,7 @@ +CREATE TABLE `session_context_epoch` ( + `session_id` text PRIMARY KEY, + `baseline` text NOT NULL, + `checkpoint` text NOT NULL, + `baseline_seq` integer NOT NULL, + CONSTRAINT `fk_session_context_epoch_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE +); diff --git a/packages/core/migration/20260604180746_add_session_context_epoch/snapshot.json b/packages/core/migration/20260604180746_add_session_context_epoch/snapshot.json new file mode 100644 index 0000000000..7ff48041b3 --- /dev/null +++ b/packages/core/migration/20260604180746_add_session_context_epoch/snapshot.json @@ -0,0 +1,2026 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "f08074bb-2b27-4365-924c-5a4eae9a5194", + "prevIds": [ + "fc92fa34-8074-44c3-88f0-a5417f7fd92d" + ], + "ddl": [ + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "data_migration", + "entityType": "tables" + }, + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "project_directory", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "session_context_epoch", + "entityType": "tables" + }, + { + "name": "session_input", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_completed", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "action", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "resource", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "baseline", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "checkpoint", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "baseline_seq", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "prompt", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "delivery", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "promoted_seq", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "metadata", + "entityType": "columns", + "table": "session" + }, + { + "type": "real", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_input", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_output", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_reasoning", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_read", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_write", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": [ + "active_account_id" + ], + "tableTo": "account", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": [ + "aggregate_id" + ], + "tableTo": "event_sequence", + "columnsTo": [ + "aggregate_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_project_directory_project_id_project_id_fk", + "entityType": "fks", + "table": "project_directory" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_context_epoch_session_id_session_id_fk", + "entityType": "fks", + "table": "session_context_epoch" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_input_session_id_session_id_fk", + "entityType": "fks", + "table": "session_input" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": [ + "project_id", + "directory" + ], + "nameExplicit": false, + "name": "project_directory_pk", + "entityType": "pks", + "table": "project_directory" + }, + { + "columns": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "data_migration_pk", + "table": "data_migration", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "aggregate_id" + ], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_context_epoch_pk", + "table": "session_context_epoch", + "entityType": "pks" + }, + { + "columns": [ + "seq" + ], + "nameExplicit": false, + "name": "session_input_pk", + "table": "session_input", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "aggregate_id", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "event_aggregate_seq_idx", + "entityType": "indexes", + "table": "event" + }, + { + "columns": [ + { + "value": "aggregate_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "event_aggregate_type_seq_idx", + "entityType": "indexes", + "table": "event" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + }, + { + "value": "action", + "isExpression": false + }, + { + "value": "resource", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "permission_project_action_resource_idx", + "entityType": "indexes", + "table": "permission" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "promoted_seq", + "isExpression": false + }, + { + "value": "delivery", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_input_session_pending_delivery_seq_idx", + "entityType": "indexes", + "table": "session_input" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_seq_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_seq_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_time_created_id_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_input_id_unique", + "entityType": "uniques", + "table": "session_input" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/core/migration/20260604181329_add_session_context_updates/migration.sql b/packages/core/migration/20260604181329_add_session_context_updates/migration.sql new file mode 100644 index 0000000000..c44ac8ae4e --- /dev/null +++ b/packages/core/migration/20260604181329_add_session_context_updates/migration.sql @@ -0,0 +1,10 @@ +CREATE TABLE `session_context_message` ( + `session_id` text NOT NULL, + `seq` integer NOT NULL, + `parts` text NOT NULL, + CONSTRAINT `session_context_message_pk` PRIMARY KEY(`session_id`, `seq`), + CONSTRAINT `fk_session_context_message_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +ALTER TABLE `session_context_epoch` ADD `revision` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +CREATE INDEX `session_context_message_session_seq_idx` ON `session_context_message` (`session_id`,`seq`); \ No newline at end of file diff --git a/packages/core/migration/20260604181329_add_session_context_updates/snapshot.json b/packages/core/migration/20260604181329_add_session_context_updates/snapshot.json new file mode 100644 index 0000000000..32058c614c --- /dev/null +++ b/packages/core/migration/20260604181329_add_session_context_updates/snapshot.json @@ -0,0 +1,2113 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "85667f6a-603f-4041-be9b-783a34eb74d4", + "prevIds": [ + "f08074bb-2b27-4365-924c-5a4eae9a5194" + ], + "ddl": [ + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "data_migration", + "entityType": "tables" + }, + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "project_directory", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "session_context_epoch", + "entityType": "tables" + }, + { + "name": "session_context_message", + "entityType": "tables" + }, + { + "name": "session_input", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_completed", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "action", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "resource", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "baseline", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "checkpoint", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "baseline_seq", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "revision", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_context_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "session_context_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parts", + "entityType": "columns", + "table": "session_context_message" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "prompt", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "delivery", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "promoted_seq", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "metadata", + "entityType": "columns", + "table": "session" + }, + { + "type": "real", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_input", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_output", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_reasoning", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_read", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_write", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": [ + "active_account_id" + ], + "tableTo": "account", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": [ + "aggregate_id" + ], + "tableTo": "event_sequence", + "columnsTo": [ + "aggregate_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_project_directory_project_id_project_id_fk", + "entityType": "fks", + "table": "project_directory" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_context_epoch_session_id_session_id_fk", + "entityType": "fks", + "table": "session_context_epoch" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_context_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_context_message" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_input_session_id_session_id_fk", + "entityType": "fks", + "table": "session_input" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": [ + "project_id", + "directory" + ], + "nameExplicit": false, + "name": "project_directory_pk", + "entityType": "pks", + "table": "project_directory" + }, + { + "columns": [ + "session_id", + "seq" + ], + "nameExplicit": false, + "name": "session_context_message_pk", + "entityType": "pks", + "table": "session_context_message" + }, + { + "columns": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "data_migration_pk", + "table": "data_migration", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "aggregate_id" + ], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_context_epoch_pk", + "table": "session_context_epoch", + "entityType": "pks" + }, + { + "columns": [ + "seq" + ], + "nameExplicit": false, + "name": "session_input_pk", + "table": "session_input", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "aggregate_id", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "event_aggregate_seq_idx", + "entityType": "indexes", + "table": "event" + }, + { + "columns": [ + { + "value": "aggregate_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "event_aggregate_type_seq_idx", + "entityType": "indexes", + "table": "event" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + }, + { + "value": "action", + "isExpression": false + }, + { + "value": "resource", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "permission_project_action_resource_idx", + "entityType": "indexes", + "table": "permission" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_context_message_session_seq_idx", + "entityType": "indexes", + "table": "session_context_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "promoted_seq", + "isExpression": false + }, + { + "value": "delivery", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_input_session_pending_delivery_seq_idx", + "entityType": "indexes", + "table": "session_input" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_seq_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_seq_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_time_created_id_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_input_id_unique", + "entityType": "uniques", + "table": "session_input" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/core/migration/20260604181706_add_session_context_replacement/migration.sql b/packages/core/migration/20260604181706_add_session_context_replacement/migration.sql new file mode 100644 index 0000000000..046a793890 --- /dev/null +++ b/packages/core/migration/20260604181706_add_session_context_replacement/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `session_context_epoch` ADD `replacement_pending` integer DEFAULT false NOT NULL; \ No newline at end of file diff --git a/packages/core/migration/20260604181706_add_session_context_replacement/snapshot.json b/packages/core/migration/20260604181706_add_session_context_replacement/snapshot.json new file mode 100644 index 0000000000..c5baf161a0 --- /dev/null +++ b/packages/core/migration/20260604181706_add_session_context_replacement/snapshot.json @@ -0,0 +1,2123 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "5d4aa1b8-68a2-4aca-8890-b40b16679706", + "prevIds": [ + "85667f6a-603f-4041-be9b-783a34eb74d4" + ], + "ddl": [ + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "data_migration", + "entityType": "tables" + }, + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "project_directory", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "session_context_epoch", + "entityType": "tables" + }, + { + "name": "session_context_message", + "entityType": "tables" + }, + { + "name": "session_input", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_completed", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "action", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "resource", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "baseline", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "checkpoint", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "baseline_seq", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "replacement_pending", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "revision", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_context_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "session_context_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parts", + "entityType": "columns", + "table": "session_context_message" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "prompt", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "delivery", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "promoted_seq", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "metadata", + "entityType": "columns", + "table": "session" + }, + { + "type": "real", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_input", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_output", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_reasoning", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_read", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_write", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": [ + "active_account_id" + ], + "tableTo": "account", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": [ + "aggregate_id" + ], + "tableTo": "event_sequence", + "columnsTo": [ + "aggregate_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_project_directory_project_id_project_id_fk", + "entityType": "fks", + "table": "project_directory" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_context_epoch_session_id_session_id_fk", + "entityType": "fks", + "table": "session_context_epoch" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_context_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_context_message" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_input_session_id_session_id_fk", + "entityType": "fks", + "table": "session_input" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": [ + "project_id", + "directory" + ], + "nameExplicit": false, + "name": "project_directory_pk", + "entityType": "pks", + "table": "project_directory" + }, + { + "columns": [ + "session_id", + "seq" + ], + "nameExplicit": false, + "name": "session_context_message_pk", + "entityType": "pks", + "table": "session_context_message" + }, + { + "columns": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "data_migration_pk", + "table": "data_migration", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "aggregate_id" + ], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_context_epoch_pk", + "table": "session_context_epoch", + "entityType": "pks" + }, + { + "columns": [ + "seq" + ], + "nameExplicit": false, + "name": "session_input_pk", + "table": "session_input", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "aggregate_id", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "event_aggregate_seq_idx", + "entityType": "indexes", + "table": "event" + }, + { + "columns": [ + { + "value": "aggregate_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "event_aggregate_type_seq_idx", + "entityType": "indexes", + "table": "event" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + }, + { + "value": "action", + "isExpression": false + }, + { + "value": "resource", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "permission_project_action_resource_idx", + "entityType": "indexes", + "table": "permission" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_context_message_session_seq_idx", + "entityType": "indexes", + "table": "session_context_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "promoted_seq", + "isExpression": false + }, + { + "value": "delivery", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_input_session_pending_delivery_seq_idx", + "entityType": "indexes", + "table": "session_input" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_seq_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_seq_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_time_created_id_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_input_id_unique", + "entityType": "uniques", + "table": "session_input" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/core/migration/20260604181807_add_session_context_replacement_sequence/migration.sql b/packages/core/migration/20260604181807_add_session_context_replacement_sequence/migration.sql new file mode 100644 index 0000000000..35ada3e2bc --- /dev/null +++ b/packages/core/migration/20260604181807_add_session_context_replacement_sequence/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `session_context_epoch` ADD `replacement_seq` integer; \ No newline at end of file diff --git a/packages/core/migration/20260604181807_add_session_context_replacement_sequence/snapshot.json b/packages/core/migration/20260604181807_add_session_context_replacement_sequence/snapshot.json new file mode 100644 index 0000000000..902c531801 --- /dev/null +++ b/packages/core/migration/20260604181807_add_session_context_replacement_sequence/snapshot.json @@ -0,0 +1,2133 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "297cd5f2-b759-46d2-b4c1-7490a65dff3a", + "prevIds": [ + "5d4aa1b8-68a2-4aca-8890-b40b16679706" + ], + "ddl": [ + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "data_migration", + "entityType": "tables" + }, + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "project_directory", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "session_context_epoch", + "entityType": "tables" + }, + { + "name": "session_context_message", + "entityType": "tables" + }, + { + "name": "session_input", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_completed", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "action", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "resource", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "baseline", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "checkpoint", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "baseline_seq", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "replacement_pending", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "replacement_seq", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "revision", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_context_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "session_context_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parts", + "entityType": "columns", + "table": "session_context_message" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "prompt", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "delivery", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "promoted_seq", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "metadata", + "entityType": "columns", + "table": "session" + }, + { + "type": "real", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_input", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_output", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_reasoning", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_read", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_write", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": [ + "active_account_id" + ], + "tableTo": "account", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": [ + "aggregate_id" + ], + "tableTo": "event_sequence", + "columnsTo": [ + "aggregate_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_project_directory_project_id_project_id_fk", + "entityType": "fks", + "table": "project_directory" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_context_epoch_session_id_session_id_fk", + "entityType": "fks", + "table": "session_context_epoch" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_context_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_context_message" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_input_session_id_session_id_fk", + "entityType": "fks", + "table": "session_input" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": [ + "project_id", + "directory" + ], + "nameExplicit": false, + "name": "project_directory_pk", + "entityType": "pks", + "table": "project_directory" + }, + { + "columns": [ + "session_id", + "seq" + ], + "nameExplicit": false, + "name": "session_context_message_pk", + "entityType": "pks", + "table": "session_context_message" + }, + { + "columns": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "data_migration_pk", + "table": "data_migration", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "aggregate_id" + ], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_context_epoch_pk", + "table": "session_context_epoch", + "entityType": "pks" + }, + { + "columns": [ + "seq" + ], + "nameExplicit": false, + "name": "session_input_pk", + "table": "session_input", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "aggregate_id", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "event_aggregate_seq_idx", + "entityType": "indexes", + "table": "event" + }, + { + "columns": [ + { + "value": "aggregate_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "event_aggregate_type_seq_idx", + "entityType": "indexes", + "table": "event" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + }, + { + "value": "action", + "isExpression": false + }, + { + "value": "resource", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "permission_project_action_resource_idx", + "entityType": "indexes", + "table": "permission" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_context_message_session_seq_idx", + "entityType": "indexes", + "table": "session_context_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "promoted_seq", + "isExpression": false + }, + { + "value": "delivery", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_input_session_pending_delivery_seq_idx", + "entityType": "indexes", + "table": "session_input" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_seq_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_seq_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_time_created_id_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_input_id_unique", + "entityType": "uniques", + "table": "session_input" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 59ed2fdbcf..2bb36b01a7 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -32,5 +32,9 @@ export const migrations = ( import("./migration/20260603141458_session_input_inbox"), import("./migration/20260603160727_jittery_ezekiel_stane"), import("./migration/20260604172448_event_sourced_session_input"), + import("./migration/20260604180746_add_session_context_epoch"), + import("./migration/20260604181329_add_session_context_updates"), + import("./migration/20260604181706_add_session_context_replacement"), + import("./migration/20260604181807_add_session_context_replacement_sequence"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260604180746_add_session_context_epoch.ts b/packages/core/src/database/migration/20260604180746_add_session_context_epoch.ts new file mode 100644 index 0000000000..3bc87317f9 --- /dev/null +++ b/packages/core/src/database/migration/20260604180746_add_session_context_epoch.ts @@ -0,0 +1,19 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260604180746_add_session_context_epoch", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`session_context_epoch\` ( + \`session_id\` text PRIMARY KEY, + \`baseline\` text NOT NULL, + \`checkpoint\` text NOT NULL, + \`baseline_seq\` integer NOT NULL, + CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260604181329_add_session_context_updates.ts b/packages/core/src/database/migration/20260604181329_add_session_context_updates.ts new file mode 100644 index 0000000000..6988aa8fbd --- /dev/null +++ b/packages/core/src/database/migration/20260604181329_add_session_context_updates.ts @@ -0,0 +1,23 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260604181329_add_session_context_updates", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`session_context_message\` ( + \`session_id\` text NOT NULL, + \`seq\` integer NOT NULL, + \`parts\` text NOT NULL, + CONSTRAINT \`session_context_message_pk\` PRIMARY KEY(\`session_id\`, \`seq\`), + CONSTRAINT \`fk_session_context_message_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(`ALTER TABLE \`session_context_epoch\` ADD \`revision\` integer DEFAULT 0 NOT NULL;`) + yield* tx.run( + `CREATE INDEX \`session_context_message_session_seq_idx\` ON \`session_context_message\` (\`session_id\`,\`seq\`);`, + ) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260604181706_add_session_context_replacement.ts b/packages/core/src/database/migration/20260604181706_add_session_context_replacement.ts new file mode 100644 index 0000000000..69040dc61d --- /dev/null +++ b/packages/core/src/database/migration/20260604181706_add_session_context_replacement.ts @@ -0,0 +1,11 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260604181706_add_session_context_replacement", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`session_context_epoch\` ADD \`replacement_pending\` integer DEFAULT false NOT NULL;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260604181807_add_session_context_replacement_sequence.ts b/packages/core/src/database/migration/20260604181807_add_session_context_replacement_sequence.ts new file mode 100644 index 0000000000..5298c0d644 --- /dev/null +++ b/packages/core/src/database/migration/20260604181807_add_session_context_replacement_sequence.ts @@ -0,0 +1,11 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260604181807_add_session_context_replacement_sequence", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`session_context_epoch\` ADD \`replacement_seq\` integer;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index b45a4aab75..194afd9013 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -40,6 +40,7 @@ import { RequestExecutor } from "@opencode-ai/llm/route" import * as SessionRunnerLLM from "./session/runner/llm" import { SessionRunnerModel } from "./session/runner/model" import { SessionRunCoordinator } from "./session/run-coordinator" +import { SessionSystemContext } from "./session-system-context" import { FetchHttpClient } from "effect/unstable/http" export class LocationServiceMap extends LayerMap.Service()("@opencode/example/LocationServiceMap", { @@ -60,6 +61,7 @@ export class LocationServiceMap extends LayerMap.Service()(" Watcher.locationLayer, Pty.locationLayer, SkillV2.locationLayer, + SessionSystemContext.locationLayer, permissionsAndTools, LocationMutation.locationLayer.pipe(Layer.orDie), ).pipe(Layer.provideMerge(location)) diff --git a/packages/core/src/session/context-epoch.ts b/packages/core/src/session/context-epoch.ts new file mode 100644 index 0000000000..ed37c2794b --- /dev/null +++ b/packages/core/src/session/context-epoch.ts @@ -0,0 +1,184 @@ +export * as SessionContextEpoch from "./context-epoch" + +import { and, eq, sql } from "drizzle-orm" +import { DateTime, Effect, Schema } from "effect" +import type { Database } from "../database/database" +import { EventV2 } from "../event" +import { SessionSystemContext } from "../session-system-context" +import { SystemContext } from "../system-context" +import { SessionEvent } from "./event" +import { SessionSchema } from "./schema" +import { SessionContextEpochTable, SessionContextMessageTable } from "./sql" + +type DatabaseService = Database.Interface["db"] +const sameBaseline = Schema.toEquivalence(SystemContext.PartsSchema) +const sameCheckpoint = Schema.toEquivalence(SystemContext.CheckpointSchema) + +export const prepare = Effect.fn("SessionContextEpoch.prepare")(function* ( + db: DatabaseService, + events: EventV2.Interface, + context: SessionSystemContext.Interface, + sessionID: SessionSchema.ID, +) { + const snapshot = yield* context.load() + const stored = yield* find(db, sessionID) + if (!stored) { + const initialized = SystemContext.initialize(snapshot) + yield* events.publish(SessionEvent.ContextInitialized, { + sessionID, + timestamp: yield* DateTime.now, + baseline: initialized.baseline, + checkpoint: initialized.checkpoint, + }) + return initialized.baseline + } + if (stored.replacement_pending) { + const initialized = SystemContext.initialize(snapshot) + yield* events.publish(SessionEvent.ContextReplaced, { + sessionID, + timestamp: yield* DateTime.now, + expectedRevision: stored.revision, + baseline: initialized.baseline, + checkpoint: initialized.checkpoint, + }) + return initialized.baseline + } + + const refreshed = SystemContext.refresh(snapshot, stored.checkpoint) + if (sameCheckpoint(refreshed.checkpoint, stored.checkpoint)) return stored.baseline + yield* events.publish(SessionEvent.ContextUpdated, { + sessionID, + timestamp: yield* DateTime.now, + expectedRevision: stored.revision, + parts: refreshed.changes, + checkpoint: refreshed.checkpoint, + }) + return stored.baseline +}) + +export const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { + return yield* db + .select() + .from(SessionContextEpochTable) + .where(eq(SessionContextEpochTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie) +}) + +export const projectInitialized = Effect.fn("SessionContextEpoch.projectInitialized")(function* ( + db: DatabaseService, + event: SessionEvent.ContextInitialized, + seq: number, +) { + const stored = yield* find(db, event.data.sessionID) + if (stored) { + if (stored.baseline_seq > seq) return yield* Effect.void + if (stored.baseline_seq !== seq || !sameBaseline(stored.baseline, event.data.baseline)) + return yield* Effect.die("Session context epoch initialization conflicts with stored baseline") + return yield* Effect.void + } + return yield* db + .insert(SessionContextEpochTable) + .values({ + session_id: event.data.sessionID, + baseline: event.data.baseline, + checkpoint: event.data.checkpoint, + baseline_seq: seq, + replacement_pending: false, + replacement_seq: null, + revision: 0, + }) + .run() + .pipe(Effect.orDie) +}) + +export const projectUpdated = Effect.fn("SessionContextEpoch.projectUpdated")(function* ( + db: DatabaseService, + event: SessionEvent.ContextUpdated, + seq: number, +) { + const stored = yield* find(db, event.data.sessionID) + if (!stored) return yield* Effect.die("Session context epoch is not initialized") + if (stored.replacement_pending) return yield* Effect.die("Session context epoch replacement is pending") + if (stored.revision > event.data.expectedRevision) { + if (event.data.parts.length === 0) return yield* Effect.void + const projected = yield* db + .select({ parts: SessionContextMessageTable.parts }) + .from(SessionContextMessageTable) + .where( + and(eq(SessionContextMessageTable.session_id, event.data.sessionID), eq(SessionContextMessageTable.seq, seq)), + ) + .get() + .pipe(Effect.orDie) + if (projected && sameBaseline(projected.parts, event.data.parts)) return yield* Effect.void + return yield* Effect.die("Session context update conflicts with stored projection") + } + const updated = yield* db + .update(SessionContextEpochTable) + .set({ checkpoint: event.data.checkpoint, revision: event.data.expectedRevision + 1 }) + .where( + and( + eq(SessionContextEpochTable.session_id, event.data.sessionID), + eq(SessionContextEpochTable.revision, event.data.expectedRevision), + ), + ) + .returning({ revision: SessionContextEpochTable.revision }) + .get() + .pipe(Effect.orDie) + if (!updated) return yield* Effect.die("Session context epoch revision mismatch") + if (event.data.parts.length === 0) return yield* Effect.void + return yield* db + .insert(SessionContextMessageTable) + .values({ session_id: event.data.sessionID, seq, parts: event.data.parts }) + .run() + .pipe(Effect.orDie) +}) + +export const projectReplaced = Effect.fn("SessionContextEpoch.projectReplaced")(function* ( + db: DatabaseService, + event: SessionEvent.ContextReplaced, + seq: number, +) { + const stored = yield* find(db, event.data.sessionID) + if (!stored) return yield* Effect.die("Session context epoch is not initialized") + if (!stored.replacement_pending) { + if (stored.baseline_seq === seq && sameBaseline(stored.baseline, event.data.baseline)) return yield* Effect.void + return yield* Effect.die("Session context epoch replacement was not requested") + } + const updated = yield* db + .update(SessionContextEpochTable) + .set({ + baseline: event.data.baseline, + checkpoint: event.data.checkpoint, + baseline_seq: seq, + replacement_pending: false, + replacement_seq: null, + revision: event.data.expectedRevision + 1, + }) + .where( + and( + eq(SessionContextEpochTable.session_id, event.data.sessionID), + eq(SessionContextEpochTable.revision, event.data.expectedRevision), + ), + ) + .returning({ revision: SessionContextEpochTable.revision }) + .get() + .pipe(Effect.orDie) + if (!updated) return yield* Effect.die("Session context epoch revision mismatch") + return yield* Effect.void +}) + +export const requestReplacement = Effect.fn("SessionContextEpoch.requestReplacement")(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + seq: number, +) { + const stored = yield* find(db, sessionID) + if (!stored || stored.baseline_seq >= seq || stored.replacement_seq === seq) return yield* Effect.void + return yield* db + .update(SessionContextEpochTable) + .set({ replacement_pending: true, replacement_seq: seq, revision: sql`${SessionContextEpochTable.revision} + 1` }) + .where(eq(SessionContextEpochTable.session_id, sessionID)) + .run() + .pipe(Effect.orDie) +}) diff --git a/packages/core/src/session/context.ts b/packages/core/src/session/context.ts index 458f768032..4f4634529c 100644 --- a/packages/core/src/session/context.ts +++ b/packages/core/src/session/context.ts @@ -4,14 +4,18 @@ import { Database } from "../database/database" import { MessageDecodeError } from "./error" import { SessionMessage } from "./message" import { SessionSchema } from "./schema" -import { SessionMessageTable } from "./sql" +import { SessionContextEpochTable, SessionContextMessageTable, SessionMessageTable } from "./sql" +import type { SystemContext } from "../system-context" type DatabaseService = Database.Interface["db"] const decode = Schema.decodeUnknownEffect(SessionMessage.Message) +export type RunnerMessage = + | SessionMessage.Message + | { readonly type: "system-context"; readonly parts: ReadonlyArray } -export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { - const compaction = yield* db +const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { + return yield* db .select() .from(SessionMessageTable) .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction"))) @@ -19,7 +23,14 @@ export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseServ .limit(1) .get() .pipe(Effect.orDie) - const rows = yield* db +}) + +const messageRows = Effect.fnUntraced(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + compaction: typeof SessionMessageTable.$inferSelect | undefined, +) { + return yield* db .select() .from(SessionMessageTable) .where( @@ -31,17 +42,79 @@ export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseServ .orderBy(asc(SessionMessageTable.seq)) .all() .pipe(Effect.orDie) - return yield* Effect.forEach(rows, (row) => - decode({ ...row.data, id: row.id, type: row.type }).pipe( - Effect.mapError( - () => - new MessageDecodeError({ - sessionID: SessionSchema.ID.make(row.session_id), - messageID: SessionMessage.ID.make(row.id), - }), - ), +}) + +const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) => + decode({ ...row.data, id: row.id, type: row.type }).pipe( + Effect.mapError( + () => + new MessageDecodeError({ + sessionID: SessionSchema.ID.make(row.session_id), + messageID: SessionMessage.ID.make(row.id), + }), ), ) + +export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { + return yield* Effect.forEach( + yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID)), + decodeMessageRow, + ) }) +export const loadForRunner = Effect.fn("SessionContext.loadForRunner")(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, +) { + const compaction = yield* latestCompaction(db, sessionID) + const messages = yield* messageRows(db, sessionID, compaction) + const epoch = yield* db + .select({ baselineSeq: SessionContextEpochTable.baseline_seq }) + .from(SessionContextEpochTable) + .where(eq(SessionContextEpochTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie) + const updates = yield* db + .select() + .from(SessionContextMessageTable) + .where( + and( + eq(SessionContextMessageTable.session_id, sessionID), + epoch ? gt(SessionContextMessageTable.seq, epoch.baselineSeq) : undefined, + ), + ) + .orderBy(asc(SessionContextMessageTable.seq)) + .all() + .pipe(Effect.orDie) + return yield* Effect.forEach( + merge( + messages.map((row) => ({ type: "message" as const, seq: row.seq, row })), + updates.map((row) => ({ type: "system-context" as const, seq: row.seq, row })), + ), + (item): Effect.Effect => + item.type === "message" + ? decodeMessageRow(item.row) + : Effect.succeed({ type: "system-context", parts: item.row.parts }), + ) +}) + +function merge( + left: ReadonlyArray, + right: ReadonlyArray, +): Array { + const result: Array = [] + let leftIndex = 0 + let rightIndex = 0 + while (leftIndex < left.length || rightIndex < right.length) { + if (rightIndex >= right.length || (leftIndex < left.length && left[leftIndex].seq < right[rightIndex].seq)) { + result.push(left[leftIndex]) + leftIndex++ + continue + } + result.push(right[rightIndex]) + rightIndex++ + } + return result +} + export * as SessionContext from "./context" diff --git a/packages/core/src/session/event.ts b/packages/core/src/session/event.ts index a85e807d24..f471d2bf78 100644 --- a/packages/core/src/session/event.ts +++ b/packages/core/src/session/event.ts @@ -10,6 +10,7 @@ import { SessionSchema } from "./schema" import { Location } from "../location" import { RelativePath } from "../schema" import { SessionMessageID } from "./message-id" +import { SystemContext } from "../system-context" export { FileAttachment } @@ -119,6 +120,41 @@ export namespace PromptLifecycle { export type Promoted = typeof Promoted.Type } +export const ContextInitialized = EventV2.define({ + type: "session.next.context.initialized", + ...options, + schema: { + ...Base, + baseline: SystemContext.PartsSchema, + checkpoint: SystemContext.CheckpointSchema, + }, +}) +export type ContextInitialized = typeof ContextInitialized.Type + +export const ContextUpdated = EventV2.define({ + type: "session.next.context.updated", + ...options, + schema: { + ...Base, + expectedRevision: NonNegativeInt, + parts: SystemContext.PartsSchema, + checkpoint: SystemContext.CheckpointSchema, + }, +}) +export type ContextUpdated = typeof ContextUpdated.Type + +export const ContextReplaced = EventV2.define({ + type: "session.next.context.replaced", + ...options, + schema: { + ...Base, + expectedRevision: NonNegativeInt, + baseline: SystemContext.PartsSchema, + checkpoint: SystemContext.CheckpointSchema, + }, +}) +export type ContextReplaced = typeof ContextReplaced.Type + export const Synthetic = EventV2.define({ type: "session.next.synthetic", ...options, @@ -444,6 +480,9 @@ const DurableDefinitions = [ Prompted, PromptLifecycle.Admitted, PromptLifecycle.Promoted, + ContextInitialized, + ContextUpdated, + ContextReplaced, Synthetic, Shell.Started, Shell.Ended, diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index a7a3383205..6e4c228082 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -159,6 +159,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }, "session.next.prompt.admitted": () => Effect.void, "session.next.prompt.promoted": () => Effect.void, + "session.next.context.initialized": () => Effect.void, + "session.next.context.updated": () => Effect.void, + "session.next.context.replaced": () => Effect.void, "session.next.synthetic": (event) => { return adapter.appendMessage( new SessionMessage.Synthetic({ diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index e780d7ceaa..ca997c689b 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -11,6 +11,7 @@ import { SessionMessage } from "./message" import { SessionMessageUpdater } from "./message-updater" import { SessionInput } from "./input" import { WorkspaceV2 } from "../workspace" +import { SessionContextEpoch } from "./context-epoch" import { MessageTable, PartTable, SessionMessageTable, SessionTable } from "./sql" import type { DeepMutable } from "../schema" @@ -352,12 +353,18 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie, Effect.andThen(run(db, event))), ) yield* events.project(SessionEvent.ModelSwitched, (event) => - db - .update(SessionTable) - .set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) - .where(eq(SessionTable.id, event.data.sessionID)) - .run() - .pipe(Effect.orDie, Effect.andThen(run(db, event))), + Effect.gen(function* () { + yield* db + .update(SessionTable) + .set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) + .where(eq(SessionTable.id, event.data.sessionID)) + .run() + .pipe(Effect.orDie) + yield* run(db, event) + if (event.seq === undefined) + return yield* Effect.die("Synchronized Session event is missing aggregate sequence") + yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq) + }), ) yield* events.project(SessionEvent.Prompted, (event) => Effect.gen(function* () { @@ -413,6 +420,18 @@ export const layer = Layer.effectDiscard( ) }), ) + yield* events.project(SessionEvent.ContextInitialized, (event) => { + if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence") + return SessionContextEpoch.projectInitialized(db, event, event.seq) + }) + yield* events.project(SessionEvent.ContextUpdated, (event) => { + if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence") + return SessionContextEpoch.projectUpdated(db, event, event.seq) + }) + yield* events.project(SessionEvent.ContextReplaced, (event) => { + if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence") + return SessionContextEpoch.projectReplaced(db, event, event.seq) + }) yield* events.project(SessionEvent.Synthetic, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event)) @@ -432,7 +451,12 @@ export const layer = Layer.effectDiscard( // yield* events.project(SessionEvent.Retried, (event) => run(db, event)) yield* events.project(SessionEvent.Compaction.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Compaction.Delta, (event) => run(db, event)) - yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event)) + yield* events.project(SessionEvent.Compaction.Ended, (event) => { + if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence") + return run(db, event).pipe( + Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)), + ) + }) }), ) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 8da27e4c85..8d7db38157 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -1,4 +1,4 @@ -import { LLM, LLMClient, LLMError, LLMEvent } from "@opencode-ai/llm" +import { LLM, LLMClient, LLMError, LLMEvent, SystemPart } from "@opencode-ai/llm" import { Cause, DateTime, Effect, FiberSet, Layer, Semaphore, Stream } from "effect" import { EventV2 } from "../../event" import { ModelV2 } from "../../model" @@ -14,6 +14,8 @@ import { SessionRunnerModel } from "./model" import { Database } from "../../database/database" import { SessionInput } from "../input" import { QuestionV2 } from "../../question" +import { SessionSystemContext } from "../../session-system-context" +import { SessionContextEpoch } from "../context-epoch" /** * Runs one durable coding-agent Session until it settles. @@ -85,6 +87,7 @@ export const layer = Layer.effect( const tools = yield* ToolRegistry.Service const models = yield* SessionRunnerModel.Service const store = yield* SessionStore.Service + const systemContext = yield* SessionSystemContext.Service const db = (yield* Database.Service).db const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) { const session = yield* store.get(sessionID) @@ -95,6 +98,9 @@ export const layer = Layer.effect( const getContext = Effect.fn("SessionRunner.getContext")(function* (sessionID: SessionSchema.ID) { return yield* store.context(sessionID) }) + const getRunnerContext = Effect.fn("SessionRunner.getRunnerContext")(function* (sessionID: SessionSchema.ID) { + return yield* store.runnerContext(sessionID) + }) const failInterruptedTools = Effect.fn("SessionRunner.failInterruptedTools")(function* ( sessionID: SessionSchema.ID, @@ -141,8 +147,14 @@ export const layer = Layer.effect( } } yield* failInterruptedTools(session.id) - const context = yield* getContext(session.id) - const request = LLM.request({ model, messages: toLLMMessages(context, model), tools: yield* tools.definitions() }) + const system = yield* SessionContextEpoch.prepare(db, events, systemContext, session.id) + const context = yield* getRunnerContext(session.id) + const request = LLM.request({ + model, + system: system.map((part) => SystemPart.make(part.text)), + messages: toLLMMessages(context, model), + tools: yield* tools.definitions(), + }) const publisher = createLLMEventPublisher(events, { sessionID: session.id, agent: session.agent ?? "build", diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index 75c8fce6a5..e2ee07516c 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -9,6 +9,8 @@ import { } from "@opencode-ai/llm" import { SessionMessage } from "../message" import type { FileAttachment } from "../prompt" +import { SessionContext } from "../context" +import { SystemContext } from "../../system-context" const media = (file: FileAttachment): ContentPart => ({ type: "media", @@ -91,7 +93,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => { return [Message.make({ id: message.id, role: "assistant", content, metadata: message.metadata }), ...results] } -function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] { +function toLLMMessage(message: SessionContext.RunnerMessage, model: Model): Message[] { switch (message.type) { case "agent-switched": case "model-switched": @@ -131,9 +133,11 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] metadata: message.metadata, }), ] + case "system-context": + return [Message.system(SystemContext.render(message.parts))] } } /** Translate projected V2 Session history into canonical @opencode-ai/llm context. */ -export const toLLMMessages = (messages: readonly SessionMessage.Message[], model: Model) => +export const toLLMMessages = (messages: readonly SessionContext.RunnerMessage[], model: Model) => messages.flatMap((message) => toLLMMessage(message, model)) diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index 27f22bd4bf..fd73a164ac 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -11,6 +11,7 @@ import type { SessionSchema } from "./schema" import type { MessageID, PartID, SessionV1 } from "../v1/session" import { WorkspaceV2 } from "../workspace" import { Timestamps } from "../database/schema.sql" +import type { SystemContext } from "../system-context" type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id"> type V1MessageData = Omit @@ -161,3 +162,32 @@ export const SessionInputTable = sqliteTable( uniqueIndex("session_input_session_promoted_seq_idx").on(table.session_id, table.promoted_seq), ], ) + +export const SessionContextEpochTable = sqliteTable("session_context_epoch", { + session_id: text() + .$type() + .primaryKey() + .references(() => SessionTable.id, { onDelete: "cascade" }), + baseline: text({ mode: "json" }).notNull().$type>(), + checkpoint: text({ mode: "json" }).notNull().$type(), + baseline_seq: integer().notNull(), + replacement_pending: integer({ mode: "boolean" }).notNull().default(false), + replacement_seq: integer(), + revision: integer().notNull().default(0), +}) + +export const SessionContextMessageTable = sqliteTable( + "session_context_message", + { + session_id: text() + .$type() + .notNull() + .references(() => SessionTable.id, { onDelete: "cascade" }), + seq: integer().notNull(), + parts: text({ mode: "json" }).notNull().$type>(), + }, + (table) => [ + primaryKey({ columns: [table.session_id, table.seq] }), + index("session_context_message_session_seq_idx").on(table.session_id, table.seq), + ], +) diff --git a/packages/core/src/session/store.ts b/packages/core/src/session/store.ts index 8660202da9..b262a77495 100644 --- a/packages/core/src/session/store.ts +++ b/packages/core/src/session/store.ts @@ -13,6 +13,9 @@ import { fromRow } from "./info" export interface Interface { readonly get: (sessionID: SessionSchema.ID) => Effect.Effect readonly context: (sessionID: SessionSchema.ID) => Effect.Effect + readonly runnerContext: ( + sessionID: SessionSchema.ID, + ) => Effect.Effect readonly message: ( messageID: SessionMessage.ID, ) => Effect.Effect<{ readonly sessionID: SessionSchema.ID; readonly message: SessionMessage.Message } | undefined> @@ -34,6 +37,9 @@ export const layer = Layer.effect( context: Effect.fn("SessionStore.context")(function* (sessionID) { return yield* SessionContext.load(db, sessionID) }), + runnerContext: Effect.fn("SessionStore.runnerContext")(function* (sessionID) { + return yield* SessionContext.loadForRunner(db, sessionID) + }), message: Effect.fn("SessionStore.message")(function* (messageID) { const row = yield* db .select() diff --git a/packages/core/src/system-context.ts b/packages/core/src/system-context.ts index c40f995ed2..ac52cfda91 100644 --- a/packages/core/src/system-context.ts +++ b/packages/core/src/system-context.ts @@ -49,6 +49,13 @@ export interface Part { readonly text: string } +export const PartSchema = Schema.Struct({ + key: Key, + text: Schema.String, +}) +export const PartsSchema = Schema.Array(PartSchema) +export const CheckpointSchema = Schema.Record(Schema.String, Schema.String) + export type Checkpoint = Readonly> export interface Initialized { @@ -105,11 +112,18 @@ export function initialize(snapshot: Snapshot): Initialized { export function refresh(snapshot: Snapshot, previous: Checkpoint): Refreshed { return { - changes: snapshot.entries.flatMap((entry) => - entry._tag === "Available" && getCheckpoint(previous, entry.key) !== entry.hash - ? [{ key: entry.key, text: entry.update }] - : [], - ), + changes: [ + ...snapshot.entries.flatMap((entry) => + entry._tag === "Available" && getCheckpoint(previous, entry.key) !== entry.hash + ? [{ key: entry.key, text: entry.update }] + : [], + ), + ...Object.keys(previous).flatMap((key) => + snapshot.entries.some((entry) => entry.key === key) + ? [] + : [{ key: Key.make(key), text: `System context component removed: ${key}` }], + ), + ], checkpoint: nextCheckpoint(snapshot, previous), } } diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 6b0f1d2692..366ea7e495 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -63,15 +63,22 @@ describe("DatabaseMigration", () => { expect( yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`), ).toEqual({ name: "session_input" }) - expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 30 }) + expect( + yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_context_epoch'`), + ).toEqual({ name: "session_context_epoch" }) + expect( + yield* db.get(sql`SELECT 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: 34 }) expect( 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_context_message_session_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`, ), ).toEqual([ { name: "event_aggregate_seq_idx" }, { name: "event_aggregate_type_seq_idx" }, { name: "session_input_session_admitted_seq_idx" }, + { name: "session_context_message_session_seq_idx" }, { name: "session_input_session_pending_delivery_seq_idx" }, { name: "session_input_session_promoted_seq_idx" }, { name: "session_message_session_seq_idx" }, diff --git a/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json b/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json index bad659f93e..0e452bb618 100644 --- a/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json +++ b/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json @@ -13,7 +13,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}" + "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Recorded context\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}" }, "response": { "status": 200, diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 775e744d84..259e0a2816 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -9,6 +9,7 @@ import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message" import { SessionV2 } from "@opencode-ai/core/session" import { ToolOutput } from "@opencode-ai/core/tool-output" import { DateTime } from "effect" +import { SystemContext } from "@opencode-ai/core/system-context" const created = DateTime.makeUnsafe(0) const id = (value: string) => SessionMessage.ID.make(`msg_${value}`) @@ -86,6 +87,15 @@ describe("toLLMMessages", () => { ]) }) + test("maps hidden Session context updates into chronological system messages", () => { + expect( + toLLMMessages( + [{ type: "system-context", parts: [{ key: SystemContext.Key.make("test/context"), text: "Updated context" }] }], + model, + ), + ).toEqual([Message.system("Updated context")]) + }) + test("expands assistant tool calls and settled outcomes into canonical tool messages", () => { const messages = toLLMMessages( [ diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 53a89efa61..edeb04ff6b 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -19,6 +19,9 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" +import { SessionSystemContext } from "@opencode-ai/core/session-system-context" +import { SystemContext } from "@opencode-ai/core/system-context" +import { Hash } from "@opencode-ai/core/util/hash" import { describe, expect } from "bun:test" import { eq } from "drizzle-orm" import { Effect, Layer } from "effect" @@ -55,6 +58,23 @@ const model = OpenAIChat.route }) .model({ id: "gpt-4o-mini" }) const models = SessionRunnerModel.layerWith(() => Effect.succeed(model)) +const systemContext = Layer.succeed( + SessionSystemContext.Service, + SessionSystemContext.Service.of({ + load: () => + Effect.succeed({ + entries: [ + { + _tag: "Available" as const, + key: SystemContext.Key.make("test/context"), + baseline: "Recorded context", + update: "Recorded context", + hash: Hash.sha256("Recorded context"), + }, + ], + }), + }), +) const runner = SessionRunnerLLM.defaultLayer.pipe( Layer.provide(database), Layer.provide(store), @@ -62,6 +82,7 @@ const runner = SessionRunnerLLM.defaultLayer.pipe( Layer.provide(client), Layer.provide(registry), Layer.provide(models), + Layer.provide(systemContext), ) const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner)) const execution = Layer.effect( @@ -88,6 +109,7 @@ const it = testEffect( permission, registry, models, + systemContext, runner, coordinator, execution, @@ -145,6 +167,7 @@ describe("SessionRunnerLLM recorded", () => { ).toEqual([ "session.next.prompt.admitted.1", "session.next.prompt.promoted.1", + "session.next.context.initialized.1", "session.next.step.started.1", "session.next.text.started.1", "session.next.text.ended.1", diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 8607ac33b6..1f8d4a2317 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -34,6 +34,9 @@ import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { NativeTool } from "@opencode-ai/core/tool/native" import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" +import { SessionSystemContext } from "@opencode-ai/core/session-system-context" +import { SystemContext } from "@opencode-ai/core/system-context" +import { Hash } from "@opencode-ai/core/util/hash" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { Cause, DateTime, Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect" @@ -135,6 +138,28 @@ const echo = Layer.effectDiscard( ), ).pipe(Layer.provide(registry)) const models = SessionRunnerModel.layerWith(() => Effect.succeed(model)) +const systemContextKey = SystemContext.Key.make("test/context") +let systemBaseline = "Initial context" +let systemRemoved = false +const systemContext = Layer.succeed( + SessionSystemContext.Service, + SessionSystemContext.Service.of({ + load: () => + Effect.sync(() => ({ + entries: systemRemoved + ? [] + : [ + { + _tag: "Available" as const, + key: systemContextKey, + baseline: systemBaseline, + update: systemBaseline, + hash: Hash.sha256(systemBaseline), + }, + ], + })), + }), +) const runner = SessionRunnerLLM.layer.pipe( Layer.provide(database), Layer.provide(store), @@ -142,6 +167,7 @@ const runner = SessionRunnerLLM.layer.pipe( Layer.provide(client), Layer.provide(registry), Layer.provide(models), + Layer.provide(systemContext), ) const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner)) const execution = Layer.effect( @@ -170,6 +196,7 @@ const it = testEffect( registry, echo, models, + systemContext, runner, coordinator, execution, @@ -200,6 +227,8 @@ const insertSession = (id: SessionV2.ID) => const setup = Effect.gen(function* () { const { db } = yield* Database.Service response = [] + systemBaseline = "Initial context" + systemRemoved = false responses = undefined streamFailure = undefined responseStream = undefined @@ -511,6 +540,173 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("reuses one durable baseline after the context producer changes", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + + requests.length = 0 + response = [] + yield* session.resume(sessionID) + systemBaseline = "Changed context" + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.resume(sessionID) + + expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ + ["Initial context"], + ["Initial context"], + ]) + expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"]) + expect(requests[1]?.messages.at(-1)?.content).toEqual([{ type: "text", text: "Changed context" }]) + expect(yield* session.messages({ sessionID })).toHaveLength(2) + const { db } = yield* Database.Service + expect( + yield* db + .select({ id: EventTable.id }) + .from(EventTable) + .where(eq(EventTable.type, "session.next.context.initialized.1")) + .all() + .pipe(Effect.orDie), + ).toHaveLength(1) + expect( + yield* db + .select({ id: EventTable.id }) + .from(EventTable) + .where(eq(EventTable.type, "session.next.context.updated.1")) + .all() + .pipe(Effect.orDie), + ).toHaveLength(1) + yield* replaySessionProjection(sessionID) + expect(yield* session.messages({ sessionID })).toHaveLength(2) + }), + ) + + it.effect("admits removed context as a hidden chronological tombstone", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + + requests.length = 0 + response = [] + yield* session.resume(sessionID) + systemRemoved = true + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.resume(sessionID) + + expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"]) + expect(requests[1]?.messages.at(-1)?.content).toEqual([ + { type: "text", text: "System context component removed: test/context" }, + ]) + expect(yield* session.messages({ sessionID })).toHaveLength(2) + }), + ) + + it.effect("replaces the baseline lazily after a model switch and drops prior hidden updates", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + + requests.length = 0 + response = [] + yield* session.resume(sessionID) + systemBaseline = "Changed context" + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.resume(sessionID) + yield* events.publish(SessionEvent.ModelSwitched, { + sessionID, + timestamp: DateTime.makeUnsafe(1), + model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, + }) + systemBaseline = "Replacement context" + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false }) + yield* session.resume(sessionID) + + expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ + ["Initial context"], + ["Initial context"], + ["Replacement context"], + ]) + expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"]) + expect(requests[2]?.messages.map((message) => message.role)).toEqual(["user", "user", "user"]) + const { db } = yield* Database.Service + expect( + yield* db + .select({ id: EventTable.id }) + .from(EventTable) + .where(eq(EventTable.type, "session.next.context.replaced.1")) + .all() + .pipe(Effect.orDie), + ).toHaveLength(1) + yield* replaySessionProjection(sessionID) + expect(yield* session.messages({ sessionID })).toHaveLength(4) + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fourth" }), resume: false }) + yield* session.resume(sessionID) + expect( + yield* db + .select({ id: EventTable.id }) + .from(EventTable) + .where(eq(EventTable.type, "session.next.context.replaced.1")) + .all() + .pipe(Effect.orDie), + ).toHaveLength(1) + }), + ) + + it.effect("replaces the baseline lazily after completed compaction without reopening replacement on replay", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + + requests.length = 0 + response = [] + yield* session.resume(sessionID) + yield* events.publish(SessionEvent.Compaction.Started, { + sessionID, + timestamp: DateTime.makeUnsafe(1), + reason: "manual", + }) + yield* events.publish(SessionEvent.Compaction.Ended, { + sessionID, + timestamp: DateTime.makeUnsafe(2), + text: "summary", + }) + systemBaseline = "Replacement context" + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.resume(sessionID) + + expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ + ["Initial context"], + ["Replacement context"], + ]) + const { db } = yield* Database.Service + expect( + yield* db + .select({ id: EventTable.id }) + .from(EventTable) + .where(eq(EventTable.type, "session.next.context.replaced.1")) + .all() + .pipe(Effect.orDie), + ).toHaveLength(1) + yield* replaySessionProjection(sessionID) + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false }) + yield* session.resume(sessionID) + expect( + yield* db + .select({ id: EventTable.id }) + .from(EventTable) + .where(eq(EventTable.type, "session.next.context.replaced.1")) + .all() + .pipe(Effect.orDie), + ).toHaveLength(1) + }), + ) + it.effect("projects reasoning and tool events without executing or continuing tools", () => Effect.gen(function* () { yield* setup diff --git a/packages/core/test/system-context.test.ts b/packages/core/test/system-context.test.ts index e15843e63b..406d4f8c62 100644 --- a/packages/core/test/system-context.test.ts +++ b/packages/core/test/system-context.test.ts @@ -112,7 +112,7 @@ describe("SystemContext", () => { expect(refreshed).toEqual({ changes: [], checkpoint: previous }) }) - test("drops checkpoints for removed components", async () => { + test("emits tombstones and drops checkpoints for removed components", async () => { const context = SystemContext.struct({ date: SystemContext.value({ key: key("core/date"), @@ -126,7 +126,7 @@ describe("SystemContext", () => { }) expect(refreshed).toEqual({ - changes: [], + changes: [{ key: key("plugin/removed"), text: "System context component removed: plugin/removed" }], checkpoint: { "core/date": Hash.sha256("The current date is 2026-06-03.") }, }) }) diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index 330089ae95..c83cd7288b 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -351,38 +351,17 @@ const endsInServerToolUse = (message: LLMRequest["messages"][number]) => { return message.role === "assistant" && last?.type === "tool-call" && last.providerExecuted === true } -const endsInLocalToolUse = (message: LLMRequest["messages"][number]) => { - const last = message.content.at(-1) - return message.role === "assistant" && last?.type === "tool-call" && last.providerExecuted !== true -} - -const validateNativeSystemUpdate = Effect.fn("AnthropicMessages.validateNativeSystemUpdate")(function* ( - messages: LLMRequest["messages"], - index: number, -) { +const canUseNativeSystemUpdate = (messages: LLMRequest["messages"], index: number) => { const previous = messages[index - 1] const next = messages[index + 1] - if (!previous) - return yield* invalid( - "Anthropic Messages chronological system updates cannot be the first message; use LLMRequest.system", - ) - if (previous.role === "system") - return yield* invalid("Anthropic Messages chronological system updates cannot be consecutive") - if (endsInLocalToolUse(previous)) - return yield* invalid( - "Anthropic Messages chronological system updates cannot appear between a local tool call and its tool result", - ) - if (previous.role !== "user" && previous.role !== "tool" && !endsInServerToolUse(previous)) - return yield* invalid( - "Anthropic Messages chronological system updates must follow a user message, tool result, or assistant server tool use", - ) - if (next?.role === "system") - return yield* invalid("Anthropic Messages chronological system updates cannot be consecutive") - if (next && next.role !== "assistant") - return yield* invalid( - "Anthropic Messages chronological system updates must end the messages array or immediately precede an assistant message", - ) -}) + return ( + previous !== undefined && + previous.role !== "system" && + (previous.role === "user" || previous.role === "tool" || endsInServerToolUse(previous)) && + next?.role !== "system" && + (next === undefined || next.role === "assistant") + ) +} const lowerNativeSystemUpdate = Effect.fn("AnthropicMessages.lowerNativeSystemUpdate")(function* ( message: LLMRequest["messages"][number], @@ -407,8 +386,8 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* ( for (const [index, message] of request.messages.entries()) { if (message.role === "system") { - if (supportsNativeSystemUpdates(request)) { - yield* validateNativeSystemUpdate(request.messages, index) + yield* ProviderShared.guardSystemUpdatePlacement("Anthropic Messages", request.messages[index - 1]) + if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request.messages, index)) { messages.push(yield* lowerNativeSystemUpdate(message, breakpoints)) continue } diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index 2b3a2e9510..52affab7b9 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -292,8 +292,9 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* ( ) { const messages: BedrockMessage[] = [] - for (const message of request.messages) { + for (const [index, message] of request.messages.entries()) { if (message.role === "system") { + yield* ProviderShared.guardSystemUpdatePlacement("Bedrock Converse", request.messages[index - 1]) const part = yield* ProviderShared.wrappedSystemUpdate("Bedrock Converse", message) const content = textWithCache(breakpoints, part.text, part.cache) const previous = messages.at(-1) diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts index 93159a1b64..5b3487b026 100644 --- a/packages/llm/src/protocols/gemini.ts +++ b/packages/llm/src/protocols/gemini.ts @@ -200,8 +200,9 @@ const lowerToolCall = (part: ToolCallPart) => ({ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) { const contents: GeminiContent[] = [] - for (const message of request.messages) { + for (const [index, message] of request.messages.entries()) { if (message.role === "system") { + yield* ProviderShared.guardSystemUpdatePlacement("Gemini", request.messages[index - 1]) const part = yield* ProviderShared.wrappedSystemUpdate("Gemini", message) const previous = contents.at(-1) if (previous?.role === "user") diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index c0769bf1f6..00ad65c967 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -252,8 +252,9 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: const system: OpenAIChatMessage[] = request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }] const messages = [...system] - for (const message of request.messages) { + for (const [index, message] of request.messages.entries()) { if (message.role === "system") { + yield* ProviderShared.guardSystemUpdatePlacement("OpenAI Chat", request.messages[index - 1]) const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message) const previous = messages.at(-1) if (previous?.role === "user") diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 06d07f8ccb..400f4e7a6b 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -338,8 +338,9 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ const input: OpenAIResponsesInputItem[] = [...system] const store = OpenAIOptions.store(request) - for (const message of request.messages) { + for (const [index, message] of request.messages.entries()) { if (message.role === "system") { + yield* ProviderShared.guardSystemUpdatePlacement("OpenAI Responses", request.messages[index - 1]) const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Responses", message) const previous = input.at(-1) if (previous && "role" in previous && previous.role === "user") diff --git a/packages/llm/src/protocols/shared.ts b/packages/llm/src/protocols/shared.ts index 1bcd8d4dcb..98323a7ffe 100644 --- a/packages/llm/src/protocols/shared.ts +++ b/packages/llm/src/protocols/shared.ts @@ -177,6 +177,20 @@ export const wrappedSystemUpdate = Effect.fn("ProviderShared.wrappedSystemUpdate return { type: "text" as const, text: wrapSystemUpdate(content), cache: content.at(-1)?.cache } }) +export const guardSystemUpdatePlacement = Effect.fn("ProviderShared.guardSystemUpdatePlacement")(function* ( + route: string, + previous: LLMRequest["messages"][number] | undefined, +) { + if ( + previous?.role === "assistant" && + previous.content.some((part) => part.type === "tool-call" && part.providerExecuted !== true) + ) + return yield* invalidRequest( + `${route} chronological system updates cannot appear between a local tool call and its tool result`, + ) + return yield* Effect.void +}) + /** * Parse the streamed JSON input of a tool call. Treats an empty string as * `"{}"` — providers occasionally finish a tool call without ever emitting diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index 50fced0de3..f665ff049d 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -125,22 +125,56 @@ describe("Anthropic Messages route", () => { }), ) - it.effect("rejects invalid native chronological system update placement", () => + it.effect("falls back for unsupported native chronological system update placement", () => Effect.gen(function* () { const placementError = (messages: Parameters[0]["messages"]) => LLMClient.prepare(LLM.request({ model: opus48, messages, cache: "none" })).pipe(Effect.flip) - expect((yield* placementError([Message.system("First.")])).message).toContain("cannot be the first message") expect( - (yield* placementError([Message.user("Before."), Message.system("One."), Message.system("Two.")])).message, - ).toContain("cannot be consecutive") + (yield* LLMClient.prepare( + LLM.request({ + model: opus48, + messages: [Message.assistant("Plain."), Message.system("After plain assistant.")], + cache: "none", + }), + )).body.messages, + ).toEqual([ + { role: "assistant", content: [{ type: "text", text: "Plain." }] }, + { + role: "user", + content: [{ type: "text", text: "\nAfter plain assistant.\n" }], + }, + ]) expect( - (yield* placementError([Message.assistant("Plain."), Message.system("After plain assistant.")])).message, - ).toContain("must follow a user message, tool result, or assistant server tool use") + (yield* LLMClient.prepare( + LLM.request({ model: opus48, messages: [Message.system("First.")], cache: "none" }), + )).body.messages, + ).toEqual([{ role: "user", content: [{ type: "text", text: "\nFirst.\n" }] }]) + expect( + (yield* LLMClient.prepare( + LLM.request({ + model: opus48, + messages: [Message.user("Before."), Message.system("One."), Message.system("Two.")], + cache: "none", + }), + )).body.messages, + ).toEqual([ + { + role: "user", + content: [ + { type: "text", text: "Before." }, + { type: "text", text: "\nOne.\n" }, + { type: "text", text: "\nTwo.\n" }, + ], + }, + ]) expect( (yield* placementError([ Message.user("Use the tool."), - Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]), + Message.assistant([ + ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }), + { type: "text", text: "Waiting." }, + ]), Message.system("Too early."), Message.tool({ id: "call_1", name: "lookup", result: "Done." }), ])).message, diff --git a/packages/llm/test/schema.test.ts b/packages/llm/test/schema.test.ts index ff1bc448b9..b824544d0b 100644 --- a/packages/llm/test/schema.test.ts +++ b/packages/llm/test/schema.test.ts @@ -1,8 +1,18 @@ import { describe, expect, test } from "bun:test" -import { Schema } from "effect" +import { Effect, Schema } from "effect" import * as OpenAIChat from "../src/protocols/openai-chat" import * as OpenAIResponses from "../src/protocols/openai-responses" -import { ContentPart, LLMEvent, LLMRequest, Model, ModelID, ProviderID, Usage } from "../src/schema" +import { + ContentPart, + LLMEvent, + LLMRequest, + Message, + Model, + ModelID, + ProviderID, + ToolCallPart, + Usage, +} from "../src/schema" import { ProviderShared } from "../src/protocols/shared" const model = new Model({ @@ -54,6 +64,17 @@ describe("llm schema", () => { expect(decoded.messages[0]).toMatchObject({ role: "system", content: [{ type: "text", text: "Operator update." }] }) }) + test("rejects chronological system updates between a local tool call and its result", async () => { + const previous = Message.assistant([ + ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }), + { type: "text", text: "Waiting." }, + ]) + + await expect(Effect.runPromise(ProviderShared.guardSystemUpdatePlacement("Test", previous))).rejects.toThrow( + "Test chronological system updates cannot appear between a local tool call and its tool result", + ) + }) + test("rejects invalid event type", () => { expect(() => decodeLLMEvent({ type: "bogus" })).toThrow() }) diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 33a619b222..a2db44a13a 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -686,3 +686,65 @@ Compatibility: - Foreground V2 bash execution is unchanged. - Reintroduce background bash only with durable status observation, completion delivery, and explicit cancellation semantics. + +## 2026-06-04: Initialize Durable Session Context Epochs + +Affected schema: + +- Add synchronized `session.next.context.initialized.1` Session events. +- Add `session_context_epoch` for one active immutable keyed baseline, component-hash checkpoint, and baseline sequence per Session. + +Change: + +- Lazily initialize one durable Context Epoch at the first safe provider-turn boundary. +- Lower its exact keyed baseline parts through `LLMRequest.system` for every provider turn in the epoch. +- Reuse the stored baseline verbatim after restart or producer changes instead of resampling privileged initial context. +- Keep ordinary Session transcript APIs unchanged. + +Compatibility: + +- This adds one database migration and one synchronized Session event type. +- Existing experimental V2 Session databases remain disposable across incompatible pre-launch event-schema changes. +- Chronological context updates, replacement epochs after compaction or model switches, project instructions, skills guidance, and plugin transforms remain follow-up slices. + +## 2026-06-04: Admit Chronological Session Context Updates + +Affected schema: + +- Add synchronized `session.next.context.updated.1` Session events. +- Add `session_context_epoch.revision` for transactional checkpoint advancement. +- Add `session_context_message` for hidden chronological keyed context updates ordered by Session aggregate sequence. + +Change: + +- Refresh Location-scoped Context Components at each safe provider-turn boundary. +- Keep the stored baseline immutable while admitting changed component values as runner-private chronological `Message.system(...)` history. +- Advance component-hash checkpoints transactionally even when a removed component produces no visible update text. +- Keep ordinary Session transcript APIs unchanged while runner history merges visible Session messages and hidden context updates by durable aggregate sequence. +- Reject chronological system updates that would split a local tool call from its result across provider protocols; use wrapped user fallback when Anthropic native system-update placement is unsupported. + +Compatibility: + +- This adds one database migration and one synchronized Session event type. +- 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. + +## 2026-06-04: Replace Session Context Epochs Lazily + +Affected schema: + +- Add synchronized `session.next.context.replaced.1` Session events. +- Add `session_context_epoch.replacement_pending` and `session_context_epoch.replacement_seq` for idempotent lazy replacement requests. + +Change: + +- Mark the active Context Epoch for replacement after a model switch or completed compaction projection. +- Persist the triggering aggregate sequence so same-target replay cannot reopen an already-settled replacement. +- Render and persist the fresh immutable baseline lazily at the next safe provider-turn boundary. +- Exclude hidden chronological updates from earlier epochs when assembling active provider history. + +Compatibility: + +- This adds two additive database migrations and one synchronized Session event type. +- 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. From 03974ba0e6d0443e793781f764aa139f66265a92 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 4 Jun 2026 14:49:05 -0400 Subject: [PATCH 02/17] fix(core): harden v2 session context epochs --- .../migration.sql | 7 - .../snapshot.json | 2026 ---------------- .../migration.sql | 10 - .../migration.sql | 1 - .../snapshot.json | 2123 ---------------- .../migration.sql | 1 - .../snapshot.json | 2133 ----------------- .../migration.sql | 17 + .../snapshot.json | 32 +- packages/core/src/database/migration.gen.ts | 5 +- ...0260604180746_add_session_context_epoch.ts | 19 - ...4181706_add_session_context_replacement.ts | 11 - ...dd_session_context_replacement_sequence.ts | 11 - ...260604184448_add_session_context_epoch.ts} | 17 +- packages/core/src/session/context-epoch.ts | 44 +- packages/core/src/session/context.ts | 16 +- packages/core/src/session/runner/llm.ts | 18 +- .../core/src/session/runner/to-llm-message.ts | 3 +- packages/core/src/session/sql.ts | 6 +- packages/core/src/session/store.ts | 5 +- packages/core/src/system-context.ts | 12 +- packages/core/test/database-migration.test.ts | 5 +- .../core/test/session-runner-message.test.ts | 17 +- packages/core/test/session-runner.test.ts | 173 +- packages/core/test/system-context.test.ts | 27 +- .../llm/src/protocols/anthropic-messages.ts | 2 +- .../llm/src/protocols/bedrock-converse.ts | 2 +- packages/llm/src/protocols/gemini.ts | 2 +- packages/llm/src/protocols/openai-chat.ts | 2 +- .../llm/src/protocols/openai-responses.ts | 2 +- packages/llm/src/protocols/shared.ts | 16 +- packages/llm/test/schema.test.ts | 26 +- specs/v2/schema-changelog.md | 10 +- 33 files changed, 340 insertions(+), 6461 deletions(-) delete mode 100644 packages/core/migration/20260604180746_add_session_context_epoch/migration.sql delete mode 100644 packages/core/migration/20260604180746_add_session_context_epoch/snapshot.json delete mode 100644 packages/core/migration/20260604181329_add_session_context_updates/migration.sql delete mode 100644 packages/core/migration/20260604181706_add_session_context_replacement/migration.sql delete mode 100644 packages/core/migration/20260604181706_add_session_context_replacement/snapshot.json delete mode 100644 packages/core/migration/20260604181807_add_session_context_replacement_sequence/migration.sql delete mode 100644 packages/core/migration/20260604181807_add_session_context_replacement_sequence/snapshot.json create mode 100644 packages/core/migration/20260604184448_add_session_context_epoch/migration.sql rename packages/core/migration/{20260604181329_add_session_context_updates => 20260604184448_add_session_context_epoch}/snapshot.json (98%) delete mode 100644 packages/core/src/database/migration/20260604180746_add_session_context_epoch.ts delete mode 100644 packages/core/src/database/migration/20260604181706_add_session_context_replacement.ts delete mode 100644 packages/core/src/database/migration/20260604181807_add_session_context_replacement_sequence.ts rename packages/core/src/database/migration/{20260604181329_add_session_context_updates.ts => 20260604184448_add_session_context_epoch.ts} (54%) diff --git a/packages/core/migration/20260604180746_add_session_context_epoch/migration.sql b/packages/core/migration/20260604180746_add_session_context_epoch/migration.sql deleted file mode 100644 index 7aaf9ecb79..0000000000 --- a/packages/core/migration/20260604180746_add_session_context_epoch/migration.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE TABLE `session_context_epoch` ( - `session_id` text PRIMARY KEY, - `baseline` text NOT NULL, - `checkpoint` text NOT NULL, - `baseline_seq` integer NOT NULL, - CONSTRAINT `fk_session_context_epoch_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE -); diff --git a/packages/core/migration/20260604180746_add_session_context_epoch/snapshot.json b/packages/core/migration/20260604180746_add_session_context_epoch/snapshot.json deleted file mode 100644 index 7ff48041b3..0000000000 --- a/packages/core/migration/20260604180746_add_session_context_epoch/snapshot.json +++ /dev/null @@ -1,2026 +0,0 @@ -{ - "version": "7", - "dialect": "sqlite", - "id": "f08074bb-2b27-4365-924c-5a4eae9a5194", - "prevIds": [ - "fc92fa34-8074-44c3-88f0-a5417f7fd92d" - ], - "ddl": [ - { - "name": "workspace", - "entityType": "tables" - }, - { - "name": "data_migration", - "entityType": "tables" - }, - { - "name": "account_state", - "entityType": "tables" - }, - { - "name": "account", - "entityType": "tables" - }, - { - "name": "control_account", - "entityType": "tables" - }, - { - "name": "event_sequence", - "entityType": "tables" - }, - { - "name": "event", - "entityType": "tables" - }, - { - "name": "permission", - "entityType": "tables" - }, - { - "name": "project_directory", - "entityType": "tables" - }, - { - "name": "project", - "entityType": "tables" - }, - { - "name": "message", - "entityType": "tables" - }, - { - "name": "part", - "entityType": "tables" - }, - { - "name": "session_context_epoch", - "entityType": "tables" - }, - { - "name": "session_input", - "entityType": "tables" - }, - { - "name": "session_message", - "entityType": "tables" - }, - { - "name": "session", - "entityType": "tables" - }, - { - "name": "todo", - "entityType": "tables" - }, - { - "name": "session_share", - "entityType": "tables" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "workspace" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "type", - "entityType": "columns", - "table": "workspace" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": "''", - "generated": null, - "name": "name", - "entityType": "columns", - "table": "workspace" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "branch", - "entityType": "columns", - "table": "workspace" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "directory", - "entityType": "columns", - "table": "workspace" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "extra", - "entityType": "columns", - "table": "workspace" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "project_id", - "entityType": "columns", - "table": "workspace" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_used", - "entityType": "columns", - "table": "workspace" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "name", - "entityType": "columns", - "table": "data_migration" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_completed", - "entityType": "columns", - "table": "data_migration" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "account_state" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "active_account_id", - "entityType": "columns", - "table": "account_state" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "active_org_id", - "entityType": "columns", - "table": "account_state" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "account" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "email", - "entityType": "columns", - "table": "account" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "url", - "entityType": "columns", - "table": "account" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "access_token", - "entityType": "columns", - "table": "account" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "refresh_token", - "entityType": "columns", - "table": "account" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "token_expiry", - "entityType": "columns", - "table": "account" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "account" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "account" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "email", - "entityType": "columns", - "table": "control_account" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "url", - "entityType": "columns", - "table": "control_account" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "access_token", - "entityType": "columns", - "table": "control_account" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "refresh_token", - "entityType": "columns", - "table": "control_account" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "token_expiry", - "entityType": "columns", - "table": "control_account" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "active", - "entityType": "columns", - "table": "control_account" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "control_account" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "control_account" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "aggregate_id", - "entityType": "columns", - "table": "event_sequence" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "seq", - "entityType": "columns", - "table": "event_sequence" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "owner_id", - "entityType": "columns", - "table": "event_sequence" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "event" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "aggregate_id", - "entityType": "columns", - "table": "event" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "seq", - "entityType": "columns", - "table": "event" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "type", - "entityType": "columns", - "table": "event" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "data", - "entityType": "columns", - "table": "event" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "permission" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "project_id", - "entityType": "columns", - "table": "permission" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "action", - "entityType": "columns", - "table": "permission" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "resource", - "entityType": "columns", - "table": "permission" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "permission" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "permission" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "project_id", - "entityType": "columns", - "table": "project_directory" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "directory", - "entityType": "columns", - "table": "project_directory" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "type", - "entityType": "columns", - "table": "project_directory" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "project_directory" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "project" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "worktree", - "entityType": "columns", - "table": "project" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "vcs", - "entityType": "columns", - "table": "project" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "name", - "entityType": "columns", - "table": "project" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "icon_url", - "entityType": "columns", - "table": "project" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "icon_url_override", - "entityType": "columns", - "table": "project" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "icon_color", - "entityType": "columns", - "table": "project" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "project" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "project" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_initialized", - "entityType": "columns", - "table": "project" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "sandboxes", - "entityType": "columns", - "table": "project" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "commands", - "entityType": "columns", - "table": "project" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "message" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "session_id", - "entityType": "columns", - "table": "message" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "message" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "message" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "data", - "entityType": "columns", - "table": "message" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "part" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "message_id", - "entityType": "columns", - "table": "part" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "session_id", - "entityType": "columns", - "table": "part" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "part" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "part" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "data", - "entityType": "columns", - "table": "part" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "session_id", - "entityType": "columns", - "table": "session_context_epoch" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "baseline", - "entityType": "columns", - "table": "session_context_epoch" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "checkpoint", - "entityType": "columns", - "table": "session_context_epoch" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "baseline_seq", - "entityType": "columns", - "table": "session_context_epoch" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": true, - "default": null, - "generated": null, - "name": "seq", - "entityType": "columns", - "table": "session_input" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "session_input" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "session_id", - "entityType": "columns", - "table": "session_input" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "prompt", - "entityType": "columns", - "table": "session_input" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "delivery", - "entityType": "columns", - "table": "session_input" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "promoted_seq", - "entityType": "columns", - "table": "session_input" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "session_input" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "session_message" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "session_id", - "entityType": "columns", - "table": "session_message" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "type", - "entityType": "columns", - "table": "session_message" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "seq", - "entityType": "columns", - "table": "session_message" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "session_message" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "session_message" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "data", - "entityType": "columns", - "table": "session_message" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "project_id", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "workspace_id", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "parent_id", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "slug", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "directory", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "path", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "title", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "version", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "share_url", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "summary_additions", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "summary_deletions", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "summary_files", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "summary_diffs", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "metadata", - "entityType": "columns", - "table": "session" - }, - { - "type": "real", - "notNull": true, - "autoincrement": false, - "default": "0", - "generated": null, - "name": "cost", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "0", - "generated": null, - "name": "tokens_input", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "0", - "generated": null, - "name": "tokens_output", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "0", - "generated": null, - "name": "tokens_reasoning", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "0", - "generated": null, - "name": "tokens_cache_read", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "0", - "generated": null, - "name": "tokens_cache_write", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "revert", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "permission", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "agent", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "model", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_compacting", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_archived", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "session_id", - "entityType": "columns", - "table": "todo" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "content", - "entityType": "columns", - "table": "todo" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "status", - "entityType": "columns", - "table": "todo" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "priority", - "entityType": "columns", - "table": "todo" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "position", - "entityType": "columns", - "table": "todo" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "todo" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "todo" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "session_id", - "entityType": "columns", - "table": "session_share" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "session_share" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "secret", - "entityType": "columns", - "table": "session_share" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "url", - "entityType": "columns", - "table": "session_share" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "session_share" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "session_share" - }, - { - "columns": [ - "project_id" - ], - "tableTo": "project", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_workspace_project_id_project_id_fk", - "entityType": "fks", - "table": "workspace" - }, - { - "columns": [ - "active_account_id" - ], - "tableTo": "account", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "SET NULL", - "nameExplicit": false, - "name": "fk_account_state_active_account_id_account_id_fk", - "entityType": "fks", - "table": "account_state" - }, - { - "columns": [ - "aggregate_id" - ], - "tableTo": "event_sequence", - "columnsTo": [ - "aggregate_id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", - "entityType": "fks", - "table": "event" - }, - { - "columns": [ - "project_id" - ], - "tableTo": "project", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_permission_project_id_project_id_fk", - "entityType": "fks", - "table": "permission" - }, - { - "columns": [ - "project_id" - ], - "tableTo": "project", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_project_directory_project_id_project_id_fk", - "entityType": "fks", - "table": "project_directory" - }, - { - "columns": [ - "session_id" - ], - "tableTo": "session", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_message_session_id_session_id_fk", - "entityType": "fks", - "table": "message" - }, - { - "columns": [ - "message_id" - ], - "tableTo": "message", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_part_message_id_message_id_fk", - "entityType": "fks", - "table": "part" - }, - { - "columns": [ - "session_id" - ], - "tableTo": "session", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_session_context_epoch_session_id_session_id_fk", - "entityType": "fks", - "table": "session_context_epoch" - }, - { - "columns": [ - "session_id" - ], - "tableTo": "session", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_session_input_session_id_session_id_fk", - "entityType": "fks", - "table": "session_input" - }, - { - "columns": [ - "session_id" - ], - "tableTo": "session", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_session_message_session_id_session_id_fk", - "entityType": "fks", - "table": "session_message" - }, - { - "columns": [ - "project_id" - ], - "tableTo": "project", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_session_project_id_project_id_fk", - "entityType": "fks", - "table": "session" - }, - { - "columns": [ - "session_id" - ], - "tableTo": "session", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_todo_session_id_session_id_fk", - "entityType": "fks", - "table": "todo" - }, - { - "columns": [ - "session_id" - ], - "tableTo": "session", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_session_share_session_id_session_id_fk", - "entityType": "fks", - "table": "session_share" - }, - { - "columns": [ - "email", - "url" - ], - "nameExplicit": false, - "name": "control_account_pk", - "entityType": "pks", - "table": "control_account" - }, - { - "columns": [ - "project_id", - "directory" - ], - "nameExplicit": false, - "name": "project_directory_pk", - "entityType": "pks", - "table": "project_directory" - }, - { - "columns": [ - "session_id", - "position" - ], - "nameExplicit": false, - "name": "todo_pk", - "entityType": "pks", - "table": "todo" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "workspace_pk", - "table": "workspace", - "entityType": "pks" - }, - { - "columns": [ - "name" - ], - "nameExplicit": false, - "name": "data_migration_pk", - "table": "data_migration", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "account_state_pk", - "table": "account_state", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "account_pk", - "table": "account", - "entityType": "pks" - }, - { - "columns": [ - "aggregate_id" - ], - "nameExplicit": false, - "name": "event_sequence_pk", - "table": "event_sequence", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "event_pk", - "table": "event", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "permission_pk", - "table": "permission", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "project_pk", - "table": "project", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "message_pk", - "table": "message", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "part_pk", - "table": "part", - "entityType": "pks" - }, - { - "columns": [ - "session_id" - ], - "nameExplicit": false, - "name": "session_context_epoch_pk", - "table": "session_context_epoch", - "entityType": "pks" - }, - { - "columns": [ - "seq" - ], - "nameExplicit": false, - "name": "session_input_pk", - "table": "session_input", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "session_message_pk", - "table": "session_message", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "session_pk", - "table": "session", - "entityType": "pks" - }, - { - "columns": [ - "session_id" - ], - "nameExplicit": false, - "name": "session_share_pk", - "table": "session_share", - "entityType": "pks" - }, - { - "columns": [ - { - "value": "aggregate_id", - "isExpression": false - }, - { - "value": "seq", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "event_aggregate_seq_idx", - "entityType": "indexes", - "table": "event" - }, - { - "columns": [ - { - "value": "aggregate_id", - "isExpression": false - }, - { - "value": "type", - "isExpression": false - }, - { - "value": "seq", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "event_aggregate_type_seq_idx", - "entityType": "indexes", - "table": "event" - }, - { - "columns": [ - { - "value": "project_id", - "isExpression": false - }, - { - "value": "action", - "isExpression": false - }, - { - "value": "resource", - "isExpression": false - } - ], - "isUnique": true, - "where": null, - "origin": "manual", - "name": "permission_project_action_resource_idx", - "entityType": "indexes", - "table": "permission" - }, - { - "columns": [ - { - "value": "session_id", - "isExpression": false - }, - { - "value": "time_created", - "isExpression": false - }, - { - "value": "id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "message_session_time_created_id_idx", - "entityType": "indexes", - "table": "message" - }, - { - "columns": [ - { - "value": "message_id", - "isExpression": false - }, - { - "value": "id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "part_message_id_id_idx", - "entityType": "indexes", - "table": "part" - }, - { - "columns": [ - { - "value": "session_id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "part_session_idx", - "entityType": "indexes", - "table": "part" - }, - { - "columns": [ - { - "value": "session_id", - "isExpression": false - }, - { - "value": "promoted_seq", - "isExpression": false - }, - { - "value": "delivery", - "isExpression": false - }, - { - "value": "seq", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "session_input_session_pending_delivery_seq_idx", - "entityType": "indexes", - "table": "session_input" - }, - { - "columns": [ - { - "value": "session_id", - "isExpression": false - }, - { - "value": "seq", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "session_message_session_seq_idx", - "entityType": "indexes", - "table": "session_message" - }, - { - "columns": [ - { - "value": "session_id", - "isExpression": false - }, - { - "value": "type", - "isExpression": false - }, - { - "value": "seq", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "session_message_session_type_seq_idx", - "entityType": "indexes", - "table": "session_message" - }, - { - "columns": [ - { - "value": "session_id", - "isExpression": false - }, - { - "value": "time_created", - "isExpression": false - }, - { - "value": "id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "session_message_session_time_created_id_idx", - "entityType": "indexes", - "table": "session_message" - }, - { - "columns": [ - { - "value": "time_created", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "session_message_time_created_idx", - "entityType": "indexes", - "table": "session_message" - }, - { - "columns": [ - { - "value": "project_id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "session_project_idx", - "entityType": "indexes", - "table": "session" - }, - { - "columns": [ - { - "value": "workspace_id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "session_workspace_idx", - "entityType": "indexes", - "table": "session" - }, - { - "columns": [ - { - "value": "parent_id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "session_parent_idx", - "entityType": "indexes", - "table": "session" - }, - { - "columns": [ - { - "value": "session_id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "todo_session_idx", - "entityType": "indexes", - "table": "todo" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "session_input_id_unique", - "entityType": "uniques", - "table": "session_input" - } - ], - "renames": [] -} \ No newline at end of file diff --git a/packages/core/migration/20260604181329_add_session_context_updates/migration.sql b/packages/core/migration/20260604181329_add_session_context_updates/migration.sql deleted file mode 100644 index c44ac8ae4e..0000000000 --- a/packages/core/migration/20260604181329_add_session_context_updates/migration.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE `session_context_message` ( - `session_id` text NOT NULL, - `seq` integer NOT NULL, - `parts` text NOT NULL, - CONSTRAINT `session_context_message_pk` PRIMARY KEY(`session_id`, `seq`), - CONSTRAINT `fk_session_context_message_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE -); ---> statement-breakpoint -ALTER TABLE `session_context_epoch` ADD `revision` integer DEFAULT 0 NOT NULL;--> statement-breakpoint -CREATE INDEX `session_context_message_session_seq_idx` ON `session_context_message` (`session_id`,`seq`); \ No newline at end of file diff --git a/packages/core/migration/20260604181706_add_session_context_replacement/migration.sql b/packages/core/migration/20260604181706_add_session_context_replacement/migration.sql deleted file mode 100644 index 046a793890..0000000000 --- a/packages/core/migration/20260604181706_add_session_context_replacement/migration.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE `session_context_epoch` ADD `replacement_pending` integer DEFAULT false NOT NULL; \ No newline at end of file diff --git a/packages/core/migration/20260604181706_add_session_context_replacement/snapshot.json b/packages/core/migration/20260604181706_add_session_context_replacement/snapshot.json deleted file mode 100644 index c5baf161a0..0000000000 --- a/packages/core/migration/20260604181706_add_session_context_replacement/snapshot.json +++ /dev/null @@ -1,2123 +0,0 @@ -{ - "version": "7", - "dialect": "sqlite", - "id": "5d4aa1b8-68a2-4aca-8890-b40b16679706", - "prevIds": [ - "85667f6a-603f-4041-be9b-783a34eb74d4" - ], - "ddl": [ - { - "name": "workspace", - "entityType": "tables" - }, - { - "name": "data_migration", - "entityType": "tables" - }, - { - "name": "account_state", - "entityType": "tables" - }, - { - "name": "account", - "entityType": "tables" - }, - { - "name": "control_account", - "entityType": "tables" - }, - { - "name": "event_sequence", - "entityType": "tables" - }, - { - "name": "event", - "entityType": "tables" - }, - { - "name": "permission", - "entityType": "tables" - }, - { - "name": "project_directory", - "entityType": "tables" - }, - { - "name": "project", - "entityType": "tables" - }, - { - "name": "message", - "entityType": "tables" - }, - { - "name": "part", - "entityType": "tables" - }, - { - "name": "session_context_epoch", - "entityType": "tables" - }, - { - "name": "session_context_message", - "entityType": "tables" - }, - { - "name": "session_input", - "entityType": "tables" - }, - { - "name": "session_message", - "entityType": "tables" - }, - { - "name": "session", - "entityType": "tables" - }, - { - "name": "todo", - "entityType": "tables" - }, - { - "name": "session_share", - "entityType": "tables" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "workspace" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "type", - "entityType": "columns", - "table": "workspace" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": "''", - "generated": null, - "name": "name", - "entityType": "columns", - "table": "workspace" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "branch", - "entityType": "columns", - "table": "workspace" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "directory", - "entityType": "columns", - "table": "workspace" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "extra", - "entityType": "columns", - "table": "workspace" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "project_id", - "entityType": "columns", - "table": "workspace" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_used", - "entityType": "columns", - "table": "workspace" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "name", - "entityType": "columns", - "table": "data_migration" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_completed", - "entityType": "columns", - "table": "data_migration" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "account_state" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "active_account_id", - "entityType": "columns", - "table": "account_state" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "active_org_id", - "entityType": "columns", - "table": "account_state" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "account" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "email", - "entityType": "columns", - "table": "account" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "url", - "entityType": "columns", - "table": "account" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "access_token", - "entityType": "columns", - "table": "account" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "refresh_token", - "entityType": "columns", - "table": "account" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "token_expiry", - "entityType": "columns", - "table": "account" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "account" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "account" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "email", - "entityType": "columns", - "table": "control_account" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "url", - "entityType": "columns", - "table": "control_account" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "access_token", - "entityType": "columns", - "table": "control_account" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "refresh_token", - "entityType": "columns", - "table": "control_account" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "token_expiry", - "entityType": "columns", - "table": "control_account" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "active", - "entityType": "columns", - "table": "control_account" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "control_account" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "control_account" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "aggregate_id", - "entityType": "columns", - "table": "event_sequence" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "seq", - "entityType": "columns", - "table": "event_sequence" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "owner_id", - "entityType": "columns", - "table": "event_sequence" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "event" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "aggregate_id", - "entityType": "columns", - "table": "event" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "seq", - "entityType": "columns", - "table": "event" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "type", - "entityType": "columns", - "table": "event" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "data", - "entityType": "columns", - "table": "event" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "permission" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "project_id", - "entityType": "columns", - "table": "permission" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "action", - "entityType": "columns", - "table": "permission" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "resource", - "entityType": "columns", - "table": "permission" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "permission" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "permission" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "project_id", - "entityType": "columns", - "table": "project_directory" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "directory", - "entityType": "columns", - "table": "project_directory" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "type", - "entityType": "columns", - "table": "project_directory" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "project_directory" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "project" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "worktree", - "entityType": "columns", - "table": "project" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "vcs", - "entityType": "columns", - "table": "project" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "name", - "entityType": "columns", - "table": "project" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "icon_url", - "entityType": "columns", - "table": "project" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "icon_url_override", - "entityType": "columns", - "table": "project" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "icon_color", - "entityType": "columns", - "table": "project" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "project" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "project" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_initialized", - "entityType": "columns", - "table": "project" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "sandboxes", - "entityType": "columns", - "table": "project" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "commands", - "entityType": "columns", - "table": "project" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "message" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "session_id", - "entityType": "columns", - "table": "message" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "message" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "message" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "data", - "entityType": "columns", - "table": "message" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "part" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "message_id", - "entityType": "columns", - "table": "part" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "session_id", - "entityType": "columns", - "table": "part" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "part" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "part" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "data", - "entityType": "columns", - "table": "part" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "session_id", - "entityType": "columns", - "table": "session_context_epoch" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "baseline", - "entityType": "columns", - "table": "session_context_epoch" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "checkpoint", - "entityType": "columns", - "table": "session_context_epoch" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "baseline_seq", - "entityType": "columns", - "table": "session_context_epoch" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "false", - "generated": null, - "name": "replacement_pending", - "entityType": "columns", - "table": "session_context_epoch" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "0", - "generated": null, - "name": "revision", - "entityType": "columns", - "table": "session_context_epoch" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "session_id", - "entityType": "columns", - "table": "session_context_message" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "seq", - "entityType": "columns", - "table": "session_context_message" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "parts", - "entityType": "columns", - "table": "session_context_message" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": true, - "default": null, - "generated": null, - "name": "seq", - "entityType": "columns", - "table": "session_input" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "session_input" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "session_id", - "entityType": "columns", - "table": "session_input" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "prompt", - "entityType": "columns", - "table": "session_input" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "delivery", - "entityType": "columns", - "table": "session_input" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "promoted_seq", - "entityType": "columns", - "table": "session_input" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "session_input" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "session_message" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "session_id", - "entityType": "columns", - "table": "session_message" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "type", - "entityType": "columns", - "table": "session_message" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "seq", - "entityType": "columns", - "table": "session_message" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "session_message" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "session_message" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "data", - "entityType": "columns", - "table": "session_message" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "project_id", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "workspace_id", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "parent_id", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "slug", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "directory", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "path", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "title", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "version", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "share_url", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "summary_additions", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "summary_deletions", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "summary_files", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "summary_diffs", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "metadata", - "entityType": "columns", - "table": "session" - }, - { - "type": "real", - "notNull": true, - "autoincrement": false, - "default": "0", - "generated": null, - "name": "cost", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "0", - "generated": null, - "name": "tokens_input", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "0", - "generated": null, - "name": "tokens_output", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "0", - "generated": null, - "name": "tokens_reasoning", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "0", - "generated": null, - "name": "tokens_cache_read", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "0", - "generated": null, - "name": "tokens_cache_write", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "revert", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "permission", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "agent", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "model", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_compacting", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_archived", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "session_id", - "entityType": "columns", - "table": "todo" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "content", - "entityType": "columns", - "table": "todo" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "status", - "entityType": "columns", - "table": "todo" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "priority", - "entityType": "columns", - "table": "todo" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "position", - "entityType": "columns", - "table": "todo" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "todo" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "todo" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "session_id", - "entityType": "columns", - "table": "session_share" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "session_share" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "secret", - "entityType": "columns", - "table": "session_share" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "url", - "entityType": "columns", - "table": "session_share" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "session_share" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "session_share" - }, - { - "columns": [ - "project_id" - ], - "tableTo": "project", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_workspace_project_id_project_id_fk", - "entityType": "fks", - "table": "workspace" - }, - { - "columns": [ - "active_account_id" - ], - "tableTo": "account", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "SET NULL", - "nameExplicit": false, - "name": "fk_account_state_active_account_id_account_id_fk", - "entityType": "fks", - "table": "account_state" - }, - { - "columns": [ - "aggregate_id" - ], - "tableTo": "event_sequence", - "columnsTo": [ - "aggregate_id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", - "entityType": "fks", - "table": "event" - }, - { - "columns": [ - "project_id" - ], - "tableTo": "project", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_permission_project_id_project_id_fk", - "entityType": "fks", - "table": "permission" - }, - { - "columns": [ - "project_id" - ], - "tableTo": "project", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_project_directory_project_id_project_id_fk", - "entityType": "fks", - "table": "project_directory" - }, - { - "columns": [ - "session_id" - ], - "tableTo": "session", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_message_session_id_session_id_fk", - "entityType": "fks", - "table": "message" - }, - { - "columns": [ - "message_id" - ], - "tableTo": "message", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_part_message_id_message_id_fk", - "entityType": "fks", - "table": "part" - }, - { - "columns": [ - "session_id" - ], - "tableTo": "session", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_session_context_epoch_session_id_session_id_fk", - "entityType": "fks", - "table": "session_context_epoch" - }, - { - "columns": [ - "session_id" - ], - "tableTo": "session", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_session_context_message_session_id_session_id_fk", - "entityType": "fks", - "table": "session_context_message" - }, - { - "columns": [ - "session_id" - ], - "tableTo": "session", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_session_input_session_id_session_id_fk", - "entityType": "fks", - "table": "session_input" - }, - { - "columns": [ - "session_id" - ], - "tableTo": "session", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_session_message_session_id_session_id_fk", - "entityType": "fks", - "table": "session_message" - }, - { - "columns": [ - "project_id" - ], - "tableTo": "project", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_session_project_id_project_id_fk", - "entityType": "fks", - "table": "session" - }, - { - "columns": [ - "session_id" - ], - "tableTo": "session", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_todo_session_id_session_id_fk", - "entityType": "fks", - "table": "todo" - }, - { - "columns": [ - "session_id" - ], - "tableTo": "session", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_session_share_session_id_session_id_fk", - "entityType": "fks", - "table": "session_share" - }, - { - "columns": [ - "email", - "url" - ], - "nameExplicit": false, - "name": "control_account_pk", - "entityType": "pks", - "table": "control_account" - }, - { - "columns": [ - "project_id", - "directory" - ], - "nameExplicit": false, - "name": "project_directory_pk", - "entityType": "pks", - "table": "project_directory" - }, - { - "columns": [ - "session_id", - "seq" - ], - "nameExplicit": false, - "name": "session_context_message_pk", - "entityType": "pks", - "table": "session_context_message" - }, - { - "columns": [ - "session_id", - "position" - ], - "nameExplicit": false, - "name": "todo_pk", - "entityType": "pks", - "table": "todo" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "workspace_pk", - "table": "workspace", - "entityType": "pks" - }, - { - "columns": [ - "name" - ], - "nameExplicit": false, - "name": "data_migration_pk", - "table": "data_migration", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "account_state_pk", - "table": "account_state", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "account_pk", - "table": "account", - "entityType": "pks" - }, - { - "columns": [ - "aggregate_id" - ], - "nameExplicit": false, - "name": "event_sequence_pk", - "table": "event_sequence", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "event_pk", - "table": "event", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "permission_pk", - "table": "permission", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "project_pk", - "table": "project", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "message_pk", - "table": "message", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "part_pk", - "table": "part", - "entityType": "pks" - }, - { - "columns": [ - "session_id" - ], - "nameExplicit": false, - "name": "session_context_epoch_pk", - "table": "session_context_epoch", - "entityType": "pks" - }, - { - "columns": [ - "seq" - ], - "nameExplicit": false, - "name": "session_input_pk", - "table": "session_input", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "session_message_pk", - "table": "session_message", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "session_pk", - "table": "session", - "entityType": "pks" - }, - { - "columns": [ - "session_id" - ], - "nameExplicit": false, - "name": "session_share_pk", - "table": "session_share", - "entityType": "pks" - }, - { - "columns": [ - { - "value": "aggregate_id", - "isExpression": false - }, - { - "value": "seq", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "event_aggregate_seq_idx", - "entityType": "indexes", - "table": "event" - }, - { - "columns": [ - { - "value": "aggregate_id", - "isExpression": false - }, - { - "value": "type", - "isExpression": false - }, - { - "value": "seq", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "event_aggregate_type_seq_idx", - "entityType": "indexes", - "table": "event" - }, - { - "columns": [ - { - "value": "project_id", - "isExpression": false - }, - { - "value": "action", - "isExpression": false - }, - { - "value": "resource", - "isExpression": false - } - ], - "isUnique": true, - "where": null, - "origin": "manual", - "name": "permission_project_action_resource_idx", - "entityType": "indexes", - "table": "permission" - }, - { - "columns": [ - { - "value": "session_id", - "isExpression": false - }, - { - "value": "time_created", - "isExpression": false - }, - { - "value": "id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "message_session_time_created_id_idx", - "entityType": "indexes", - "table": "message" - }, - { - "columns": [ - { - "value": "message_id", - "isExpression": false - }, - { - "value": "id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "part_message_id_id_idx", - "entityType": "indexes", - "table": "part" - }, - { - "columns": [ - { - "value": "session_id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "part_session_idx", - "entityType": "indexes", - "table": "part" - }, - { - "columns": [ - { - "value": "session_id", - "isExpression": false - }, - { - "value": "seq", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "session_context_message_session_seq_idx", - "entityType": "indexes", - "table": "session_context_message" - }, - { - "columns": [ - { - "value": "session_id", - "isExpression": false - }, - { - "value": "promoted_seq", - "isExpression": false - }, - { - "value": "delivery", - "isExpression": false - }, - { - "value": "seq", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "session_input_session_pending_delivery_seq_idx", - "entityType": "indexes", - "table": "session_input" - }, - { - "columns": [ - { - "value": "session_id", - "isExpression": false - }, - { - "value": "seq", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "session_message_session_seq_idx", - "entityType": "indexes", - "table": "session_message" - }, - { - "columns": [ - { - "value": "session_id", - "isExpression": false - }, - { - "value": "type", - "isExpression": false - }, - { - "value": "seq", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "session_message_session_type_seq_idx", - "entityType": "indexes", - "table": "session_message" - }, - { - "columns": [ - { - "value": "session_id", - "isExpression": false - }, - { - "value": "time_created", - "isExpression": false - }, - { - "value": "id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "session_message_session_time_created_id_idx", - "entityType": "indexes", - "table": "session_message" - }, - { - "columns": [ - { - "value": "time_created", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "session_message_time_created_idx", - "entityType": "indexes", - "table": "session_message" - }, - { - "columns": [ - { - "value": "project_id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "session_project_idx", - "entityType": "indexes", - "table": "session" - }, - { - "columns": [ - { - "value": "workspace_id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "session_workspace_idx", - "entityType": "indexes", - "table": "session" - }, - { - "columns": [ - { - "value": "parent_id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "session_parent_idx", - "entityType": "indexes", - "table": "session" - }, - { - "columns": [ - { - "value": "session_id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "todo_session_idx", - "entityType": "indexes", - "table": "todo" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "session_input_id_unique", - "entityType": "uniques", - "table": "session_input" - } - ], - "renames": [] -} \ No newline at end of file diff --git a/packages/core/migration/20260604181807_add_session_context_replacement_sequence/migration.sql b/packages/core/migration/20260604181807_add_session_context_replacement_sequence/migration.sql deleted file mode 100644 index 35ada3e2bc..0000000000 --- a/packages/core/migration/20260604181807_add_session_context_replacement_sequence/migration.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE `session_context_epoch` ADD `replacement_seq` integer; \ No newline at end of file diff --git a/packages/core/migration/20260604181807_add_session_context_replacement_sequence/snapshot.json b/packages/core/migration/20260604181807_add_session_context_replacement_sequence/snapshot.json deleted file mode 100644 index 902c531801..0000000000 --- a/packages/core/migration/20260604181807_add_session_context_replacement_sequence/snapshot.json +++ /dev/null @@ -1,2133 +0,0 @@ -{ - "version": "7", - "dialect": "sqlite", - "id": "297cd5f2-b759-46d2-b4c1-7490a65dff3a", - "prevIds": [ - "5d4aa1b8-68a2-4aca-8890-b40b16679706" - ], - "ddl": [ - { - "name": "workspace", - "entityType": "tables" - }, - { - "name": "data_migration", - "entityType": "tables" - }, - { - "name": "account_state", - "entityType": "tables" - }, - { - "name": "account", - "entityType": "tables" - }, - { - "name": "control_account", - "entityType": "tables" - }, - { - "name": "event_sequence", - "entityType": "tables" - }, - { - "name": "event", - "entityType": "tables" - }, - { - "name": "permission", - "entityType": "tables" - }, - { - "name": "project_directory", - "entityType": "tables" - }, - { - "name": "project", - "entityType": "tables" - }, - { - "name": "message", - "entityType": "tables" - }, - { - "name": "part", - "entityType": "tables" - }, - { - "name": "session_context_epoch", - "entityType": "tables" - }, - { - "name": "session_context_message", - "entityType": "tables" - }, - { - "name": "session_input", - "entityType": "tables" - }, - { - "name": "session_message", - "entityType": "tables" - }, - { - "name": "session", - "entityType": "tables" - }, - { - "name": "todo", - "entityType": "tables" - }, - { - "name": "session_share", - "entityType": "tables" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "workspace" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "type", - "entityType": "columns", - "table": "workspace" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": "''", - "generated": null, - "name": "name", - "entityType": "columns", - "table": "workspace" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "branch", - "entityType": "columns", - "table": "workspace" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "directory", - "entityType": "columns", - "table": "workspace" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "extra", - "entityType": "columns", - "table": "workspace" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "project_id", - "entityType": "columns", - "table": "workspace" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_used", - "entityType": "columns", - "table": "workspace" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "name", - "entityType": "columns", - "table": "data_migration" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_completed", - "entityType": "columns", - "table": "data_migration" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "account_state" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "active_account_id", - "entityType": "columns", - "table": "account_state" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "active_org_id", - "entityType": "columns", - "table": "account_state" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "account" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "email", - "entityType": "columns", - "table": "account" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "url", - "entityType": "columns", - "table": "account" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "access_token", - "entityType": "columns", - "table": "account" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "refresh_token", - "entityType": "columns", - "table": "account" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "token_expiry", - "entityType": "columns", - "table": "account" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "account" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "account" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "email", - "entityType": "columns", - "table": "control_account" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "url", - "entityType": "columns", - "table": "control_account" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "access_token", - "entityType": "columns", - "table": "control_account" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "refresh_token", - "entityType": "columns", - "table": "control_account" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "token_expiry", - "entityType": "columns", - "table": "control_account" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "active", - "entityType": "columns", - "table": "control_account" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "control_account" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "control_account" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "aggregate_id", - "entityType": "columns", - "table": "event_sequence" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "seq", - "entityType": "columns", - "table": "event_sequence" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "owner_id", - "entityType": "columns", - "table": "event_sequence" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "event" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "aggregate_id", - "entityType": "columns", - "table": "event" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "seq", - "entityType": "columns", - "table": "event" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "type", - "entityType": "columns", - "table": "event" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "data", - "entityType": "columns", - "table": "event" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "permission" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "project_id", - "entityType": "columns", - "table": "permission" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "action", - "entityType": "columns", - "table": "permission" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "resource", - "entityType": "columns", - "table": "permission" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "permission" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "permission" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "project_id", - "entityType": "columns", - "table": "project_directory" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "directory", - "entityType": "columns", - "table": "project_directory" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "type", - "entityType": "columns", - "table": "project_directory" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "project_directory" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "project" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "worktree", - "entityType": "columns", - "table": "project" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "vcs", - "entityType": "columns", - "table": "project" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "name", - "entityType": "columns", - "table": "project" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "icon_url", - "entityType": "columns", - "table": "project" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "icon_url_override", - "entityType": "columns", - "table": "project" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "icon_color", - "entityType": "columns", - "table": "project" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "project" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "project" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_initialized", - "entityType": "columns", - "table": "project" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "sandboxes", - "entityType": "columns", - "table": "project" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "commands", - "entityType": "columns", - "table": "project" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "message" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "session_id", - "entityType": "columns", - "table": "message" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "message" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "message" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "data", - "entityType": "columns", - "table": "message" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "part" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "message_id", - "entityType": "columns", - "table": "part" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "session_id", - "entityType": "columns", - "table": "part" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "part" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "part" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "data", - "entityType": "columns", - "table": "part" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "session_id", - "entityType": "columns", - "table": "session_context_epoch" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "baseline", - "entityType": "columns", - "table": "session_context_epoch" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "checkpoint", - "entityType": "columns", - "table": "session_context_epoch" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "baseline_seq", - "entityType": "columns", - "table": "session_context_epoch" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "false", - "generated": null, - "name": "replacement_pending", - "entityType": "columns", - "table": "session_context_epoch" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "replacement_seq", - "entityType": "columns", - "table": "session_context_epoch" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "0", - "generated": null, - "name": "revision", - "entityType": "columns", - "table": "session_context_epoch" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "session_id", - "entityType": "columns", - "table": "session_context_message" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "seq", - "entityType": "columns", - "table": "session_context_message" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "parts", - "entityType": "columns", - "table": "session_context_message" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": true, - "default": null, - "generated": null, - "name": "seq", - "entityType": "columns", - "table": "session_input" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "session_input" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "session_id", - "entityType": "columns", - "table": "session_input" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "prompt", - "entityType": "columns", - "table": "session_input" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "delivery", - "entityType": "columns", - "table": "session_input" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "promoted_seq", - "entityType": "columns", - "table": "session_input" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "session_input" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "session_message" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "session_id", - "entityType": "columns", - "table": "session_message" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "type", - "entityType": "columns", - "table": "session_message" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "seq", - "entityType": "columns", - "table": "session_message" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "session_message" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "session_message" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "data", - "entityType": "columns", - "table": "session_message" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "project_id", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "workspace_id", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "parent_id", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "slug", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "directory", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "path", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "title", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "version", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "share_url", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "summary_additions", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "summary_deletions", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "summary_files", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "summary_diffs", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "metadata", - "entityType": "columns", - "table": "session" - }, - { - "type": "real", - "notNull": true, - "autoincrement": false, - "default": "0", - "generated": null, - "name": "cost", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "0", - "generated": null, - "name": "tokens_input", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "0", - "generated": null, - "name": "tokens_output", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "0", - "generated": null, - "name": "tokens_reasoning", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "0", - "generated": null, - "name": "tokens_cache_read", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": "0", - "generated": null, - "name": "tokens_cache_write", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "revert", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "permission", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "agent", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "model", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_compacting", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_archived", - "entityType": "columns", - "table": "session" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "session_id", - "entityType": "columns", - "table": "todo" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "content", - "entityType": "columns", - "table": "todo" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "status", - "entityType": "columns", - "table": "todo" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "priority", - "entityType": "columns", - "table": "todo" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "position", - "entityType": "columns", - "table": "todo" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "todo" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "todo" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "session_id", - "entityType": "columns", - "table": "session_share" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "session_share" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "secret", - "entityType": "columns", - "table": "session_share" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "url", - "entityType": "columns", - "table": "session_share" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_created", - "entityType": "columns", - "table": "session_share" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "time_updated", - "entityType": "columns", - "table": "session_share" - }, - { - "columns": [ - "project_id" - ], - "tableTo": "project", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_workspace_project_id_project_id_fk", - "entityType": "fks", - "table": "workspace" - }, - { - "columns": [ - "active_account_id" - ], - "tableTo": "account", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "SET NULL", - "nameExplicit": false, - "name": "fk_account_state_active_account_id_account_id_fk", - "entityType": "fks", - "table": "account_state" - }, - { - "columns": [ - "aggregate_id" - ], - "tableTo": "event_sequence", - "columnsTo": [ - "aggregate_id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", - "entityType": "fks", - "table": "event" - }, - { - "columns": [ - "project_id" - ], - "tableTo": "project", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_permission_project_id_project_id_fk", - "entityType": "fks", - "table": "permission" - }, - { - "columns": [ - "project_id" - ], - "tableTo": "project", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_project_directory_project_id_project_id_fk", - "entityType": "fks", - "table": "project_directory" - }, - { - "columns": [ - "session_id" - ], - "tableTo": "session", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_message_session_id_session_id_fk", - "entityType": "fks", - "table": "message" - }, - { - "columns": [ - "message_id" - ], - "tableTo": "message", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_part_message_id_message_id_fk", - "entityType": "fks", - "table": "part" - }, - { - "columns": [ - "session_id" - ], - "tableTo": "session", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_session_context_epoch_session_id_session_id_fk", - "entityType": "fks", - "table": "session_context_epoch" - }, - { - "columns": [ - "session_id" - ], - "tableTo": "session", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_session_context_message_session_id_session_id_fk", - "entityType": "fks", - "table": "session_context_message" - }, - { - "columns": [ - "session_id" - ], - "tableTo": "session", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_session_input_session_id_session_id_fk", - "entityType": "fks", - "table": "session_input" - }, - { - "columns": [ - "session_id" - ], - "tableTo": "session", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_session_message_session_id_session_id_fk", - "entityType": "fks", - "table": "session_message" - }, - { - "columns": [ - "project_id" - ], - "tableTo": "project", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_session_project_id_project_id_fk", - "entityType": "fks", - "table": "session" - }, - { - "columns": [ - "session_id" - ], - "tableTo": "session", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_todo_session_id_session_id_fk", - "entityType": "fks", - "table": "todo" - }, - { - "columns": [ - "session_id" - ], - "tableTo": "session", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_session_share_session_id_session_id_fk", - "entityType": "fks", - "table": "session_share" - }, - { - "columns": [ - "email", - "url" - ], - "nameExplicit": false, - "name": "control_account_pk", - "entityType": "pks", - "table": "control_account" - }, - { - "columns": [ - "project_id", - "directory" - ], - "nameExplicit": false, - "name": "project_directory_pk", - "entityType": "pks", - "table": "project_directory" - }, - { - "columns": [ - "session_id", - "seq" - ], - "nameExplicit": false, - "name": "session_context_message_pk", - "entityType": "pks", - "table": "session_context_message" - }, - { - "columns": [ - "session_id", - "position" - ], - "nameExplicit": false, - "name": "todo_pk", - "entityType": "pks", - "table": "todo" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "workspace_pk", - "table": "workspace", - "entityType": "pks" - }, - { - "columns": [ - "name" - ], - "nameExplicit": false, - "name": "data_migration_pk", - "table": "data_migration", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "account_state_pk", - "table": "account_state", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "account_pk", - "table": "account", - "entityType": "pks" - }, - { - "columns": [ - "aggregate_id" - ], - "nameExplicit": false, - "name": "event_sequence_pk", - "table": "event_sequence", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "event_pk", - "table": "event", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "permission_pk", - "table": "permission", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "project_pk", - "table": "project", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "message_pk", - "table": "message", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "part_pk", - "table": "part", - "entityType": "pks" - }, - { - "columns": [ - "session_id" - ], - "nameExplicit": false, - "name": "session_context_epoch_pk", - "table": "session_context_epoch", - "entityType": "pks" - }, - { - "columns": [ - "seq" - ], - "nameExplicit": false, - "name": "session_input_pk", - "table": "session_input", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "session_message_pk", - "table": "session_message", - "entityType": "pks" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "session_pk", - "table": "session", - "entityType": "pks" - }, - { - "columns": [ - "session_id" - ], - "nameExplicit": false, - "name": "session_share_pk", - "table": "session_share", - "entityType": "pks" - }, - { - "columns": [ - { - "value": "aggregate_id", - "isExpression": false - }, - { - "value": "seq", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "event_aggregate_seq_idx", - "entityType": "indexes", - "table": "event" - }, - { - "columns": [ - { - "value": "aggregate_id", - "isExpression": false - }, - { - "value": "type", - "isExpression": false - }, - { - "value": "seq", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "event_aggregate_type_seq_idx", - "entityType": "indexes", - "table": "event" - }, - { - "columns": [ - { - "value": "project_id", - "isExpression": false - }, - { - "value": "action", - "isExpression": false - }, - { - "value": "resource", - "isExpression": false - } - ], - "isUnique": true, - "where": null, - "origin": "manual", - "name": "permission_project_action_resource_idx", - "entityType": "indexes", - "table": "permission" - }, - { - "columns": [ - { - "value": "session_id", - "isExpression": false - }, - { - "value": "time_created", - "isExpression": false - }, - { - "value": "id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "message_session_time_created_id_idx", - "entityType": "indexes", - "table": "message" - }, - { - "columns": [ - { - "value": "message_id", - "isExpression": false - }, - { - "value": "id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "part_message_id_id_idx", - "entityType": "indexes", - "table": "part" - }, - { - "columns": [ - { - "value": "session_id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "part_session_idx", - "entityType": "indexes", - "table": "part" - }, - { - "columns": [ - { - "value": "session_id", - "isExpression": false - }, - { - "value": "seq", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "session_context_message_session_seq_idx", - "entityType": "indexes", - "table": "session_context_message" - }, - { - "columns": [ - { - "value": "session_id", - "isExpression": false - }, - { - "value": "promoted_seq", - "isExpression": false - }, - { - "value": "delivery", - "isExpression": false - }, - { - "value": "seq", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "session_input_session_pending_delivery_seq_idx", - "entityType": "indexes", - "table": "session_input" - }, - { - "columns": [ - { - "value": "session_id", - "isExpression": false - }, - { - "value": "seq", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "session_message_session_seq_idx", - "entityType": "indexes", - "table": "session_message" - }, - { - "columns": [ - { - "value": "session_id", - "isExpression": false - }, - { - "value": "type", - "isExpression": false - }, - { - "value": "seq", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "session_message_session_type_seq_idx", - "entityType": "indexes", - "table": "session_message" - }, - { - "columns": [ - { - "value": "session_id", - "isExpression": false - }, - { - "value": "time_created", - "isExpression": false - }, - { - "value": "id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "session_message_session_time_created_id_idx", - "entityType": "indexes", - "table": "session_message" - }, - { - "columns": [ - { - "value": "time_created", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "session_message_time_created_idx", - "entityType": "indexes", - "table": "session_message" - }, - { - "columns": [ - { - "value": "project_id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "session_project_idx", - "entityType": "indexes", - "table": "session" - }, - { - "columns": [ - { - "value": "workspace_id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "session_workspace_idx", - "entityType": "indexes", - "table": "session" - }, - { - "columns": [ - { - "value": "parent_id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "session_parent_idx", - "entityType": "indexes", - "table": "session" - }, - { - "columns": [ - { - "value": "session_id", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "todo_session_idx", - "entityType": "indexes", - "table": "todo" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "session_input_id_unique", - "entityType": "uniques", - "table": "session_input" - } - ], - "renames": [] -} \ No newline at end of file diff --git a/packages/core/migration/20260604184448_add_session_context_epoch/migration.sql b/packages/core/migration/20260604184448_add_session_context_epoch/migration.sql new file mode 100644 index 0000000000..a10df47539 --- /dev/null +++ b/packages/core/migration/20260604184448_add_session_context_epoch/migration.sql @@ -0,0 +1,17 @@ +CREATE TABLE `session_context_epoch` ( + `session_id` text PRIMARY KEY, + `baseline` text NOT NULL, + `checkpoint` text NOT NULL, + `baseline_seq` integer NOT NULL, + `replacement_seq` integer, + `revision` integer DEFAULT 0 NOT NULL, + CONSTRAINT `fk_session_context_epoch_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE TABLE `session_context_message` ( + `session_id` text NOT NULL, + `seq` integer NOT NULL, + `parts` text NOT NULL, + CONSTRAINT `session_context_message_pk` PRIMARY KEY(`session_id`, `seq`), + CONSTRAINT `fk_session_context_message_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE +); diff --git a/packages/core/migration/20260604181329_add_session_context_updates/snapshot.json b/packages/core/migration/20260604184448_add_session_context_epoch/snapshot.json similarity index 98% rename from packages/core/migration/20260604181329_add_session_context_updates/snapshot.json rename to packages/core/migration/20260604184448_add_session_context_epoch/snapshot.json index 32058c614c..4715fa89fe 100644 --- a/packages/core/migration/20260604181329_add_session_context_updates/snapshot.json +++ b/packages/core/migration/20260604184448_add_session_context_epoch/snapshot.json @@ -1,9 +1,9 @@ { "version": "7", "dialect": "sqlite", - "id": "85667f6a-603f-4041-be9b-783a34eb74d4", + "id": "6fe30aa9-5772-49a6-b688-d022dbd28903", "prevIds": [ - "f08074bb-2b27-4365-924c-5a4eae9a5194" + "fc92fa34-8074-44c3-88f0-a5417f7fd92d" ], "ddl": [ { @@ -822,6 +822,16 @@ "entityType": "columns", "table": "session_context_epoch" }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "replacement_seq", + "entityType": "columns", + "table": "session_context_epoch" + }, { "type": "integer", "notNull": true, @@ -1923,24 +1933,6 @@ "entityType": "indexes", "table": "part" }, - { - "columns": [ - { - "value": "session_id", - "isExpression": false - }, - { - "value": "seq", - "isExpression": false - } - ], - "isUnique": false, - "where": null, - "origin": "manual", - "name": "session_context_message_session_seq_idx", - "entityType": "indexes", - "table": "session_context_message" - }, { "columns": [ { diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 2bb36b01a7..e0d8d04bfe 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -32,9 +32,6 @@ export const migrations = ( import("./migration/20260603141458_session_input_inbox"), import("./migration/20260603160727_jittery_ezekiel_stane"), import("./migration/20260604172448_event_sourced_session_input"), - import("./migration/20260604180746_add_session_context_epoch"), - import("./migration/20260604181329_add_session_context_updates"), - import("./migration/20260604181706_add_session_context_replacement"), - import("./migration/20260604181807_add_session_context_replacement_sequence"), + import("./migration/20260604184448_add_session_context_epoch"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260604180746_add_session_context_epoch.ts b/packages/core/src/database/migration/20260604180746_add_session_context_epoch.ts deleted file mode 100644 index 3bc87317f9..0000000000 --- a/packages/core/src/database/migration/20260604180746_add_session_context_epoch.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Effect } from "effect" -import type { DatabaseMigration } from "../migration" - -export default { - id: "20260604180746_add_session_context_epoch", - up(tx) { - return Effect.gen(function* () { - yield* tx.run(` - CREATE TABLE \`session_context_epoch\` ( - \`session_id\` text PRIMARY KEY, - \`baseline\` text NOT NULL, - \`checkpoint\` text NOT NULL, - \`baseline_seq\` integer NOT NULL, - CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE - ); - `) - }) - }, -} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260604181706_add_session_context_replacement.ts b/packages/core/src/database/migration/20260604181706_add_session_context_replacement.ts deleted file mode 100644 index 69040dc61d..0000000000 --- a/packages/core/src/database/migration/20260604181706_add_session_context_replacement.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Effect } from "effect" -import type { DatabaseMigration } from "../migration" - -export default { - id: "20260604181706_add_session_context_replacement", - up(tx) { - return Effect.gen(function* () { - yield* tx.run(`ALTER TABLE \`session_context_epoch\` ADD \`replacement_pending\` integer DEFAULT false NOT NULL;`) - }) - }, -} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260604181807_add_session_context_replacement_sequence.ts b/packages/core/src/database/migration/20260604181807_add_session_context_replacement_sequence.ts deleted file mode 100644 index 5298c0d644..0000000000 --- a/packages/core/src/database/migration/20260604181807_add_session_context_replacement_sequence.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Effect } from "effect" -import type { DatabaseMigration } from "../migration" - -export default { - id: "20260604181807_add_session_context_replacement_sequence", - up(tx) { - return Effect.gen(function* () { - yield* tx.run(`ALTER TABLE \`session_context_epoch\` ADD \`replacement_seq\` integer;`) - }) - }, -} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260604181329_add_session_context_updates.ts b/packages/core/src/database/migration/20260604184448_add_session_context_epoch.ts similarity index 54% rename from packages/core/src/database/migration/20260604181329_add_session_context_updates.ts rename to packages/core/src/database/migration/20260604184448_add_session_context_epoch.ts index 6988aa8fbd..45ff9e5335 100644 --- a/packages/core/src/database/migration/20260604181329_add_session_context_updates.ts +++ b/packages/core/src/database/migration/20260604184448_add_session_context_epoch.ts @@ -2,9 +2,20 @@ import { Effect } from "effect" import type { DatabaseMigration } from "../migration" export default { - id: "20260604181329_add_session_context_updates", + id: "20260604184448_add_session_context_epoch", up(tx) { return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`session_context_epoch\` ( + \`session_id\` text PRIMARY KEY, + \`baseline\` text NOT NULL, + \`checkpoint\` text NOT NULL, + \`baseline_seq\` integer NOT NULL, + \`replacement_seq\` integer, + \`revision\` integer DEFAULT 0 NOT NULL, + CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) yield* tx.run(` CREATE TABLE \`session_context_message\` ( \`session_id\` text NOT NULL, @@ -14,10 +25,6 @@ export default { CONSTRAINT \`fk_session_context_message_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE ); `) - yield* tx.run(`ALTER TABLE \`session_context_epoch\` ADD \`revision\` integer DEFAULT 0 NOT NULL;`) - yield* tx.run( - `CREATE INDEX \`session_context_message_session_seq_idx\` ON \`session_context_message\` (\`session_id\`,\`seq\`);`, - ) }) }, } satisfies DatabaseMigration.Migration diff --git a/packages/core/src/session/context-epoch.ts b/packages/core/src/session/context-epoch.ts index ed37c2794b..dec7e564d2 100644 --- a/packages/core/src/session/context-epoch.ts +++ b/packages/core/src/session/context-epoch.ts @@ -1,6 +1,6 @@ export * as SessionContextEpoch from "./context-epoch" -import { and, eq, sql } from "drizzle-orm" +import { and, eq, isNull, lt, sql } from "drizzle-orm" import { DateTime, Effect, Schema } from "effect" import type { Database } from "../database/database" import { EventV2 } from "../event" @@ -24,28 +24,33 @@ export const prepare = Effect.fn("SessionContextEpoch.prepare")(function* ( const stored = yield* find(db, sessionID) if (!stored) { const initialized = SystemContext.initialize(snapshot) - yield* events.publish(SessionEvent.ContextInitialized, { + const event = yield* events.publish(SessionEvent.ContextInitialized, { sessionID, timestamp: yield* DateTime.now, baseline: initialized.baseline, checkpoint: initialized.checkpoint, }) - return initialized.baseline + if (event.seq === undefined) return yield* Effect.die("Synchronized Session event is missing aggregate sequence") + return { baseline: initialized.baseline, baselineSeq: event.seq } } - if (stored.replacement_pending) { + if (stored.replacement_seq !== null) { + if (SystemContext.replacementBlocked(snapshot, stored.checkpoint)) + return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } const initialized = SystemContext.initialize(snapshot) - yield* events.publish(SessionEvent.ContextReplaced, { + const event = yield* events.publish(SessionEvent.ContextReplaced, { sessionID, timestamp: yield* DateTime.now, expectedRevision: stored.revision, baseline: initialized.baseline, checkpoint: initialized.checkpoint, }) - return initialized.baseline + if (event.seq === undefined) return yield* Effect.die("Synchronized Session event is missing aggregate sequence") + return { baseline: initialized.baseline, baselineSeq: event.seq } } const refreshed = SystemContext.refresh(snapshot, stored.checkpoint) - if (sameCheckpoint(refreshed.checkpoint, stored.checkpoint)) return stored.baseline + if (sameCheckpoint(refreshed.checkpoint, stored.checkpoint)) + return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } yield* events.publish(SessionEvent.ContextUpdated, { sessionID, timestamp: yield* DateTime.now, @@ -53,7 +58,7 @@ export const prepare = Effect.fn("SessionContextEpoch.prepare")(function* ( parts: refreshed.changes, checkpoint: refreshed.checkpoint, }) - return stored.baseline + return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } }) export const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { @@ -84,7 +89,6 @@ export const projectInitialized = Effect.fn("SessionContextEpoch.projectInitiali baseline: event.data.baseline, checkpoint: event.data.checkpoint, baseline_seq: seq, - replacement_pending: false, replacement_seq: null, revision: 0, }) @@ -99,7 +103,9 @@ export const projectUpdated = Effect.fn("SessionContextEpoch.projectUpdated")(fu ) { const stored = yield* find(db, event.data.sessionID) if (!stored) return yield* Effect.die("Session context epoch is not initialized") - if (stored.replacement_pending) return yield* Effect.die("Session context epoch replacement is pending") + if (stored.baseline_seq > seq) return yield* Effect.void + if (stored.replacement_seq !== null && seq >= stored.replacement_seq) + return yield* Effect.die("Session context epoch replacement is pending") if (stored.revision > event.data.expectedRevision) { if (event.data.parts.length === 0) return yield* Effect.void const projected = yield* db @@ -141,8 +147,9 @@ export const projectReplaced = Effect.fn("SessionContextEpoch.projectReplaced")( ) { const stored = yield* find(db, event.data.sessionID) if (!stored) return yield* Effect.die("Session context epoch is not initialized") - if (!stored.replacement_pending) { - if (stored.baseline_seq === seq && sameBaseline(stored.baseline, event.data.baseline)) return yield* Effect.void + if (stored.baseline_seq > seq) return yield* Effect.void + if (stored.baseline_seq === seq && sameBaseline(stored.baseline, event.data.baseline)) return yield* Effect.void + if (stored.replacement_seq === null) { return yield* Effect.die("Session context epoch replacement was not requested") } const updated = yield* db @@ -151,7 +158,6 @@ export const projectReplaced = Effect.fn("SessionContextEpoch.projectReplaced")( baseline: event.data.baseline, checkpoint: event.data.checkpoint, baseline_seq: seq, - replacement_pending: false, replacement_seq: null, revision: event.data.expectedRevision + 1, }) @@ -173,12 +179,16 @@ export const requestReplacement = Effect.fn("SessionContextEpoch.requestReplacem sessionID: SessionSchema.ID, seq: number, ) { - const stored = yield* find(db, sessionID) - if (!stored || stored.baseline_seq >= seq || stored.replacement_seq === seq) return yield* Effect.void return yield* db .update(SessionContextEpochTable) - .set({ replacement_pending: true, replacement_seq: seq, revision: sql`${SessionContextEpochTable.revision} + 1` }) - .where(eq(SessionContextEpochTable.session_id, sessionID)) + .set({ replacement_seq: seq, revision: sql`${SessionContextEpochTable.revision} + 1` }) + .where( + and( + eq(SessionContextEpochTable.session_id, sessionID), + isNull(SessionContextEpochTable.replacement_seq), + lt(SessionContextEpochTable.baseline_seq, seq), + ), + ) .run() .pipe(Effect.orDie) }) diff --git a/packages/core/src/session/context.ts b/packages/core/src/session/context.ts index 4f4634529c..d4fa8769bb 100644 --- a/packages/core/src/session/context.ts +++ b/packages/core/src/session/context.ts @@ -4,7 +4,7 @@ import { Database } from "../database/database" import { MessageDecodeError } from "./error" import { SessionMessage } from "./message" import { SessionSchema } from "./schema" -import { SessionContextEpochTable, SessionContextMessageTable, SessionMessageTable } from "./sql" +import { SessionContextMessageTable, SessionMessageTable } from "./sql" import type { SystemContext } from "../system-context" type DatabaseService = Database.Interface["db"] @@ -65,24 +65,14 @@ export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseServ export const loadForRunner = Effect.fn("SessionContext.loadForRunner")(function* ( db: DatabaseService, sessionID: SessionSchema.ID, + baselineSeq: number, ) { const compaction = yield* latestCompaction(db, sessionID) const messages = yield* messageRows(db, sessionID, compaction) - const epoch = yield* db - .select({ baselineSeq: SessionContextEpochTable.baseline_seq }) - .from(SessionContextEpochTable) - .where(eq(SessionContextEpochTable.session_id, sessionID)) - .get() - .pipe(Effect.orDie) const updates = yield* db .select() .from(SessionContextMessageTable) - .where( - and( - eq(SessionContextMessageTable.session_id, sessionID), - epoch ? gt(SessionContextMessageTable.seq, epoch.baselineSeq) : undefined, - ), - ) + .where(and(eq(SessionContextMessageTable.session_id, sessionID), gt(SessionContextMessageTable.seq, baselineSeq))) .orderBy(asc(SessionContextMessageTable.seq)) .all() .pipe(Effect.orDie) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 8d7db38157..087fe0a328 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -98,8 +98,11 @@ export const layer = Layer.effect( const getContext = Effect.fn("SessionRunner.getContext")(function* (sessionID: SessionSchema.ID) { return yield* store.context(sessionID) }) - const getRunnerContext = Effect.fn("SessionRunner.getRunnerContext")(function* (sessionID: SessionSchema.ID) { - return yield* store.runnerContext(sessionID) + const getRunnerContext = Effect.fn("SessionRunner.getRunnerContext")(function* ( + sessionID: SessionSchema.ID, + baselineSeq: number, + ) { + return yield* store.runnerContext(sessionID, baselineSeq) }) const failInterruptedTools = Effect.fn("SessionRunner.failInterruptedTools")(function* ( @@ -132,9 +135,10 @@ export const layer = Layer.effect( cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError) const runTurn = Effect.fn("SessionRunner.runTurn")(function* ( - session: SessionSchema.Info, + sessionID: SessionSchema.ID, promotion: "steer" | "queue" | undefined, ) { + const session = yield* getSession(sessionID) const model = yield* models.resolve(session) const toolFibers = yield* FiberSet.make() let needsContinuation = false @@ -146,12 +150,11 @@ export const layer = Layer.effect( yield* SessionInput.promoteSteers(db, events, session.id, cutoff) } } - yield* failInterruptedTools(session.id) const system = yield* SessionContextEpoch.prepare(db, events, systemContext, session.id) - const context = yield* getRunnerContext(session.id) + const context = yield* getRunnerContext(session.id, system.baselineSeq) const request = LLM.request({ model, - system: system.map((part) => SystemPart.make(part.text)), + system: system.baseline.map((part) => SystemPart.make(part.text)), messages: toLLMMessages(context, model), tools: yield* tools.definitions(), }) @@ -251,12 +254,13 @@ export const layer = Layer.effect( const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer") const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue") if (input.force !== true && !hasSteer && !hasQueue) return + yield* failInterruptedTools(input.sessionID) let promotion: "steer" | "queue" | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined let openActivity = input.force === true || hasSteer || hasQueue while (openActivity) { let needsContinuation = true for (let step = 0; step < MAX_STEPS; step++) { - needsContinuation = yield* runTurn(session, promotion) + needsContinuation = yield* runTurn(session.id, promotion) promotion = "steer" if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer") if (!needsContinuation) break diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index e2ee07516c..82020c8538 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -10,7 +10,6 @@ import { import { SessionMessage } from "../message" import type { FileAttachment } from "../prompt" import { SessionContext } from "../context" -import { SystemContext } from "../../system-context" const media = (file: FileAttachment): ContentPart => ({ type: "media", @@ -134,7 +133,7 @@ function toLLMMessage(message: SessionContext.RunnerMessage, model: Model): Mess }), ] case "system-context": - return [Message.system(SystemContext.render(message.parts))] + return [Message.system(message.parts.map((part) => ({ type: "text", text: part.text })))] } } diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index fd73a164ac..5564610d18 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -171,7 +171,6 @@ export const SessionContextEpochTable = sqliteTable("session_context_epoch", { baseline: text({ mode: "json" }).notNull().$type>(), checkpoint: text({ mode: "json" }).notNull().$type(), baseline_seq: integer().notNull(), - replacement_pending: integer({ mode: "boolean" }).notNull().default(false), replacement_seq: integer(), revision: integer().notNull().default(0), }) @@ -186,8 +185,5 @@ export const SessionContextMessageTable = sqliteTable( seq: integer().notNull(), parts: text({ mode: "json" }).notNull().$type>(), }, - (table) => [ - primaryKey({ columns: [table.session_id, table.seq] }), - index("session_context_message_session_seq_idx").on(table.session_id, table.seq), - ], + (table) => [primaryKey({ columns: [table.session_id, table.seq] })], ) diff --git a/packages/core/src/session/store.ts b/packages/core/src/session/store.ts index b262a77495..ad60c56d41 100644 --- a/packages/core/src/session/store.ts +++ b/packages/core/src/session/store.ts @@ -15,6 +15,7 @@ export interface Interface { readonly context: (sessionID: SessionSchema.ID) => Effect.Effect readonly runnerContext: ( sessionID: SessionSchema.ID, + baselineSeq: number, ) => Effect.Effect readonly message: ( messageID: SessionMessage.ID, @@ -37,8 +38,8 @@ export const layer = Layer.effect( context: Effect.fn("SessionStore.context")(function* (sessionID) { return yield* SessionContext.load(db, sessionID) }), - runnerContext: Effect.fn("SessionStore.runnerContext")(function* (sessionID) { - return yield* SessionContext.loadForRunner(db, sessionID) + runnerContext: Effect.fn("SessionStore.runnerContext")(function* (sessionID, baselineSeq) { + return yield* SessionContext.loadForRunner(db, sessionID, baselineSeq) }), message: Effect.fn("SessionStore.message")(function* (messageID) { const row = yield* db diff --git a/packages/core/src/system-context.ts b/packages/core/src/system-context.ts index ac52cfda91..67aa6ad75d 100644 --- a/packages/core/src/system-context.ts +++ b/packages/core/src/system-context.ts @@ -54,7 +54,7 @@ export const PartSchema = Schema.Struct({ text: Schema.String, }) export const PartsSchema = Schema.Array(PartSchema) -export const CheckpointSchema = Schema.Record(Schema.String, Schema.String) +export const CheckpointSchema = Schema.Record(Key, Schema.String) export type Checkpoint = Readonly> @@ -111,6 +111,7 @@ export function initialize(snapshot: Snapshot): Initialized { } export function refresh(snapshot: Snapshot, previous: Checkpoint): Refreshed { + const keys = new Set(snapshot.entries.map((entry) => entry.key)) return { changes: [ ...snapshot.entries.flatMap((entry) => @@ -119,18 +120,15 @@ export function refresh(snapshot: Snapshot, previous: Checkpoint): Refreshed { : [], ), ...Object.keys(previous).flatMap((key) => - snapshot.entries.some((entry) => entry.key === key) - ? [] - : [{ key: Key.make(key), text: `System context component removed: ${key}` }], + keys.has(Key.make(key)) ? [] : [{ key: Key.make(key), text: `System context component removed: ${key}` }], ), ], checkpoint: nextCheckpoint(snapshot, previous), } } -export function render(parts: ReadonlyArray) { - return parts.map((part) => part.text).join("\n\n") -} +export const replacementBlocked = (snapshot: Snapshot, previous: Checkpoint) => + snapshot.entries.some((entry) => entry._tag === "Unavailable" && getCheckpoint(previous, entry.key) !== undefined) function nextCheckpoint(snapshot: Snapshot, previous: Checkpoint) { return Object.fromEntries( diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 366ea7e495..d840c2c6d8 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -69,16 +69,15 @@ describe("DatabaseMigration", () => { expect( 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: 34 }) + expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 31 }) expect( 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_context_message_session_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`, ), ).toEqual([ { name: "event_aggregate_seq_idx" }, { name: "event_aggregate_type_seq_idx" }, { name: "session_input_session_admitted_seq_idx" }, - { name: "session_context_message_session_seq_idx" }, { name: "session_input_session_pending_delivery_seq_idx" }, { name: "session_input_session_promoted_seq_idx" }, { name: "session_message_session_seq_idx" }, diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 259e0a2816..e00346cd1e 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -90,10 +90,23 @@ describe("toLLMMessages", () => { test("maps hidden Session context updates into chronological system messages", () => { expect( toLLMMessages( - [{ type: "system-context", parts: [{ key: SystemContext.Key.make("test/context"), text: "Updated context" }] }], + [ + { + type: "system-context", + parts: [ + { key: SystemContext.Key.make("test/context"), text: "Updated context" }, + { key: SystemContext.Key.make("test/other"), text: "Other context" }, + ], + }, + ], model, ), - ).toEqual([Message.system("Updated context")]) + ).toEqual([ + Message.system([ + { type: "text", text: "Updated context" }, + { type: "text", text: "Other context" }, + ]), + ]) }) test("expands assistant tool calls and settled outcomes into canonical tool messages", () => { diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 1f8d4a2317..7b31b91358 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -57,6 +57,7 @@ let streamStarted: Deferred.Deferred | undefined let streamFailure: LLMError | undefined let toolExecutionGate: Deferred.Deferred | undefined let toolExecutionsStarted: Deferred.Deferred | undefined +let toolExecutionsReady = 5 let activeToolExecutions = 0 let maxActiveToolExecutions = 0 const client = Layer.succeed( @@ -85,6 +86,7 @@ const client = Layer.succeed( }), ) const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route }) +const replacementModel = Model.make({ id: "replacement", provider: "fake", route: OpenAIChat.route }) const authorizations: ToolRegistry.AuthorizeInput[] = [] const executions: string[] = [] const permission = Layer.succeed( @@ -118,7 +120,7 @@ const echo = Layer.effectDiscard( executions.push(text) activeToolExecutions++ maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions) - if (activeToolExecutions === 5 && toolExecutionsStarted) { + if (activeToolExecutions === toolExecutionsReady && toolExecutionsStarted) { yield* Deferred.succeed(toolExecutionsStarted, undefined) } if (toolExecutionGate) yield* Deferred.await(toolExecutionGate) @@ -137,10 +139,13 @@ const echo = Layer.effectDiscard( }), ), ).pipe(Layer.provide(registry)) -const models = SessionRunnerModel.layerWith(() => Effect.succeed(model)) +const models = SessionRunnerModel.layerWith((session) => + Effect.succeed(session.model?.id === "replacement" ? replacementModel : model), +) const systemContextKey = SystemContext.Key.make("test/context") let systemBaseline = "Initial context" let systemRemoved = false +let systemUnavailable = false const systemContext = Layer.succeed( SessionSystemContext.Service, SessionSystemContext.Service.of({ @@ -148,15 +153,17 @@ const systemContext = Layer.succeed( Effect.sync(() => ({ entries: systemRemoved ? [] - : [ - { - _tag: "Available" as const, - key: systemContextKey, - baseline: systemBaseline, - update: systemBaseline, - hash: Hash.sha256(systemBaseline), - }, - ], + : systemUnavailable + ? [{ _tag: "Unavailable" as const, key: systemContextKey }] + : [ + { + _tag: "Available" as const, + key: systemContextKey, + baseline: systemBaseline, + update: systemBaseline, + hash: Hash.sha256(systemBaseline), + }, + ], })), }), ) @@ -229,6 +236,7 @@ const setup = Effect.gen(function* () { response = [] systemBaseline = "Initial context" systemRemoved = false + systemUnavailable = false responses = undefined streamFailure = undefined responseStream = undefined @@ -236,6 +244,7 @@ const setup = Effect.gen(function* () { streamStarted = undefined toolExecutionGate = undefined toolExecutionsStarted = undefined + toolExecutionsReady = 5 activeToolExecutions = 0 maxActiveToolExecutions = 0 yield* db @@ -656,6 +665,105 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("defers replacement while admitted context is temporarily unavailable", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + + requests.length = 0 + response = [] + yield* session.resume(sessionID) + yield* events.publish(SessionEvent.ModelSwitched, { + sessionID, + timestamp: DateTime.makeUnsafe(1), + model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, + }) + systemUnavailable = true + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.resume(sessionID) + systemUnavailable = false + systemBaseline = "Replacement context" + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false }) + yield* session.resume(sessionID) + + expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ + ["Initial context"], + ["Initial context"], + ["Replacement context"], + ]) + const { db } = yield* Database.Service + expect( + yield* db + .select({ id: EventTable.id }) + .from(EventTable) + .where(eq(EventTable.type, "session.next.context.replaced.1")) + .all() + .pipe(Effect.orDie), + ).toHaveLength(1) + }), + ) + + it.effect("replays retained context projections while replacement is pending", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + + requests.length = 0 + response = [] + yield* session.resume(sessionID) + systemBaseline = "Changed context" + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.resume(sessionID) + yield* events.publish(SessionEvent.ModelSwitched, { + sessionID, + timestamp: DateTime.makeUnsafe(1), + model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, + }) + + yield* replaySessionProjection(sessionID) + systemBaseline = "Replacement context" + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false }) + yield* session.resume(sessionID) + expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Replacement context"]) + }), + ) + + it.effect("replays retained context projections after multiple replacements", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + + requests.length = 0 + response = [] + yield* session.resume(sessionID) + yield* events.publish(SessionEvent.ModelSwitched, { + sessionID, + timestamp: DateTime.makeUnsafe(1), + model: { id: ModelV2.ID.make("replacement-1"), providerID: ProviderV2.ID.make("fake") }, + }) + systemBaseline = "Replacement context 1" + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.resume(sessionID) + yield* events.publish(SessionEvent.ModelSwitched, { + sessionID, + timestamp: DateTime.makeUnsafe(2), + model: { id: ModelV2.ID.make("replacement-2"), providerID: ProviderV2.ID.make("fake") }, + }) + systemBaseline = "Replacement context 2" + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false }) + yield* session.resume(sessionID) + + yield* replaySessionProjection(sessionID) + expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Replacement context 2"]) + }), + ) + it.effect("replaces the baseline lazily after completed compaction without reopening replacement on replay", () => Effect.gen(function* () { yield* setup @@ -863,6 +971,49 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("reloads a model switch before a tool-driven continuation turn", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Echo this" }), resume: false }) + + requests.length = 0 + responses = [ + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call-echo", name: "echo", input: { text: "hello" } }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), + LLMEvent.finish({ reason: "tool-calls" }), + ], + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.stepFinish({ index: 0, reason: "stop" }), + LLMEvent.finish({ reason: "stop" }), + ], + ] + toolExecutionGate = yield* Deferred.make() + toolExecutionsStarted = yield* Deferred.make() + toolExecutionsReady = 1 + const run = yield* Effect.forkChild(session.resume(sessionID)) + yield* Deferred.await(toolExecutionsStarted) + yield* events.publish(SessionEvent.ModelSwitched, { + sessionID, + timestamp: DateTime.makeUnsafe(1), + model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, + }) + systemBaseline = "Replacement context" + yield* Deferred.succeed(toolExecutionGate, undefined) + yield* Fiber.join(run) + + expect(requests.map((request) => request.model)).toEqual([model, replacementModel]) + expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ + ["Initial context"], + ["Replacement context"], + ]) + }), + ) + it.effect("restores durable reasoning provider metadata in a second-turn request", () => Effect.gen(function* () { yield* setup diff --git a/packages/core/test/system-context.test.ts b/packages/core/test/system-context.test.ts index 406d4f8c62..92acf5ad30 100644 --- a/packages/core/test/system-context.test.ts +++ b/packages/core/test/system-context.test.ts @@ -69,7 +69,6 @@ describe("SystemContext", () => { "core/skills": Hash.sha256("Available skills: effect"), }, }) - expect(SystemContext.render(refreshed.changes)).toBe("The current date is 2026-06-04.\n\nAvailable skills: effect") }) test("omits unavailable initial context and admits it after its first successful load", async () => { @@ -112,6 +111,23 @@ describe("SystemContext", () => { expect(refreshed).toEqual({ changes: [], checkpoint: previous }) }) + test("blocks replacement while admitted context is unavailable", async () => { + const previous = { "core/remote-instructions": Hash.sha256("Remote instructions: old") } + const snapshot = await Effect.runPromise( + SystemContext.load( + SystemContext.struct({ + remote: SystemContext.value({ + key: key("core/remote-instructions"), + load: Effect.succeed(SystemContext.unavailable), + }), + }), + ), + ) + + expect(SystemContext.replacementBlocked(snapshot, previous)).toBe(true) + expect(SystemContext.replacementBlocked(snapshot, {})).toBe(false) + }) + test("emits tombstones and drops checkpoints for removed components", async () => { const context = SystemContext.struct({ date: SystemContext.value({ @@ -185,4 +201,13 @@ describe("SystemContext", () => { expect(() => decode("date")).toThrow() expect(() => decode("core/")).toThrow() }) + + test("requires namespaced checkpoint keys", () => { + const decode = Schema.decodeUnknownSync(SystemContext.CheckpointSchema) + const valid = JSON.parse('{"core/date":"hash"}') + const invalid = JSON.parse('{"date":"hash"}') + + expect(decode(valid)).toEqual(valid) + expect(() => decode(invalid)).toThrow() + }) }) diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index c83cd7288b..366e110792 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -386,7 +386,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* ( for (const [index, message] of request.messages.entries()) { if (message.role === "system") { - yield* ProviderShared.guardSystemUpdatePlacement("Anthropic Messages", request.messages[index - 1]) + yield* ProviderShared.guardSystemUpdatePlacement("Anthropic Messages", request.messages, index) if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request.messages, index)) { messages.push(yield* lowerNativeSystemUpdate(message, breakpoints)) continue diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index 52affab7b9..097fb195e4 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -294,7 +294,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* ( for (const [index, message] of request.messages.entries()) { if (message.role === "system") { - yield* ProviderShared.guardSystemUpdatePlacement("Bedrock Converse", request.messages[index - 1]) + yield* ProviderShared.guardSystemUpdatePlacement("Bedrock Converse", request.messages, index) const part = yield* ProviderShared.wrappedSystemUpdate("Bedrock Converse", message) const content = textWithCache(breakpoints, part.text, part.cache) const previous = messages.at(-1) diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts index 5b3487b026..b0348074e2 100644 --- a/packages/llm/src/protocols/gemini.ts +++ b/packages/llm/src/protocols/gemini.ts @@ -202,7 +202,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR for (const [index, message] of request.messages.entries()) { if (message.role === "system") { - yield* ProviderShared.guardSystemUpdatePlacement("Gemini", request.messages[index - 1]) + yield* ProviderShared.guardSystemUpdatePlacement("Gemini", request.messages, index) const part = yield* ProviderShared.wrappedSystemUpdate("Gemini", message) const previous = contents.at(-1) if (previous?.role === "user") diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index 00ad65c967..9f07179c3d 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -254,7 +254,7 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: const messages = [...system] for (const [index, message] of request.messages.entries()) { if (message.role === "system") { - yield* ProviderShared.guardSystemUpdatePlacement("OpenAI Chat", request.messages[index - 1]) + yield* ProviderShared.guardSystemUpdatePlacement("OpenAI Chat", request.messages, index) const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message) const previous = messages.at(-1) if (previous?.role === "user") diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 400f4e7a6b..f20dc07e7a 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -340,7 +340,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ for (const [index, message] of request.messages.entries()) { if (message.role === "system") { - yield* ProviderShared.guardSystemUpdatePlacement("OpenAI Responses", request.messages[index - 1]) + yield* ProviderShared.guardSystemUpdatePlacement("OpenAI Responses", request.messages, index) const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Responses", message) const previous = input.at(-1) if (previous && "role" in previous && previous.role === "user") diff --git a/packages/llm/src/protocols/shared.ts b/packages/llm/src/protocols/shared.ts index 98323a7ffe..980870952b 100644 --- a/packages/llm/src/protocols/shared.ts +++ b/packages/llm/src/protocols/shared.ts @@ -179,12 +179,18 @@ export const wrappedSystemUpdate = Effect.fn("ProviderShared.wrappedSystemUpdate export const guardSystemUpdatePlacement = Effect.fn("ProviderShared.guardSystemUpdatePlacement")(function* ( route: string, - previous: LLMRequest["messages"][number] | undefined, + messages: LLMRequest["messages"], + index: number, ) { - if ( - previous?.role === "assistant" && - previous.content.some((part) => part.type === "tool-call" && part.providerExecuted !== true) - ) + const pending = new Set() + for (const message of messages.slice(0, index)) { + for (const part of message.content) { + if (message.role === "assistant" && part.type === "tool-call" && part.providerExecuted !== true) + pending.add(part.id) + if (message.role === "tool" && part.type === "tool-result") pending.delete(part.id) + } + } + if (pending.size > 0) return yield* invalidRequest( `${route} chronological system updates cannot appear between a local tool call and its tool result`, ) diff --git a/packages/llm/test/schema.test.ts b/packages/llm/test/schema.test.ts index b824544d0b..0dbf90f282 100644 --- a/packages/llm/test/schema.test.ts +++ b/packages/llm/test/schema.test.ts @@ -65,12 +65,28 @@ describe("llm schema", () => { }) test("rejects chronological system updates between a local tool call and its result", async () => { - const previous = Message.assistant([ - ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }), - { type: "text", text: "Waiting." }, - ]) + const messages = [ + Message.assistant([ + ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }), + { type: "text", text: "Waiting." }, + ]), + ] - await expect(Effect.runPromise(ProviderShared.guardSystemUpdatePlacement("Test", previous))).rejects.toThrow( + await expect(Effect.runPromise(ProviderShared.guardSystemUpdatePlacement("Test", messages, 1))).rejects.toThrow( + "Test chronological system updates cannot appear between a local tool call and its tool result", + ) + }) + + test("rejects chronological system updates between results for multiple local tool calls", async () => { + const messages = [ + Message.assistant([ + ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }), + ToolCallPart.make({ id: "call_2", name: "lookup", input: {} }), + ]), + Message.tool({ id: "call_1", name: "lookup", result: "first" }), + ] + + await expect(Effect.runPromise(ProviderShared.guardSystemUpdatePlacement("Test", messages, 2))).rejects.toThrow( "Test chronological system updates cannot appear between a local tool call and its tool result", ) }) diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index a2db44a13a..441435eb5d 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -703,7 +703,7 @@ Change: Compatibility: -- This adds one database migration and one synchronized Session event type. +- The unpublished Context Epoch schema is consolidated into one database migration and this adds one synchronized Session event type. - Existing experimental V2 Session databases remain disposable across incompatible pre-launch event-schema changes. - Chronological context updates, replacement epochs after compaction or model switches, project instructions, skills guidance, and plugin transforms remain follow-up slices. @@ -719,13 +719,13 @@ Change: - Refresh Location-scoped Context Components at each safe provider-turn boundary. - Keep the stored baseline immutable while admitting changed component values as runner-private chronological `Message.system(...)` history. -- Advance component-hash checkpoints transactionally even when a removed component produces no visible update text. +- Emit an explicit tombstone update and advance component-hash checkpoints transactionally when a component is removed. - Keep ordinary Session transcript APIs unchanged while runner history merges visible Session messages and hidden context updates by durable aggregate sequence. - Reject chronological system updates that would split a local tool call from its result across provider protocols; use wrapped user fallback when Anthropic native system-update placement is unsupported. Compatibility: -- This adds one database migration and one synchronized Session event type. +- The unpublished Context Epoch schema remains consolidated into one database migration and this adds one synchronized Session event type. - 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. @@ -734,7 +734,7 @@ Compatibility: Affected schema: - Add synchronized `session.next.context.replaced.1` Session events. -- Add `session_context_epoch.replacement_pending` and `session_context_epoch.replacement_seq` for idempotent lazy replacement requests. +- Add nullable `session_context_epoch.replacement_seq` for idempotent lazy replacement requests. Change: @@ -745,6 +745,6 @@ Change: Compatibility: -- This adds two additive database migrations and one synchronized Session event type. +- The unpublished Context Epoch schema remains consolidated into one database migration and this adds one synchronized Session event type. - 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. From ad79f1307301ec5250d375cc6fbd65daf5a873a2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 4 Jun 2026 15:16:03 -0400 Subject: [PATCH 03/17] test(llm): record chronological system updates --- ...haiku-4-5-chronological-system-update.json | 39 +++++++++++++++++++ ...2-5-flash-chronological-system-update.json | 38 ++++++++++++++++++ ...t-4o-mini-chronological-system-update.json | 38 ++++++++++++++++++ ...s-gpt-5-5-chronological-system-update.json | 39 +++++++++++++++++++ .../llm/test/provider/golden.recorded.test.ts | 12 ++++-- packages/llm/test/recorded-scenarios.ts | 29 +++++++++++++- 6 files changed, 191 insertions(+), 4 deletions(-) create mode 100644 packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-haiku-4-5-chronological-system-update.json create mode 100644 packages/llm/test/fixtures/recordings/gemini/gemini-2-5-flash-chronological-system-update.json create mode 100644 packages/llm/test/fixtures/recordings/openai-chat/openai-chat-gpt-4o-mini-chronological-system-update.json create mode 100644 packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-chronological-system-update.json diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-haiku-4-5-chronological-system-update.json b/packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-haiku-4-5-chronological-system-update.json new file mode 100644 index 0000000000..2708b2ec6f --- /dev/null +++ b/packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-haiku-4-5-chronological-system-update.json @@ -0,0 +1,39 @@ +{ + "version": 1, + "metadata": { + "name": "anthropic-messages/anthropic-haiku-4-5-chronological-system-update", + "recordedAt": "2026-06-04T19:14:47.473Z", + "provider": "anthropic", + "route": "anthropic-messages", + "transport": "http", + "model": "claude-haiku-4-5-20251001", + "tags": [ + "prefix:anthropic-messages", + "provider:anthropic", + "system", + "chronological-system-update", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"Follow the latest instruction exactly. Reply only with the requested word.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use the latest instruction for your reply.\"},{\"type\":\"text\",\"text\":\"\\nFor this reply, respond exactly with: UPDATED\\n\"}]}],\"stream\":true,\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01QxsUPda7mfZ9zGSgw4JD54\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":49,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":5,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"UPDATED\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":49,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":5} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/gemini/gemini-2-5-flash-chronological-system-update.json b/packages/llm/test/fixtures/recordings/gemini/gemini-2-5-flash-chronological-system-update.json new file mode 100644 index 0000000000..e7bb189294 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/gemini/gemini-2-5-flash-chronological-system-update.json @@ -0,0 +1,38 @@ +{ + "version": 1, + "metadata": { + "name": "gemini/gemini-2-5-flash-chronological-system-update", + "recordedAt": "2026-06-04T19:14:48.065Z", + "provider": "google", + "route": "gemini", + "transport": "http", + "model": "gemini-2.5-flash", + "tags": [ + "prefix:gemini", + "provider:google", + "system", + "chronological-system-update", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + "headers": { + "content-type": "application/json" + }, + "body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Use the latest instruction for your reply.\"},{\"text\":\"\\nFor this reply, respond exactly with: UPDATED\\n\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"Follow the latest instruction exactly. Reply only with the requested word.\"}]},\"generationConfig\":{\"maxOutputTokens\":80,\"temperature\":0,\"thinkingConfig\":{\"thinkingBudget\":0}}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"UPDATED\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 44,\"candidatesTokenCount\": 2,\"totalTokenCount\": 46,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 44}],\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"p84hasmqJL2I-sAPtZnUkA0\"}\r\n\r\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-chat/openai-chat-gpt-4o-mini-chronological-system-update.json b/packages/llm/test/fixtures/recordings/openai-chat/openai-chat-gpt-4o-mini-chronological-system-update.json new file mode 100644 index 0000000000..6d002e4b94 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-chat/openai-chat-gpt-4o-mini-chronological-system-update.json @@ -0,0 +1,38 @@ +{ + "version": 1, + "metadata": { + "name": "openai-chat/openai-chat-gpt-4o-mini-chronological-system-update", + "recordedAt": "2026-06-04T19:14:43.750Z", + "provider": "openai", + "route": "openai-chat", + "transport": "http", + "model": "gpt-4o-mini", + "tags": [ + "prefix:openai-chat", + "provider:openai", + "system", + "chronological-system-update", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Follow the latest instruction exactly. Reply only with the requested word.\"},{\"role\":\"user\",\"content\":\"Use the latest instruction for your reply.\\n\\nFor this reply, respond exactly with: UPDATED\\n\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "data: {\"id\":\"chatcmpl-Dn7oZjOtsXXzajgbNQ17HQXOPABbd\",\"object\":\"chat.completion.chunk\",\"created\":1780600483,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_e0de90b008\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"uIKJFKJXo\"}\n\ndata: {\"id\":\"chatcmpl-Dn7oZjOtsXXzajgbNQ17HQXOPABbd\",\"object\":\"chat.completion.chunk\",\"created\":1780600483,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_e0de90b008\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"UPDATED\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"6HB3\"}\n\ndata: {\"id\":\"chatcmpl-Dn7oZjOtsXXzajgbNQ17HQXOPABbd\",\"object\":\"chat.completion.chunk\",\"created\":1780600483,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_e0de90b008\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"UlWDE\"}\n\ndata: {\"id\":\"chatcmpl-Dn7oZjOtsXXzajgbNQ17HQXOPABbd\",\"object\":\"chat.completion.chunk\",\"created\":1780600483,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_e0de90b008\",\"choices\":[],\"usage\":{\"prompt_tokens\":50,\"completion_tokens\":1,\"total_tokens\":51,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"w348ZVmuZE6\"}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-chronological-system-update.json b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-chronological-system-update.json new file mode 100644 index 0000000000..f434c08f21 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-chronological-system-update.json @@ -0,0 +1,39 @@ +{ + "version": 1, + "metadata": { + "name": "openai-responses/openai-responses-gpt-5-5-chronological-system-update", + "recordedAt": "2026-06-04T19:14:46.794Z", + "provider": "openai", + "route": "openai-responses", + "transport": "http", + "model": "gpt-5.5", + "tags": [ + "prefix:openai-responses", + "provider:openai", + "flagship", + "system", + "chronological-system-update", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the latest instruction exactly. Reply only with the requested word.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Use the latest instruction for your reply.\"},{\"type\":\"input_text\",\"text\":\"\\nFor this reply, respond exactly with: UPDATED\\n\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":80,\"stream\":true}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_0b058cc7f4ec9168016a21cea3d244819da4789560e119a7ac\",\"object\":\"response\",\"created_at\":1780600483,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_0b058cc7f4ec9168016a21cea3d244819da4789560e119a7ac\",\"object\":\"response\",\"created_at\":1780600483,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"rs_0b058cc7f4ec9168016a21cea4a180819dab9efa31556dff80\",\"type\":\"reasoning\",\"content\":[],\"encrypted_content\":\"gAAAAABqIc6kqld4jlRSpxnmJIcQxeZVqwQ9w073E8j7To6pXvbKYQTT2P_KD86yGR20IrGeredD67SvjdCe32pPRqNcDgit4XCJuSKWzvQt8EZOrs5ZbY7EjnRWOWv7WGLRFyzIJG2oQVbEwrJZzV-msWBXOj45GobymmHAJT1yj76ks33WcKtOSyGXWfmzIx1tfeVFULriBFnnWJ45iYFJS2VdwvkHyLci6uitNfwHWOALkA00aavmw9oCac8LSDrKz7aIOYMHekFI4IosCjJ0GgTrPvGm_D0EIzeAcNn_ofL31u5i1r_v--RNC9dog0Xc1DxoXeXDoLUj1YZ3JxWjFhhyAlraZ-t5S4kHsAqIo_21usyZf2u59DSdIvA1JnzDYQO24-LhsnMsHjqVvh0tYh9EZ75U7-7arr8KOupXzrFDY4SrNFmdTdyT4AdDF8CamDfHszti2P5a0q0E_0lBCehiMPBHlg8yKvVN8oSkf3svUIyJfaQ80g_K0tOgwkOW54rMbBalKNRKOCQnP7_eN16XldJbhcPY1K00XUBjfWOxLR-mXv1-20CINZKsy6a_lp-yrR7fcGaYykY7v75-svaj-6nEpVTelFiepcnHySP3vBzEzlP9n7eQf6CEcb8IUNh8sYQSnnvpEba9kWrTz7laA-EseH1tIN_GHxd1aTPyg_hW1mjN3IajA38Lvsd7RT1rcE644zSbz2-mqh5NTkQ9SGkvW3yhTLJWageRvT_qSqPVnEv18qtO0KgSOLgmsij43kfPvrcXFmnSnwtuf6kebY_T88il919MIRSuLEzI4vocXiL3sJ34pp4HPb1nfdu6N8IP_m28ti47LOQi5FZd9oFm8aYJgUaijlu5O7loDcI5Mz1uNsSqAd0V6Vs-mPFPLj00WqF4wFaYsp4qjmr9uNVaug==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"rs_0b058cc7f4ec9168016a21cea4a180819dab9efa31556dff80\",\"type\":\"reasoning\",\"content\":[],\"encrypted_content\":\"gAAAAABqIc6lpWyK-ZtXIPgHUsad6JFwBf2WpDu7lyu-ISjz0dAs-NPdkHl21o6eo84i9i-kuwPidonWRG35JNnedZ-4k17N5iJC81DwW8Gpb83rQ6VN1YqNQeWEY0Y56euNz4A13EzP84g-_X-6NDxS9WvQWTBwNrdkDKsRGn62CGxaz6EiO8Gkeb9ZGUxLc0TdLtM4fBK3Kpy1mvFOyp1kv4CSw1VE3g3iN2p8CskF1BaduprfT9WDPRfO4CHJs8_YlEFDN_THnIy-2vXsCJlqoMZ7xg0i1syW0osiISNXWimhRRg5wFME50ZQV0F6WjETCFcMsFrkEHAdjxGhhrWroPT_79niuE1iA74iiwUpeF1Bp5dTYBbwijJrDnhFG5CpOYtgUb5-5lPdwmUK0nbYDBL6dQC-PntXU6vi0u3pVRcil0dOqkzhzy56BtBM4tJagYnhuAaAkW86nbq31qq1eH8XmUSKItZ3Um2eOlGHwRdV_0iZVYYvqquoByy4Wptznw0y30XvMSeSDp2deLqzU4dCLYgxcYczlREEd1pVCah-HjpSipHNcUUMshP8h0wKBcy3C5ciBzT-cmgTIgCEEWkAatSx3ADDM9MOnk6fjogmkj-FETMIebgI02zN4Qh1GXJZahseuhouM8O5ei-17XqHWwlRvEZfolUfHSiBnHGH1Hk2mdnqLm3WfAlULQ1d0SEYrZlztdMuQW-4Fw619fixbatUvp_p0VvlOPGEKXmgKMy_ZOkJSVIS0O0KphVRCebnVWRsg5oGGezVormxKaZj5yKYtgSaODXM_q0GTgo5OxaaIz7RHX_c3e8URJ5auUwZGXh3mzNgsB7VDo9wkawCeHG0BVJ1akwF7J1uNcwfMD1tFNrO5zQwF1NfiwFo-uVots8AHK5pZqrFpY64LULoiNVmqD1EZpB_sUCwpQdVSvgox5ZvW4xzcdNEDsuYklCB25xruiIo9qufGrrSjSK8DNxB2w==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":3}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_0b058cc7f4ec9168016a21cea52760819d927c7a1d6538a62a\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":4}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_0b058cc7f4ec9168016a21cea52760819d927c7a1d6538a62a\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"UPDATED\",\"item_id\":\"msg_0b058cc7f4ec9168016a21cea52760819d927c7a1d6538a62a\",\"logprobs\":[],\"obfuscation\":\"mgR1eK6bQ\",\"output_index\":1,\"sequence_number\":6}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_0b058cc7f4ec9168016a21cea52760819d927c7a1d6538a62a\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":7,\"text\":\"UPDATED\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_0b058cc7f4ec9168016a21cea52760819d927c7a1d6538a62a\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"UPDATED\"},\"sequence_number\":8}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_0b058cc7f4ec9168016a21cea52760819d927c7a1d6538a62a\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"UPDATED\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":9}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_0b058cc7f4ec9168016a21cea3d244819da4789560e119a7ac\",\"object\":\"response\",\"created_at\":1780600483,\"status\":\"completed\",\"background\":false,\"completed_at\":1780600486,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"rs_0b058cc7f4ec9168016a21cea4a180819dab9efa31556dff80\",\"type\":\"reasoning\",\"content\":[],\"encrypted_content\":\"gAAAAABqIc6m-feiwQGp0dCrntYnYC-tRtrFG_e34B0XI6-cuPjU9c7PhWAjN7pyE8sIXfe8t1Nxi8aqXwfKbMgFX-lycUFo6Fyv209ZOpWwUf4gL5KIvCnHvISI13qmgiIzti2HHBO3HYSHNdFvrj8B3AxU1KEMjn7Pnv1-c23i0H9mYWp6TTO9K1vDCWkGZYpGe9OP0aFBZmxkdB5fWWwi2X6naLOpvHn2ssTesecwQ2lRyrdY_EV1rtL8YL0zG78azgQdJPNRL8gv_BQ1HLHS1xUDgvYnVgQb8U8Tu-42scVWP6qBXJk3-01d6sjUj8RYVgrsjLIsHZq1QlnBTSNnynGLoALtt7cC4g3LhbTCjSTP5kG-KxDQ8RiBd34CP8voN8oaM_2Ycs0t7mCtBZdmWjw92iViVlW7wVV5PNU8fPz0BqGQqKWnb6xawvEqPxhxiiNuaby26mGdMOpilDNJVyQV06NVAMtNW7givFKxT-cRuvWxecfFgGdSfbK3V03k1NlN5Pard8ZqZgqZSpOEk_MpV59outeqvKozOl1jX6E8SXPtPW4nNfHz_vUb57hKGWx6meJj2kZlQ1xpb3Tb4MEMLltKj_OPbfyv-GfXyhm_enzRCu0iepQAhU1fFHfns8z0tT4xndX_5mEMgiDkXDBg0bfXpBA8uOuiJIlV5s1wXi85zJQ6hpnqG6fKMNI2iGgQihIm4OiiG73eyePSVto8nAiq_Q4WnDMX_k1w6V1D5IIeTIc8MLZjDL5kw3pm6GnnoxlNoYoBnmdupW_ckMKMzNDg0dGLQ17TsaEiGr1dfAxStBiP1hIx_fFYebksXtFr-OVxCN_Z09hkEQ6BOvrDtE02VPI_7vjz9EroixIKYdJDWwmvXwiXYGLSzL4uyN1Z4aLS5MrfWeG3AQNSMjOBBQ_SsPUFFqskhMiy1keO5-htbgV1gbOl9BlnNO1VaACCAKdbweOdosCUiRojQ3KKhhlVhg==\",\"summary\":[]},{\"id\":\"msg_0b058cc7f4ec9168016a21cea52760819d927c7a1d6538a62a\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"UPDATED\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":48,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":17,\"output_tokens_details\":{\"reasoning_tokens\":10},\"total_tokens\":65},\"user\":null,\"metadata\":{}},\"sequence_number\":10}\n\n" + } + } + ] +} diff --git a/packages/llm/test/provider/golden.recorded.test.ts b/packages/llm/test/provider/golden.recorded.test.ts index 79d8589cf8..f82df3b5eb 100644 --- a/packages/llm/test/provider/golden.recorded.test.ts +++ b/packages/llm/test/provider/golden.recorded.test.ts @@ -73,7 +73,7 @@ describeRecordedGoldenScenarios([ prefix: "openai-chat", model: openAIChat, requires: ["OPENAI_API_KEY"], - scenarios: ["text", "tool-call", "tool-loop"], + scenarios: ["text", "chronological-system-update", "tool-call", "tool-loop"], }, { name: "OpenAI Responses gpt-5.5", @@ -83,6 +83,7 @@ describeRecordedGoldenScenarios([ tags: ["flagship"], scenarios: [ { id: "text", temperature: false }, + { id: "chronological-system-update", temperature: false }, { id: "reasoning", temperature: false }, { id: "reasoning-continuation", temperature: false }, { id: "tool-call", temperature: false }, @@ -104,7 +105,7 @@ describeRecordedGoldenScenarios([ model: anthropicHaiku, requires: ["ANTHROPIC_API_KEY"], options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) }, - scenarios: ["text", "tool-call"], + scenarios: ["text", "chronological-system-update", "tool-call"], }, { name: "Anthropic Opus 4.7", @@ -123,7 +124,12 @@ describeRecordedGoldenScenarios([ prefix: "gemini", model: gemini, requires: ["GOOGLE_GENERATIVE_AI_API_KEY"], - scenarios: [{ id: "text", maxTokens: 80 }, "tool-call", { id: "image", maxTokens: 160 }], + scenarios: [ + { id: "text", maxTokens: 80 }, + "chronological-system-update", + "tool-call", + { id: "image", maxTokens: 160 }, + ], }, { name: "xAI Grok 3 Mini", diff --git a/packages/llm/test/recorded-scenarios.ts b/packages/llm/test/recorded-scenarios.ts index 1fc01ae76e..1aa9998bfc 100644 --- a/packages/llm/test/recorded-scenarios.ts +++ b/packages/llm/test/recorded-scenarios.ts @@ -1,5 +1,5 @@ import { expect } from "bun:test" -import { Effect, Schema, Stream } from "effect" +import { Effect, Schema } from "effect" import { LLM, LLMEvent, @@ -332,6 +332,28 @@ const runTextScenario = (context: GoldenScenarioContext) => }), ]) +export const runChronologicalSystemUpdateScenario = (context: GoldenScenarioContext) => + Effect.gen(function* () { + const response = yield* generate( + LLM.request({ + id: `${context.id}_chronological_system_update`, + model: context.model, + system: "Follow the latest instruction exactly. Reply only with the requested word.", + cache: "none", + messages: [ + Message.user("Use the latest instruction for your reply."), + Message.system("For this reply, respond exactly with: UPDATED"), + ], + providerOptions: + context.model.route.id === "gemini" ? { gemini: { thinkingConfig: { thinkingBudget: 0 } } } : undefined, + generation: generation(context, context.maxTokens ?? 80), + }), + ) + + expectFinish(response.events, "stop") + expect(response.text.trim()).toMatch(/^UPDATED[.!]?$/i) + }) + const runToolCallScenario = (context: GoldenScenarioContext) => runGeneratedConversation(context, [ user("Call get_weather with city exactly Paris."), @@ -445,6 +467,11 @@ const runToolLoopScenario = (context: GoldenScenarioContext) => const goldenScenarios = { text: { title: "streams text", tags: ["text", "golden"], run: runTextScenario }, + "chronological-system-update": { + title: "uses chronological system update", + tags: ["system", "chronological-system-update", "golden"], + run: runChronologicalSystemUpdateScenario, + }, "tool-call": { title: "streams tool call", tags: ["tool", "tool-call", "golden"], run: runToolCallScenario }, "tool-loop": { title: "drives a tool loop", tags: ["tool", "tool-loop", "golden"], run: runToolLoopScenario }, image: { title: "reads image text", tags: ["media", "image", "vision", "golden"], run: runImageScenario }, From 39740e75da38ee38749e9eef3747345aead29ee2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 4 Jun 2026 15:34:31 -0400 Subject: [PATCH 04/17] docs(v2): design refreshable context sources --- specs/v2/refreshable-context-sources.md | 401 ++++++++++++++++++++++++ 1 file changed, 401 insertions(+) create mode 100644 specs/v2/refreshable-context-sources.md diff --git a/specs/v2/refreshable-context-sources.md b/specs/v2/refreshable-context-sources.md new file mode 100644 index 0000000000..fd66499665 --- /dev/null +++ b/specs/v2/refreshable-context-sources.md @@ -0,0 +1,401 @@ +# Refreshable Context Sources + +## Status + +Reviewed proposal for ambient `AGENTS.md`, configured instruction paths or URLs, and later local skill-source invalidation. + +## Decision Summary + +Context-source observation remains pull-based and lazy at a safe provider-turn boundary. + +```text +source signal +-> mark an optional observation cache stale +-> next naturally scheduled safe provider-turn boundary observes current state +-> Context Epoch compares and admits exact changed bytes durably +``` + +The first ambient `AGENTS.md` slice will not depend on filesystem watching. It will directly observe local instruction state whenever `SessionSystemContext.load()` naturally runs before a provider turn. + +Watcher-backed caches are a later efficiency optimization for roots with proven subscription coverage. URLs remain separate observations with an independently chosen refresh policy. + +## Existing Pieces + +| Existing piece | Responsibility | +| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `Watcher.locationLayer` | Publish advisory `file.watcher.updated` events for local filesystem changes. | +| `EventV2.subscribe(...)` | Expose advisory events as scoped Effect streams. | +| `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. | +| `SystemContext` | Convert coherent source samples into immutable baseline parts, chronological updates, unavailable state, and removal tombstones. | +| `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. + +## Primitive: `Refreshable` + +Place the optional coordination helper at: + +```text +packages/core/src/effect/refreshable.ts +``` + +`Refreshable` does not know about files, URLs, timers, watchers, Sessions, Context Epochs, or stale fallbacks. Domain services decide when to use it and how to recover expected observation failures. + +```ts +export interface Refreshable { + readonly get: Effect.Effect + readonly invalidate: Effect.Effect +} + +export const make = ( + load: Effect.Effect, +): Effect.Effect> +``` + +Internal state: + +```ts +type State = + | { readonly _tag: "Empty" } + | { readonly _tag: "Fresh"; readonly value: A } + | { readonly _tag: "Stale"; readonly value: A } +``` + +Implementation substrate: + +```text +SynchronizedRef.modifyEffect(...) +``` + +Semantics: + +| Operation | Behavior | +| ---------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `get` on `Empty` | Run `load`, store `Fresh(value)` only after success, and return it. | +| `get` on `Fresh(value)` | Return the cached value without I/O. | +| `get` on `Stale(previous)` | Run `load`, store `Fresh(value)` only after success, and return it. | +| `invalidate` on `Fresh(value)` | Store `Stale(value)`. | +| `invalidate` on `Empty` or `Stale` | No-op. Repeated invalidations coalesce. | +| failed `load` | Preserve the prior `Empty` or `Stale(previous)` state, propagate the failure, and retry on the next `get`. | +| invalidation during `load` | Serialize after the reload and leave the refreshed cache stale for the next `get`. | + +```mermaid +stateDiagram-v2 + [*] --> Empty + Empty --> Fresh: get / load succeeds + Empty --> Empty: get / load fails + Fresh --> Fresh: get / return cached + Fresh --> Stale: invalidate + Stale --> Fresh: get / reload succeeds + Stale --> Stale: invalidate or reload fails +``` + +### Why Custom Instead Of Effect `Cache` + +Effect `Cache`, `ScopedCache`, and `Effect.cachedInvalidateWithTTL(...)` cache failed exits. Context-source observation requires a narrower rule: + +```text +failed refresh +-> retain prior successful value internally +-> remain stale +-> retry at the next natural request +``` + +`SynchronizedRef.modifyEffect(...)` commits the next state only after the refresh effect succeeds. + +### Why No `peek` + +`Refreshable` should not expose stale fallback reads. A domain service that needs a previous discovery graph during failed rescans should own that graph explicitly in its observation model. + +### Why No Refresh Modes Or TTL + +An always-refreshed source does not need a cache: load it directly. TTL expiry, watcher events, and explicit source changes are external invalidation policies that may call `invalidate` later. + +```mermaid +sequenceDiagram + participant Signal as Optional Invalidation Source + participant Cache as Refreshable + participant Consumer + participant Loader + + Signal->>Cache: invalidate + Consumer->>Cache: get + Cache->>Loader: reload only when stale + Loader-->>Cache: successful coherent value + Cache-->>Consumer: refreshed value +``` + +## Observation Units + +Compose refreshables around coherent observations that share one invalidation policy. Do not create one uniformly per rendered Context Component or one aggregate cache for unrelated source kinds. + +```text +local built-in discovery +-> one coherent observation while it shares one refresh policy + +configured local glob +-> separate observation when its scan root or coverage differs + +configured URL +-> independent observation + +local skill directory +-> naturally one observation per registered source + +embedded skill +-> direct value, no refreshable +``` + +## Ambient Instruction Service + +Add a Location-scoped service: + +```ts +export interface InstructionContext.Interface { + readonly loadAmbient: () => Effect.Effect> +} +``` + +`InstructionContext` owns instruction discovery, stable source identity, deterministic ordering, and source loading. `SystemContext` remains unaware of files and URLs. + +Each effective source becomes one independently keyed component: + +```text +core/instructions/file/ +core/instructions/url/ +``` + +Rendered text retains the human-readable source identity: + +```text +Instructions from: /repo/packages/core/AGENTS.md + +``` + +or: + +```text +Instructions from: https://example.com/shared-agents.md + +``` + +The first implementation directly observes global and upward project `AGENTS.md` files on every safe provider-turn boundary. It does not use `Refreshable` yet unless a coherent source observation needs stale-on-failure retention. + +```mermaid +sequenceDiagram + participant Runner as Safe Provider Boundary + participant Instructions as Instruction Context + participant Files + participant Epoch as Context Epoch + + Runner->>Instructions: loadAmbient + Instructions->>Files: discover and read AGENTS.md files + Files-->>Instructions: coherent current observation + Instructions-->>Runner: independently keyed components + Runner->>Epoch: compare and durably admit changes +``` + +## Source Outcomes + +Discovery state and per-source byte observation are separate concerns. + +```text +discovery +-> which stable source identities currently exist? + +observation +-> what bytes or temporary failure does each identity currently produce? +``` + +| Observation | Component outcome | +| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| Local scan succeeds and discovers readable file | Available component with exact contents. | +| Local scan succeeds and a previously discovered file is absent | Remove component so `SystemContext` emits a tombstone. | +| Local scan fails transiently | Preserve the domain-owned prior source graph as unavailable or fail the current turn; never emit mass removals. | +| Known local file read fails transiently | Preserve the component as `SystemContext.unavailable`. | +| Known local file read reports not-found after discovery | Invalidate discovery and report unavailable until a coherent rescan confirms removal. | +| Empty local file | Available exact content, not absence. | +| URL returns `2xx` body | Available component with exact contents. | +| URL times out or returns transient failure | `SystemContext.unavailable`. | +| URL returns `404` or `410` | Decide the explicit removal contract before URL implementation. | + +Instruction removal text must be model-meaningful. If instruction component keys hash source identities, add source-specific removal rendering before unlink support is considered complete: + +```text +Instructions removed: /repo/packages/core/AGENTS.md +Do not continue applying instructions previously loaded from this source. +``` + +## First Ambient Slice + +Implement only: + +```text +global config AGENTS.md ++ upward project AGENTS.md ancestors ++ one keyed component per file ++ direct safe-turn observation +``` + +Preserve V1 ancestor stacking for `AGENTS.md`: nearest ancestor first, then outward through the project boundary. + +Do not include yet: + +```text +CLAUDE.md compatibility fallback +deprecated CONTEXT.md fallback +configured local paths or globs +configured URLs +watcher-backed caching +skills migration +nested read-triggered discovery +``` + +Tests: + +```text +initial baseline +edit +newly added ancestor AGENTS.md +confirmed unlink with meaningful removal text +empty file +transient scan failure +transient file-read failure +deterministic ordering +restart with durable checkpoint +``` + +## Future Watcher Optimization + +Watchers remain advisory optimizations. They never wake idle Sessions and never publish durable Session context events. + +The current watcher subscribes only to `location.directory`, has ignore rules, starts asynchronously, and swallows subscription failures. Effective source roots may live elsewhere. Do not expose one broad `Watcher.Service.local: "watching" | "poll"` flag. + +Add root-specific watching only when needed: + +```ts +export interface Watcher.Interface { + readonly watch: (root: AbsolutePath) => Effect.Effect<"watching" | "poll"> +} +``` + +The exact watcher API remains a follow-up design. A truthful contract must account for: + +- successful subscription startup; +- subscription failure and callback error; +- source roots above or outside `location.directory`; +- ignore and protected-path coverage; +- scope cleanup; +- own-process mutations that should synchronously invalidate caches before the next continuation turn. + +When root coverage is proven: + +```mermaid +sequenceDiagram + participant Watcher + participant Cache as Refreshable + participant Runner + participant Epoch as Context Epoch + + Watcher->>Cache: relevant add / change / unlink + Cache->>Cache: invalidate only + Runner->>Cache: get at next safe boundary + Cache-->>Runner: refreshed coherent observation + Runner->>Epoch: durably admit exact changes +``` + +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. + +## URL Sources + +URLs never share an observation cache with local discovery. + +Start with direct safe-turn loading: + +```text +safe provider-turn boundary +-> fetch URL +-> emit available or unavailable component +``` + +If measurements show excessive requests, add a URL-specific invalidation policy later: + +```text +TTL expires +-> invalidate URL Refreshable +-> next safe provider-turn boundary reloads URL +``` + +A TTL timer must not wake idle Sessions or publish durable Session events. + +## Skills Reuse + +`SkillV2` currently stores replayable source registrations through `State.create(...)` and materialized source results in a raw permanent `Map`. + +Use `Refreshable` later for local directory sources only after skill observation distinguishes confirmed absence from transient failure: + +```text +State.create +-> current replayable Source registrations + +private Map +-> reconcile against active non-embedded source keys +-> create refreshable for added source +-> drop refreshable for removed source +``` + +Do not add a generic `State -> cache invalidation` bridge. The skill service owns both current registrations and its observation-cache lifecycle. + +Remote skill refresh remains a separate design slice because the current puller skips files that already exist and therefore does not define overwrite or removal semantics. + +## Nested Instruction Discovery + +Nested instructions discovered after successful read-tool activity remain a Session-scoped follow-up. + +- The Location-scoped instruction service may resolve and reuse source observations. +- The set of nested source identities active for one Session must be durable and Session-scoped. +- Successful local file reads record newly observed nested source identities through synchronized Session events. +- The next safe provider boundary loads and admits them through Context Epoch history. +- Do not inject V1-style reminder text directly into read-tool output. +- Do not activate nested discovery for directory reads, managed tool-output resources, or external references until those semantics are explicitly designed. + +## Lifecycle + +- Location scope owns instruction services, optional watcher-consumer fibers, and refreshable state. +- `Effect.forkScoped(...)` interrupts watcher-consumer fibers when the cached Location runtime is disposed. +- Stream finalization unsubscribes `EventV2` PubSub subscriptions. +- `Watcher.locationLayer` separately finalizes native Parcel watcher subscriptions. +- Repeated invalidations coalesce into one `Stale` state. +- 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. + +## Proposed Implementation Order + +1. Add and unit-test `Refreshable.make(load)` with `get` and `invalidate`. +2. Add model-meaningful instruction removal rendering support before unlink lands. +3. Add Location-scoped `InstructionContext` for global and upward project `AGENTS.md` only. +4. Compose instruction components into `SessionSystemContext.load()`. +5. Observe local instructions directly at each safe provider boundary. +6. Test add, edit, unlink, empty file, transient scan failure, transient read failure, restart, and deterministic ordering. +7. Add configured local exact paths and globs. +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 + +1. Should source-specific removal rendering extend `SystemContext.Component`, or should `InstructionContext` retain removal metadata in a separate component registry? +2. Should the first local scan failure preserve prior discovered sources as unavailable, or fail the current provider turn until a coherent rescan succeeds? +3. Should configured URL sources treat `404` and `410` as confirmed removals? +4. What root-specific watcher API cleanly models ignore policy and callback health? +5. Should own-process file mutations publish an advisory invalidation event synchronously after commit? + +## Compression Line + +```text +State remembers what should be loaded. +Refreshable remembers whether a successful observation needs loading again. +Context Epoch remembers what the model was told. +``` From 00c4114911e5bde3f0235d495c9a6b7618946d0f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 4 Jun 2026 19:49:19 -0400 Subject: [PATCH 05/17] refactor(core): simplify v2 system context epochs --- AGENTS.md | 1 + CONTEXT.md | 49 +- .../migration.sql | 17 - .../migration.sql | 9 + .../snapshot.json | 63 +-- packages/core/src/database/migration.gen.ts | 2 +- ...604234609_add_session_context_snapshot.ts} | 13 +- packages/core/src/event.ts | 30 +- packages/core/src/session-system-context.ts | 39 +- packages/core/src/session/context-epoch.ts | 255 +++++----- packages/core/src/session/context.ts | 78 ++- packages/core/src/session/event.ts | 31 +- packages/core/src/session/message-updater.ts | 12 +- packages/core/src/session/message.ts | 8 +- packages/core/src/session/projector.ts | 12 +- packages/core/src/session/runner/llm.ts | 2 +- .../core/src/session/runner/to-llm-message.ts | 9 +- packages/core/src/session/sql.ts | 17 +- packages/core/src/session/store.ts | 2 +- packages/core/src/system-context.ts | 358 +++++++++---- packages/core/test/event.test.ts | 47 ++ .../core/test/session-runner-message.test.ts | 39 +- .../core/test/session-runner-recorded.test.ts | 25 +- packages/core/test/session-runner.test.ts | 156 +++--- .../core/test/session-system-context.test.ts | 57 ++- packages/core/test/system-context.test.ts | 469 +++++++++++------- .../src/cli/cmd/tui/context/sync-v2.tsx | 10 + .../tui/feature-plugins/system/session-v2.tsx | 3 + specs/v2/refreshable-context-sources.md | 39 +- specs/v2/schema-changelog.md | 37 +- 30 files changed, 1029 insertions(+), 860 deletions(-) delete mode 100644 packages/core/migration/20260604184448_add_session_context_epoch/migration.sql create mode 100644 packages/core/migration/20260604234609_add_session_context_snapshot/migration.sql rename packages/core/migration/{20260604184448_add_session_context_epoch => 20260604234609_add_session_context_snapshot}/snapshot.json (96%) rename packages/core/src/database/migration/{20260604184448_add_session_context_epoch.ts => 20260604234609_add_session_context_snapshot.ts} (55%) diff --git a/AGENTS.md b/AGENTS.md index 6ed0761b89..8880407a78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,6 +133,7 @@ const table = sqliteTable("session", { - Avoid mocks as much as possible - Test actual implementation, do not duplicate logic into tests +- In `packages/core/test`, define tests with the shared `it.effect` or `testEffect(...)` helpers from `test/lib/effect.ts`; do not use raw `test(...)`. Wrap synchronous assertions in `Effect.sync(...)`. - Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`. ## Type Checking diff --git a/CONTEXT.md b/CONTEXT.md index a391bf95cc..2758a944cf 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -8,12 +8,12 @@ OpenCode sessions preserve durable conversational history while assembling the r The structured collection of contextual facts presented to the model as initial instructions and chronological updates. _Avoid_: System prompt -**Context Component**: -One independently loaded fact within the **System Context**, represented by a stable key and one effectfully loaded baseline/update rendering. +**Context Source**: +One independently observed typed value within the **System Context**, represented by a stable key, JSON codec, infallible loader, pure baseline/update renderers, and an optional removal renderer for dynamic sources. _Avoid_: Prompt fragment **Mid-Conversation System Message**: -A durable chronological instruction that tells the model the newly effective state of a changed **Context Component**. +A durable chronological instruction that tells the model the newly effective state of a changed **Context Source**. _Avoid_: System update, system notification, raw text diff **Context Epoch**: @@ -23,44 +23,47 @@ The span during which one initially rendered **System Context** remains immutabl The full **System Context** rendered at the start of a **Context Epoch**. _Avoid_: Live system prompt -**Context Checkpoint**: -The durable model-hidden comparison state used to detect which **Context Components** changed since context was last admitted to a provider turn. +**Context Snapshot**: +The overwriteable model-hidden JSON state used to compare each **Context Source** with the value last admitted to a provider turn. **Unavailable Context**: -An expected temporary inability to load a **Context Component** value; the runtime retains its prior effective state and emits no update, or omits it until first successfully loaded. +An expected temporary inability to observe a **Context Source** value; the runtime retains its prior effective state and emits no update, or omits it until first successfully loaded. **Safe Provider-Turn Boundary**: The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically. ## Relationships -- A **System Context** contains one or more **Context Components**. -- A changed **Context Component** may produce one **Mid-Conversation System Message** containing its newly effective state. -- A **Mid-Conversation System Message** persists its originating **Context Component** key and the exact rendered text sent to the model. -- A **Context Checkpoint** advances atomically with the corresponding durable **Mid-Conversation System Message**. -- A **Context Checkpoint** stores one rendered-content hash per stable **Context Component** key so core and plugin-defined components can evolve independently. -- Changes from multiple **Context Components** admitted at one safe boundary combine into one **Mid-Conversation System Message**. +- A **System Context** is an opaque carrier composed from zero or more **Context Sources**. +- A changed **Context Source** may produce one **Mid-Conversation System Message** containing its newly effective state. +- A **Mid-Conversation System Message** persists the exact combined rendered text sent to the model. +- The current **Context Snapshot** advances atomically with the corresponding durable **Mid-Conversation System Message**. +- A **Context Snapshot** stores one codec-encoded JSON value and, for removable dynamic sources, a pre-rendered removal message per stable **Context Source** key. +- Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**. - Context changes are sampled and admitted lazily at a **Safe 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**. -- The first provider turn renders the latest **Baseline System Context** and initializes its **Context Checkpoint** without emitting a redundant **Mid-Conversation System Message**. -- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Checkpoint**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history. -- A **Context Checkpoint** is an evolvable component map; a newly registered core or plugin-defined **Context Component** absent from an existing checkpoint emits its current state once at the next **Safe Provider-Turn Boundary**. -- **Context Component** keys are stable and namespaced; duplicate keys fail assembly. Built-in components preserve declaration order and plugin-defined components append in lexicographic key order so rendered context is deterministic. -- Each **Context Component** loader returns its model-visible baseline string and absolute current-state update string from one coherent sample; the update string is hashed for change detection. +- The first provider turn renders the latest **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**. +- 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**. +- **Context Source** keys are stable and namespaced; duplicate keys fail composition. `SystemContext.combine(...)` preserves caller order; future plugin-source assembly must append plugin-defined sources in lexicographic key order so rendered context remains deterministic. +- Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed. +- `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**. +- `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replaced, 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. - **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text. -- Ordinary **Context Component** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**. +- Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**. - Nested project instruction files discovered while reading join the effective instructions returned by the instruction service and are admitted durably at the next **Safe Provider-Turn Boundary**. - 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. - Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values. -- Plugin-defined **Context Components** register through a scoped replayable registry so plugin hot reload adds and removes components predictably. +- Plugin-defined **Context Sources** register through a scoped replayable registry so plugin hot reload adds and removes sources predictably. - 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. -- **Mid-Conversation System Messages** remain durable model-projection history but are hidden from normal user-facing transcript surfaces. -- The date **Context Component** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later. +- **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them. +- The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later. - A **Context Epoch** begins with one immutable **Baseline System Context**. - A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**. -- A **Baseline System Context** durably preserves deterministic keyed top-level component strings rather than eagerly joining all text; request assembly lowers them into canonical LLM system parts. +- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix. - Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache. - A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history. - A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise. @@ -73,5 +76,5 @@ The point immediately before a provider call, after durable input promotion and ## Flagged ambiguities -- Legacy `experimental.chat.system.transform` can mutate the assembled baseline system prompt arbitrarily, but V2 plugins do not yet expose an equivalent hook. Decide separately whether to port it, replace dynamic uses with plugin-defined **Context Components**, or narrow its semantics. +- Legacy `experimental.chat.system.transform` can mutate the assembled baseline system prompt arbitrarily, but V2 plugins do not yet expose an equivalent hook. Decide separately whether to port it, replace dynamic uses with plugin-defined **Context Sources**, or narrow its semantics. - A location change likely starts a new **Context Epoch** so location-dependent instructions and discovery can be rebuilt cleanly, but implementation should verify whether an append-only update is sufficient and meaningfully preserves cache. diff --git a/packages/core/migration/20260604184448_add_session_context_epoch/migration.sql b/packages/core/migration/20260604184448_add_session_context_epoch/migration.sql deleted file mode 100644 index a10df47539..0000000000 --- a/packages/core/migration/20260604184448_add_session_context_epoch/migration.sql +++ /dev/null @@ -1,17 +0,0 @@ -CREATE TABLE `session_context_epoch` ( - `session_id` text PRIMARY KEY, - `baseline` text NOT NULL, - `checkpoint` text NOT NULL, - `baseline_seq` integer NOT NULL, - `replacement_seq` integer, - `revision` integer DEFAULT 0 NOT NULL, - CONSTRAINT `fk_session_context_epoch_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE -); ---> statement-breakpoint -CREATE TABLE `session_context_message` ( - `session_id` text NOT NULL, - `seq` integer NOT NULL, - `parts` text NOT NULL, - CONSTRAINT `session_context_message_pk` PRIMARY KEY(`session_id`, `seq`), - CONSTRAINT `fk_session_context_message_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE -); diff --git a/packages/core/migration/20260604234609_add_session_context_snapshot/migration.sql b/packages/core/migration/20260604234609_add_session_context_snapshot/migration.sql new file mode 100644 index 0000000000..ec98751154 --- /dev/null +++ b/packages/core/migration/20260604234609_add_session_context_snapshot/migration.sql @@ -0,0 +1,9 @@ +CREATE TABLE `session_context_epoch` ( + `session_id` text PRIMARY KEY, + `baseline` text NOT NULL, + `snapshot` text NOT NULL, + `baseline_seq` integer NOT NULL, + `replacement_seq` integer, + `revision` integer DEFAULT 0 NOT NULL, + CONSTRAINT `fk_session_context_epoch_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE +); diff --git a/packages/core/migration/20260604184448_add_session_context_epoch/snapshot.json b/packages/core/migration/20260604234609_add_session_context_snapshot/snapshot.json similarity index 96% rename from packages/core/migration/20260604184448_add_session_context_epoch/snapshot.json rename to packages/core/migration/20260604234609_add_session_context_snapshot/snapshot.json index 4715fa89fe..22c68919c5 100644 --- a/packages/core/migration/20260604184448_add_session_context_epoch/snapshot.json +++ b/packages/core/migration/20260604234609_add_session_context_snapshot/snapshot.json @@ -1,7 +1,7 @@ { "version": "7", "dialect": "sqlite", - "id": "6fe30aa9-5772-49a6-b688-d022dbd28903", + "id": "65b52d0f-2bbe-483f-a7ec-9d2a7fa29f57", "prevIds": [ "fc92fa34-8074-44c3-88f0-a5417f7fd92d" ], @@ -58,10 +58,6 @@ "name": "session_context_epoch", "entityType": "tables" }, - { - "name": "session_context_message", - "entityType": "tables" - }, { "name": "session_input", "entityType": "tables" @@ -808,7 +804,7 @@ "autoincrement": false, "default": null, "generated": null, - "name": "checkpoint", + "name": "snapshot", "entityType": "columns", "table": "session_context_epoch" }, @@ -842,36 +838,6 @@ "entityType": "columns", "table": "session_context_epoch" }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "session_id", - "entityType": "columns", - "table": "session_context_message" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "seq", - "entityType": "columns", - "table": "session_context_message" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "parts", - "entityType": "columns", - "table": "session_context_message" - }, { "type": "integer", "notNull": false, @@ -1552,21 +1518,6 @@ "entityType": "fks", "table": "session_context_epoch" }, - { - "columns": [ - "session_id" - ], - "tableTo": "session", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_session_context_message_session_id_session_id_fk", - "entityType": "fks", - "table": "session_context_message" - }, { "columns": [ "session_id" @@ -1662,16 +1613,6 @@ "entityType": "pks", "table": "project_directory" }, - { - "columns": [ - "session_id", - "seq" - ], - "nameExplicit": false, - "name": "session_context_message_pk", - "entityType": "pks", - "table": "session_context_message" - }, { "columns": [ "session_id", diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index e0d8d04bfe..949521287d 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -32,6 +32,6 @@ export const migrations = ( import("./migration/20260603141458_session_input_inbox"), import("./migration/20260603160727_jittery_ezekiel_stane"), import("./migration/20260604172448_event_sourced_session_input"), - import("./migration/20260604184448_add_session_context_epoch"), + import("./migration/20260604234609_add_session_context_snapshot"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260604184448_add_session_context_epoch.ts b/packages/core/src/database/migration/20260604234609_add_session_context_snapshot.ts similarity index 55% rename from packages/core/src/database/migration/20260604184448_add_session_context_epoch.ts rename to packages/core/src/database/migration/20260604234609_add_session_context_snapshot.ts index 45ff9e5335..f035e53cbd 100644 --- a/packages/core/src/database/migration/20260604184448_add_session_context_epoch.ts +++ b/packages/core/src/database/migration/20260604234609_add_session_context_snapshot.ts @@ -2,29 +2,20 @@ import { Effect } from "effect" import type { DatabaseMigration } from "../migration" export default { - id: "20260604184448_add_session_context_epoch", + id: "20260604234609_add_session_context_snapshot", up(tx) { return Effect.gen(function* () { yield* tx.run(` CREATE TABLE \`session_context_epoch\` ( \`session_id\` text PRIMARY KEY, \`baseline\` text NOT NULL, - \`checkpoint\` text NOT NULL, + \`snapshot\` text NOT NULL, \`baseline_seq\` integer NOT NULL, \`replacement_seq\` integer, \`revision\` integer DEFAULT 0 NOT NULL, CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE ); `) - yield* tx.run(` - CREATE TABLE \`session_context_message\` ( - \`session_id\` text NOT NULL, - \`seq\` integer NOT NULL, - \`parts\` text NOT NULL, - CONSTRAINT \`session_context_message_pk\` PRIMARY KEY(\`session_id\`, \`seq\`), - CONSTRAINT \`fk_session_context_message_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE - ); - `) }) }, } satisfies DatabaseMigration.Migration diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 57dd587dec..8ae993967f 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -45,6 +45,8 @@ export type Payload = { readonly version?: number readonly location?: Location.Ref readonly metadata?: Record + /** Internal replay marker for projectors that own non-replicated operational state. */ + readonly replay?: boolean } export type Projector = (event: Payload) => Effect.Effect @@ -137,6 +139,8 @@ export interface PublishOptions { readonly id?: ID readonly metadata?: Record readonly location?: Location.Ref + /** Local operational projection committed atomically with a new synchronized event. Not replayed or serialized. */ + readonly commit?: (seq: number) => Effect.Effect } export interface Interface { @@ -151,6 +155,7 @@ export interface Interface { readonly aggregateID: string readonly after?: Cursor }) => Stream.Stream + readonly sequence: (aggregateID: string) => Effect.Effect readonly sync: (handler: Sync) => Effect.Effect readonly listen: (listener: Listener) => Effect.Effect readonly beforeCommit: (guard: CommitGuard) => Effect.Effect @@ -215,6 +220,7 @@ export const layerWith = (options?: LayerOptions) => readonly ownerID?: string readonly strictOwner?: boolean }, + commit?: (seq: number) => Effect.Effect, ) { return Effect.gen(function* () { const definition = registry.get(event.type) @@ -330,6 +336,10 @@ export const layerWith = (options?: LayerOptions) => for (const projector of list) { yield* projector({ ...event, seq } as Payload) } + if (commit) yield* commit(seq) + const encoded = syncRegistry + .get(versionedType(definition.type, sync.version))! + .encode(event.data) yield* db .insert(EventSequenceTable) .values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }]) @@ -375,11 +385,15 @@ export const layerWith = (options?: LayerOptions) => }) } - function publishEvent(event: Payload) { + function publishEvent(event: Payload, options?: PublishOptions) { return Effect.gen(function* () { const durable = registry.get(event.type)?.sync !== undefined + if (!durable && options?.commit) + return yield* Effect.die( + new InvalidSyncEventError({ type: event.type, message: "Local commit hooks require a synchronized event" }), + ) if (durable) { - const committed = yield* commitSyncEvent(event as Payload) + const committed = yield* commitSyncEvent(event as Payload, undefined, options?.commit) if (committed) { event = { ...event, seq: committed.seq } yield* Effect.forEach(syncHandlers, (sync) => observe(event as Payload, "sync", sync), { discard: true }) @@ -431,7 +445,7 @@ export const layerWith = (options?: LayerOptions) => ...(definition.sync === undefined ? {} : { version: definition.sync.version }), ...(location ? { location } : {}), data, - } as Payload) + } as Payload, options) }) } @@ -451,6 +465,7 @@ export const layerWith = (options?: LayerOptions) => type: definition.type, version: definition.sync.version, data: definition.decode(event.data), + replay: true, } as Payload const committed = yield* commitSyncEvent(payload, { seq: event.seq, @@ -519,6 +534,14 @@ export const layerWith = (options?: LayerOptions) => .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 = (definition: D): Stream.Stream> => Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe( Stream.map((event) => event as Payload), @@ -646,6 +669,7 @@ export const layerWith = (options?: LayerOptions) => subscribe, all: streamAll, aggregateEvents: streamEvents, + sequence, sync, listen, beforeCommit, diff --git a/packages/core/src/session-system-context.ts b/packages/core/src/session-system-context.ts index 0ae80c66e3..1b66da721e 100644 --- a/packages/core/src/session-system-context.ts +++ b/packages/core/src/session-system-context.ts @@ -1,11 +1,11 @@ export * as SessionSystemContext from "./session-system-context" -import { Context, DateTime, Effect, Layer } from "effect" +import { Context, DateTime, Effect, Layer, Schema } from "effect" import { Location } from "./location" import { SystemContext } from "./system-context" export interface Interface { - readonly load: () => Effect.Effect + readonly load: () => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/SessionSystemContext") {} @@ -22,32 +22,25 @@ export const layer = Layer.effect( ` Platform: ${process.platform}`, "", ].join("\n") - const context = SystemContext.struct({ - environment: SystemContext.value({ + const context = SystemContext.combine([ + SystemContext.make({ key: SystemContext.Key.make("core/environment"), - load: Effect.succeed({ - baseline: ["Here is some useful information about the environment you are running in:", environment].join( - "\n", - ), - update: ["The environment you are running in is now:", environment].join("\n"), - }), + codec: Schema.toCodecJson(Schema.String), + load: Effect.succeed(environment), + baseline: (environment) => + ["Here is some useful information about the environment you are running in:", environment].join("\n"), + update: (_previous, environment) => ["The environment you are running in is now:", environment].join("\n"), }), - date: SystemContext.value({ + SystemContext.make({ key: SystemContext.Key.make("core/date"), - load: DateTime.nowAsDate.pipe( - Effect.map((date) => ({ - baseline: `Today's date: ${date.toDateString()}`, - update: `Today's date is now: ${date.toDateString()}`, - })), - ), + codec: Schema.toCodecJson(Schema.String), + load: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())), + baseline: (date) => `Today's date: ${date}`, + update: (_previous, date) => `Today's date is now: ${date}`, }), - }) + ]) - return Service.of({ - load: Effect.fn("SessionSystemContext.load")(function* () { - return yield* SystemContext.load(context) - }), - }) + return Service.of({ load: () => Effect.succeed(context) }) }), ) diff --git a/packages/core/src/session/context-epoch.ts b/packages/core/src/session/context-epoch.ts index dec7e564d2..2b9fdfb9f3 100644 --- a/packages/core/src/session/context-epoch.ts +++ b/packages/core/src/session/context-epoch.ts @@ -1,18 +1,17 @@ export * as SessionContextEpoch from "./context-epoch" -import { and, eq, isNull, lt, sql } from "drizzle-orm" +import { and, eq, isNull, lt, or, sql } from "drizzle-orm" import { DateTime, Effect, Schema } from "effect" import type { Database } from "../database/database" import { EventV2 } from "../event" import { SessionSystemContext } from "../session-system-context" import { SystemContext } from "../system-context" import { SessionEvent } from "./event" +import { SessionMessageID } from "./message-id" import { SessionSchema } from "./schema" -import { SessionContextEpochTable, SessionContextMessageTable } from "./sql" +import { SessionContextEpochTable } from "./sql" type DatabaseService = Database.Interface["db"] -const sameBaseline = Schema.toEquivalence(SystemContext.PartsSchema) -const sameCheckpoint = Schema.toEquivalence(SystemContext.CheckpointSchema) export const prepare = Effect.fn("SessionContextEpoch.prepare")(function* ( db: DatabaseService, @@ -20,48 +19,33 @@ export const prepare = Effect.fn("SessionContextEpoch.prepare")(function* ( context: SessionSystemContext.Interface, sessionID: SessionSchema.ID, ) { - const snapshot = yield* context.load() - const stored = yield* find(db, sessionID) + const [value, stored] = yield* Effect.all([context.load(), find(db, sessionID)], { concurrency: "unbounded" }) if (!stored) { - const initialized = SystemContext.initialize(snapshot) - const event = yield* events.publish(SessionEvent.ContextInitialized, { - sessionID, - timestamp: yield* DateTime.now, - baseline: initialized.baseline, - checkpoint: initialized.checkpoint, - }) - if (event.seq === undefined) return yield* Effect.die("Synchronized Session event is missing aggregate sequence") - return { baseline: initialized.baseline, baselineSeq: event.seq } - } - if (stored.replacement_seq !== null) { - if (SystemContext.replacementBlocked(snapshot, stored.checkpoint)) - return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } - const initialized = SystemContext.initialize(snapshot) - const event = yield* events.publish(SessionEvent.ContextReplaced, { - sessionID, - timestamp: yield* DateTime.now, - expectedRevision: stored.revision, - baseline: initialized.baseline, - checkpoint: initialized.checkpoint, - }) - if (event.seq === undefined) return yield* Effect.die("Synchronized Session event is missing aggregate sequence") - return { baseline: initialized.baseline, baselineSeq: event.seq } + const generation = yield* SystemContext.initialize(value) + const baselineSeq = yield* initialize(db, events, sessionID, generation) + return { baseline: generation.baseline, baselineSeq } } - const refreshed = SystemContext.refresh(snapshot, stored.checkpoint) - if (sameCheckpoint(refreshed.checkpoint, stored.checkpoint)) + const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe(Effect.orDie) + const result = + stored.replacement_seq === null ? yield* SystemContext.reconcile(value, snapshot) : yield* SystemContext.replace(value, snapshot) + if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked") return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } - yield* events.publish(SessionEvent.ContextUpdated, { - sessionID, - timestamp: yield* DateTime.now, - expectedRevision: stored.revision, - parts: refreshed.changes, - checkpoint: refreshed.checkpoint, - }) + if (result._tag === "Replaced") { + const replacementSeq = stored.replacement_seq ?? (yield* events.sequence(sessionID)) + yield* replace(db, sessionID, stored.revision, replacementSeq, result.generation) + return { baseline: result.generation.baseline, baselineSeq: replacementSeq } + } + + yield* events.publish( + SessionEvent.ContextUpdated, + { sessionID, messageID: SessionMessageID.ID.create(), timestamp: yield* DateTime.now, text: result.text }, + { commit: () => advance(db, sessionID, stored.revision, result.snapshot).pipe(Effect.orDie) }, + ) return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } }) -export 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 .select() .from(SessionContextEpochTable) @@ -70,110 +54,6 @@ export const find = Effect.fn("SessionContextEpoch.find")(function* (db: Databas .pipe(Effect.orDie) }) -export const projectInitialized = Effect.fn("SessionContextEpoch.projectInitialized")(function* ( - db: DatabaseService, - event: SessionEvent.ContextInitialized, - seq: number, -) { - const stored = yield* find(db, event.data.sessionID) - if (stored) { - if (stored.baseline_seq > seq) return yield* Effect.void - if (stored.baseline_seq !== seq || !sameBaseline(stored.baseline, event.data.baseline)) - return yield* Effect.die("Session context epoch initialization conflicts with stored baseline") - return yield* Effect.void - } - return yield* db - .insert(SessionContextEpochTable) - .values({ - session_id: event.data.sessionID, - baseline: event.data.baseline, - checkpoint: event.data.checkpoint, - baseline_seq: seq, - replacement_seq: null, - revision: 0, - }) - .run() - .pipe(Effect.orDie) -}) - -export const projectUpdated = Effect.fn("SessionContextEpoch.projectUpdated")(function* ( - db: DatabaseService, - event: SessionEvent.ContextUpdated, - seq: number, -) { - const stored = yield* find(db, event.data.sessionID) - if (!stored) return yield* Effect.die("Session context epoch is not initialized") - if (stored.baseline_seq > seq) return yield* Effect.void - if (stored.replacement_seq !== null && seq >= stored.replacement_seq) - return yield* Effect.die("Session context epoch replacement is pending") - if (stored.revision > event.data.expectedRevision) { - if (event.data.parts.length === 0) return yield* Effect.void - const projected = yield* db - .select({ parts: SessionContextMessageTable.parts }) - .from(SessionContextMessageTable) - .where( - and(eq(SessionContextMessageTable.session_id, event.data.sessionID), eq(SessionContextMessageTable.seq, seq)), - ) - .get() - .pipe(Effect.orDie) - if (projected && sameBaseline(projected.parts, event.data.parts)) return yield* Effect.void - return yield* Effect.die("Session context update conflicts with stored projection") - } - const updated = yield* db - .update(SessionContextEpochTable) - .set({ checkpoint: event.data.checkpoint, revision: event.data.expectedRevision + 1 }) - .where( - and( - eq(SessionContextEpochTable.session_id, event.data.sessionID), - eq(SessionContextEpochTable.revision, event.data.expectedRevision), - ), - ) - .returning({ revision: SessionContextEpochTable.revision }) - .get() - .pipe(Effect.orDie) - if (!updated) return yield* Effect.die("Session context epoch revision mismatch") - if (event.data.parts.length === 0) return yield* Effect.void - return yield* db - .insert(SessionContextMessageTable) - .values({ session_id: event.data.sessionID, seq, parts: event.data.parts }) - .run() - .pipe(Effect.orDie) -}) - -export const projectReplaced = Effect.fn("SessionContextEpoch.projectReplaced")(function* ( - db: DatabaseService, - event: SessionEvent.ContextReplaced, - seq: number, -) { - const stored = yield* find(db, event.data.sessionID) - if (!stored) return yield* Effect.die("Session context epoch is not initialized") - if (stored.baseline_seq > seq) return yield* Effect.void - if (stored.baseline_seq === seq && sameBaseline(stored.baseline, event.data.baseline)) return yield* Effect.void - if (stored.replacement_seq === null) { - return yield* Effect.die("Session context epoch replacement was not requested") - } - const updated = yield* db - .update(SessionContextEpochTable) - .set({ - baseline: event.data.baseline, - checkpoint: event.data.checkpoint, - baseline_seq: seq, - replacement_seq: null, - revision: event.data.expectedRevision + 1, - }) - .where( - and( - eq(SessionContextEpochTable.session_id, event.data.sessionID), - eq(SessionContextEpochTable.revision, event.data.expectedRevision), - ), - ) - .returning({ revision: SessionContextEpochTable.revision }) - .get() - .pipe(Effect.orDie) - if (!updated) return yield* Effect.die("Session context epoch revision mismatch") - return yield* Effect.void -}) - export const requestReplacement = Effect.fn("SessionContextEpoch.requestReplacement")(function* ( db: DatabaseService, sessionID: SessionSchema.ID, @@ -185,10 +65,97 @@ export const requestReplacement = Effect.fn("SessionContextEpoch.requestReplacem .where( and( eq(SessionContextEpochTable.session_id, sessionID), - isNull(SessionContextEpochTable.replacement_seq), lt(SessionContextEpochTable.baseline_seq, seq), + or(isNull(SessionContextEpochTable.replacement_seq), lt(SessionContextEpochTable.replacement_seq, seq)), ), ) .run() .pipe(Effect.orDie) }) + +const initialize = Effect.fnUntraced(function* ( + db: DatabaseService, + events: EventV2.Interface, + sessionID: SessionSchema.ID, + generation: SystemContext.Generation, +) { + return yield* db + .transaction( + () => + Effect.gen(function* () { + const baselineSeq = yield* events.sequence(sessionID) + yield* db + .insert(SessionContextEpochTable) + .values({ + session_id: sessionID, + baseline: generation.baseline, + snapshot: generation.snapshot, + baseline_seq: baselineSeq, + revision: 0, + }) + .run() + .pipe(Effect.orDie) + return baselineSeq + }), + { behavior: "immediate" }, + ) + .pipe(Effect.orDie) +}) + +const replace = Effect.fnUntraced(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + expectedRevision: number, + baselineSeq: number, + generation: SystemContext.Generation, +) { + yield* db + .transaction( + () => + Effect.gen(function* () { + const updated = yield* db + .update(SessionContextEpochTable) + .set({ + baseline: generation.baseline, + snapshot: generation.snapshot, + baseline_seq: baselineSeq, + replacement_seq: null, + revision: expectedRevision + 1, + }) + .where( + and( + eq(SessionContextEpochTable.session_id, sessionID), + eq(SessionContextEpochTable.revision, expectedRevision), + ), + ) + .returning({ revision: SessionContextEpochTable.revision }) + .get() + .pipe(Effect.orDie) + if (!updated) return yield* Effect.die("Session context epoch revision mismatch") + }), + { behavior: "immediate" }, + ) + .pipe(Effect.orDie) +}) + +const advance = Effect.fnUntraced(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + expectedRevision: number, + snapshot: SystemContext.Snapshot, +) { + const updated = yield* db + .update(SessionContextEpochTable) + .set({ snapshot, revision: expectedRevision + 1 }) + .where( + and( + eq(SessionContextEpochTable.session_id, sessionID), + eq(SessionContextEpochTable.revision, expectedRevision), + isNull(SessionContextEpochTable.replacement_seq), + ), + ) + .returning({ revision: SessionContextEpochTable.revision }) + .get() + .pipe(Effect.orDie) + if (!updated) return yield* Effect.die("Session context epoch revision mismatch") +}) diff --git a/packages/core/src/session/context.ts b/packages/core/src/session/context.ts index d4fa8769bb..1b41c24fe4 100644 --- a/packages/core/src/session/context.ts +++ b/packages/core/src/session/context.ts @@ -1,22 +1,18 @@ -import { and, asc, desc, eq, gt, gte, or } from "drizzle-orm" +import { and, asc, desc, eq, gt, gte, ne, or } from "drizzle-orm" import { Effect, Schema } from "effect" import { Database } from "../database/database" import { MessageDecodeError } from "./error" import { SessionMessage } from "./message" import { SessionSchema } from "./schema" -import { SessionContextMessageTable, SessionMessageTable } from "./sql" -import type { SystemContext } from "../system-context" +import { SessionContextEpochTable, SessionMessageTable } from "./sql" type DatabaseService = Database.Interface["db"] const decode = Schema.decodeUnknownEffect(SessionMessage.Message) -export type RunnerMessage = - | SessionMessage.Message - | { readonly type: "system-context"; readonly parts: ReadonlyArray } const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { return yield* db - .select() + .select({ seq: SessionMessageTable.seq }) .from(SessionMessageTable) .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction"))) .orderBy(desc(SessionMessageTable.seq)) @@ -28,7 +24,8 @@ const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessi const messageRows = Effect.fnUntraced(function* ( db: DatabaseService, sessionID: SessionSchema.ID, - compaction: typeof SessionMessageTable.$inferSelect | undefined, + compaction: { readonly seq: number } | undefined, + baselineSeq?: number, ) { return yield* db .select() @@ -36,7 +33,17 @@ const messageRows = Effect.fnUntraced(function* ( .where( and( eq(SessionMessageTable.session_id, sessionID), - compaction ? or(gte(SessionMessageTable.seq, compaction.seq)) : undefined, + compaction + ? or( + gte(SessionMessageTable.seq, compaction.seq), + baselineSeq === undefined + ? undefined + : and(eq(SessionMessageTable.type, "system"), gt(SessionMessageTable.seq, baselineSeq)), + ) + : undefined, + baselineSeq === undefined + ? undefined + : or(ne(SessionMessageTable.type, "system"), gt(SessionMessageTable.seq, baselineSeq)), ), ) .orderBy(asc(SessionMessageTable.seq)) @@ -56,8 +63,20 @@ const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) => ) export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { + const [epoch, compaction] = yield* Effect.all( + [ + db + .select({ baselineSeq: SessionContextEpochTable.baseline_seq }) + .from(SessionContextEpochTable) + .where(eq(SessionContextEpochTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie), + latestCompaction(db, sessionID), + ], + { concurrency: "unbounded" }, + ) return yield* Effect.forEach( - yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID)), + yield* messageRows(db, sessionID, compaction, epoch?.baselineSeq), decodeMessageRow, ) }) @@ -67,44 +86,7 @@ export const loadForRunner = Effect.fn("SessionContext.loadForRunner")(function* sessionID: SessionSchema.ID, baselineSeq: number, ) { - const compaction = yield* latestCompaction(db, sessionID) - const messages = yield* messageRows(db, sessionID, compaction) - const updates = yield* db - .select() - .from(SessionContextMessageTable) - .where(and(eq(SessionContextMessageTable.session_id, sessionID), gt(SessionContextMessageTable.seq, baselineSeq))) - .orderBy(asc(SessionContextMessageTable.seq)) - .all() - .pipe(Effect.orDie) - return yield* Effect.forEach( - merge( - messages.map((row) => ({ type: "message" as const, seq: row.seq, row })), - updates.map((row) => ({ type: "system-context" as const, seq: row.seq, row })), - ), - (item): Effect.Effect => - item.type === "message" - ? decodeMessageRow(item.row) - : Effect.succeed({ type: "system-context", parts: item.row.parts }), - ) + return yield* Effect.forEach(yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID), baselineSeq), decodeMessageRow) }) -function merge( - left: ReadonlyArray, - right: ReadonlyArray, -): Array { - const result: Array = [] - let leftIndex = 0 - let rightIndex = 0 - while (leftIndex < left.length || rightIndex < right.length) { - if (rightIndex >= right.length || (leftIndex < left.length && left[leftIndex].seq < right[rightIndex].seq)) { - result.push(left[leftIndex]) - leftIndex++ - continue - } - result.push(right[rightIndex]) - rightIndex++ - } - return result -} - export * as SessionContext from "./context" diff --git a/packages/core/src/session/event.ts b/packages/core/src/session/event.ts index f471d2bf78..048ec6c624 100644 --- a/packages/core/src/session/event.ts +++ b/packages/core/src/session/event.ts @@ -10,7 +10,6 @@ import { SessionSchema } from "./schema" import { Location } from "../location" import { RelativePath } from "../schema" import { SessionMessageID } from "./message-id" -import { SystemContext } from "../system-context" export { FileAttachment } @@ -120,41 +119,17 @@ export namespace PromptLifecycle { export type Promoted = typeof Promoted.Type } -export const ContextInitialized = EventV2.define({ - type: "session.next.context.initialized", - ...options, - schema: { - ...Base, - baseline: SystemContext.PartsSchema, - checkpoint: SystemContext.CheckpointSchema, - }, -}) -export type ContextInitialized = typeof ContextInitialized.Type - export const ContextUpdated = EventV2.define({ type: "session.next.context.updated", ...options, schema: { ...Base, - expectedRevision: NonNegativeInt, - parts: SystemContext.PartsSchema, - checkpoint: SystemContext.CheckpointSchema, + messageID: SessionMessageID.ID, + text: Schema.String, }, }) export type ContextUpdated = typeof ContextUpdated.Type -export const ContextReplaced = EventV2.define({ - type: "session.next.context.replaced", - ...options, - schema: { - ...Base, - expectedRevision: NonNegativeInt, - baseline: SystemContext.PartsSchema, - checkpoint: SystemContext.CheckpointSchema, - }, -}) -export type ContextReplaced = typeof ContextReplaced.Type - export const Synthetic = EventV2.define({ type: "session.next.synthetic", ...options, @@ -480,9 +455,7 @@ const DurableDefinitions = [ Prompted, PromptLifecycle.Admitted, PromptLifecycle.Promoted, - ContextInitialized, ContextUpdated, - ContextReplaced, Synthetic, Shell.Started, Shell.Ended, diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 6e4c228082..38f52c21c4 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -159,9 +159,15 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }, "session.next.prompt.admitted": () => Effect.void, "session.next.prompt.promoted": () => Effect.void, - "session.next.context.initialized": () => Effect.void, - "session.next.context.updated": () => Effect.void, - "session.next.context.replaced": () => Effect.void, + "session.next.context.updated": (event) => + adapter.appendMessage( + new SessionMessage.System({ + id: event.data.messageID, + type: "system", + text: event.data.text, + time: { created: event.data.timestamp }, + }), + ), "session.next.synthetic": (event) => { return adapter.appendMessage( new SessionMessage.Synthetic({ diff --git a/packages/core/src/session/message.ts b/packages/core/src/session/message.ts index 7f62236bef..3a379e72d3 100644 --- a/packages/core/src/session/message.ts +++ b/packages/core/src/session/message.ts @@ -51,6 +51,12 @@ export class Synthetic extends Schema.Class("Session.Message.Syntheti type: Schema.Literal("synthetic"), }) {} +export class System extends Schema.Class("Session.Message.System")({ + ...Base, + type: Schema.Literal("system"), + text: SessionEvent.ContextUpdated.data.fields.text, +}) {} + export class Shell extends Schema.Class("Session.Message.Shell")({ ...Base, type: Schema.Literal("shell"), @@ -170,7 +176,7 @@ export class Compaction extends Schema.Class("Session.Message.Compac ...Base, }) {} -export const Message = Schema.Union([AgentSwitched, ModelSwitched, User, Synthetic, Shell, Assistant, Compaction]) +export const Message = Schema.Union([AgentSwitched, ModelSwitched, User, Synthetic, System, Shell, Assistant, Compaction]) .pipe(Schema.toTaggedUnion("type")) .annotate({ identifier: "Session.Message" }) diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index ca997c689b..51da5b245e 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -420,17 +420,9 @@ export const layer = Layer.effectDiscard( ) }), ) - yield* events.project(SessionEvent.ContextInitialized, (event) => { - if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence") - return SessionContextEpoch.projectInitialized(db, event, event.seq) - }) yield* events.project(SessionEvent.ContextUpdated, (event) => { - if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence") - return SessionContextEpoch.projectUpdated(db, event, event.seq) - }) - yield* events.project(SessionEvent.ContextReplaced, (event) => { - if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence") - return SessionContextEpoch.projectReplaced(db, event, event.seq) + if (!event.replay || event.seq === undefined) return run(db, event) + return run(db, event).pipe(Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq))) }) yield* events.project(SessionEvent.Synthetic, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event)) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 087fe0a328..417bf8fd15 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -154,7 +154,7 @@ export const layer = Layer.effect( const context = yield* getRunnerContext(session.id, system.baselineSeq) const request = LLM.request({ model, - system: system.baseline.map((part) => SystemPart.make(part.text)), + system: system.baseline.length > 0 ? [SystemPart.make(system.baseline)] : [], messages: toLLMMessages(context, model), tools: yield* tools.definitions(), }) diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index 82020c8538..cca399790f 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -9,7 +9,6 @@ import { } from "@opencode-ai/llm" import { SessionMessage } from "../message" import type { FileAttachment } from "../prompt" -import { SessionContext } from "../context" const media = (file: FileAttachment): ContentPart => ({ type: "media", @@ -92,7 +91,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => { return [Message.make({ id: message.id, role: "assistant", content, metadata: message.metadata }), ...results] } -function toLLMMessage(message: SessionContext.RunnerMessage, model: Model): Message[] { +function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] { switch (message.type) { case "agent-switched": case "model-switched": @@ -112,6 +111,8 @@ function toLLMMessage(message: SessionContext.RunnerMessage, model: Model): Mess ] case "synthetic": return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })] + case "system": + return [Message.system(message.text)] case "shell": return [ Message.make({ @@ -132,11 +133,9 @@ function toLLMMessage(message: SessionContext.RunnerMessage, model: Model): Mess metadata: message.metadata, }), ] - case "system-context": - return [Message.system(message.parts.map((part) => ({ type: "text", text: part.text })))] } } /** Translate projected V2 Session history into canonical @opencode-ai/llm context. */ -export const toLLMMessages = (messages: readonly SessionContext.RunnerMessage[], model: Model) => +export const toLLMMessages = (messages: readonly SessionMessage.Message[], model: Model) => messages.flatMap((message) => toLLMMessage(message, model)) diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index 5564610d18..7cd9fbf9af 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -168,22 +168,9 @@ export const SessionContextEpochTable = sqliteTable("session_context_epoch", { .$type() .primaryKey() .references(() => SessionTable.id, { onDelete: "cascade" }), - baseline: text({ mode: "json" }).notNull().$type>(), - checkpoint: text({ mode: "json" }).notNull().$type(), + baseline: text().notNull(), + snapshot: text({ mode: "json" }).notNull().$type(), baseline_seq: integer().notNull(), replacement_seq: integer(), revision: integer().notNull().default(0), }) - -export const SessionContextMessageTable = sqliteTable( - "session_context_message", - { - session_id: text() - .$type() - .notNull() - .references(() => SessionTable.id, { onDelete: "cascade" }), - seq: integer().notNull(), - parts: text({ mode: "json" }).notNull().$type>(), - }, - (table) => [primaryKey({ columns: [table.session_id, table.seq] })], -) diff --git a/packages/core/src/session/store.ts b/packages/core/src/session/store.ts index ad60c56d41..6eb7052539 100644 --- a/packages/core/src/session/store.ts +++ b/packages/core/src/session/store.ts @@ -16,7 +16,7 @@ export interface Interface { readonly runnerContext: ( sessionID: SessionSchema.ID, baselineSeq: number, - ) => Effect.Effect + ) => Effect.Effect readonly message: ( messageID: SessionMessage.ID, ) => Effect.Effect<{ readonly sessionID: SessionSchema.ID; readonly message: SessionMessage.Message } | undefined> diff --git a/packages/core/src/system-context.ts b/packages/core/src/system-context.ts index 67aa6ad75d..b69053d9bd 100644 --- a/packages/core/src/system-context.ts +++ b/packages/core/src/system-context.ts @@ -1,73 +1,84 @@ export * as SystemContext from "./system-context" -import { Effect, Schema } from "effect" -import { Hash } from "./util/hash" +import { Effect, Option, Schema } from "effect" +/** + * Models privileged system context as independently refreshable typed sources. + * + * `Source` describes how to observe, compare, and render one value. `make` + * closes over `A`, producing an opaque `SystemContext` that composes uniformly + * with contexts built from other value types. Interpreters observe the composed + * context once, then produce a durable structured + * `Snapshot` alongside the exact model-visible baseline or update text. + * + * Returning `unavailable` means observation failed temporarily. It differs from + * removing a source from the context: refresh preserves the admitted snapshot, + * and replacement waits rather than silently constructing an incomplete baseline. + * + * @module + */ + +/** Stable namespaced identity for one independently refreshable context source. */ export const Key = Schema.String.check(Schema.isPattern(/^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._/-]*$/)).pipe( Schema.brand("SystemContext.Key"), ) export type Key = typeof Key.Type +/** Indicates that a source could not be observed without treating it as removed. */ export const unavailable = Symbol.for("@opencode/SystemContext.Unavailable") export type Unavailable = typeof unavailable -export interface Value { - /** Full component text rendered into a new epoch baseline. */ - readonly baseline: string - /** Absolute current-state text emitted when this component changes. */ - readonly update: string -} - -export interface Component { +/** Defines one typed source before its value type is hidden by `make`. */ +export interface Source { readonly key: Key - readonly load: Effect.Effect + readonly codec: Schema.Codec + readonly load: Effect.Effect + readonly baseline: (current: A) => string + readonly update: (previous: A, current: A) => string + readonly removed?: (previous: A) => string } -export interface SystemContext { - readonly components: ReadonlyArray> +const ContextTypeId: unique symbol = Symbol.for("@opencode/SystemContext") + +/** Opaque carrier for composable system context sources. */ +export interface SystemContext { + readonly [ContextTypeId]: ReadonlyArray } -export interface AvailableEntry extends Value { - readonly _tag: "Available" - readonly key: Key - readonly hash: string -} - -export interface UnavailableEntry { - readonly _tag: "Unavailable" - readonly key: Key -} - -export type Entry = AvailableEntry | UnavailableEntry - -export interface Snapshot { - readonly entries: ReadonlyArray -} - -export interface Part { - readonly key: Key - readonly text: string -} - -export const PartSchema = Schema.Struct({ - key: Key, - text: Schema.String, +/** Durable comparison state for one admitted source. */ +export const SourceSnapshot = Schema.Struct({ + value: Schema.Json, + removed: Schema.optional(Schema.NonEmptyString), }) -export const PartsSchema = Schema.Array(PartSchema) -export const CheckpointSchema = Schema.Record(Key, Schema.String) +export type SourceSnapshot = typeof SourceSnapshot.Type -export type Checkpoint = Readonly> +/** Durable structured comparison state for one active context generation. */ +export const Snapshot = Schema.Record(Key, SourceSnapshot) +export type Snapshot = Readonly> -export interface Initialized { - readonly baseline: ReadonlyArray - readonly checkpoint: Checkpoint +export interface Generation { + readonly baseline: string + readonly snapshot: Snapshot } -export interface Refreshed { - readonly changes: ReadonlyArray - readonly checkpoint: Checkpoint +export interface Updated { + readonly _tag: "Updated" + readonly text: string + readonly snapshot: Snapshot } +export interface Replaced { + readonly _tag: "Replaced" + readonly generation: Generation +} + +export interface ReplacementBlocked { + readonly _tag: "ReplacementBlocked" +} + +export type ReplacementResult = Replaced | ReplacementBlocked +export type ReconcileResult = { readonly _tag: "Unchanged" } | Updated | ReplacementResult + export class DuplicateKeyError extends Schema.TaggedErrorClass()("SystemContext.DuplicateKeyError", { key: Key, }) { @@ -76,78 +87,219 @@ export class DuplicateKeyError extends Schema.TaggedErrorClass(component: Component): Component => component - -export function struct(components: Readonly>>): SystemContext { - const values = Object.values(components) - assertUniqueKeys(values) - return { components: values } +interface PackedSource { + readonly key: Key + readonly load: Effect.Effect } -export const load = (context: SystemContext) => - Effect.sync(() => assertUniqueKeys(context.components)).pipe( - Effect.andThen( - Effect.forEach(context.components, (component) => - component.load.pipe( - Effect.map( - (result): Entry => - result === unavailable - ? { _tag: "Unavailable", key: component.key } - : { _tag: "Available", key: component.key, ...result, hash: Hash.sha256(result.update) }, - ), +interface Loaded { + readonly baseline: () => Rendered + readonly compare: (previous: Schema.Json) => Compared +} + +interface Rendered { + readonly text: string + readonly snapshot: SourceSnapshot +} + +type Compared = + | { readonly _tag: "Incompatible" } + | { readonly _tag: "Unchanged" } + | { readonly _tag: "Updated"; readonly render: () => Rendered } + +interface AvailableEntry extends Loaded { + readonly _tag: "Available" + readonly key: Key +} + +interface UnavailableEntry { + readonly _tag: "Unavailable" + readonly key: Key +} + +type Entry = AvailableEntry | UnavailableEntry + +/** The identity context. */ +export const empty = context([]) + +/** Closes a typed source into a context that composes with differently typed sources. */ +export function make(source: Source): SystemContext { + const decode = Schema.decodeUnknownOption(source.codec) + const encode = Schema.encodeSync(source.codec) + const equivalent = Schema.toEquivalence(source.codec) + return context([ + { + key: source.key, + load: source.load.pipe( + Effect.map((value) => { + if (isUnavailable(value)) return value + const snapshot = (): SourceSnapshot => ({ + value: encode(value), + ...(source.removed ? { removed: requireText(source.key, "removal", source.removed(value)) } : {}), + }) + return { + baseline: (): Rendered => ({ + text: requireText(source.key, "baseline", source.baseline(value)), + snapshot: snapshot(), + }), + compare: (previous): Compared => + Option.match(decode(previous), { + onNone: (): Compared => ({ _tag: "Incompatible" }), + onSome: (decoded): Compared => + equivalent(decoded, value) + ? { _tag: "Unchanged" } + : { + _tag: "Updated", + render: () => ({ + text: requireText(source.key, "update", source.update(decoded, value)), + snapshot: snapshot(), + }), + }, + }), + } + }), + ), + }, + ]) +} + +/** Combines contexts in order and rejects duplicate source keys immediately. */ +export function combine(values: ReadonlyArray): SystemContext { + const sources = values.flatMap((value) => value[ContextTypeId]) + assertUniqueKeys(sources) + return context(sources) +} + +const observe = (value: SystemContext) => + Effect.forEach( + value[ContextTypeId], + (source) => + source.load.pipe( + Effect.map( + (result): Entry => + result === unavailable + ? { _tag: "Unavailable", key: source.key } + : { _tag: "Available", key: source.key, ...result }, ), ), - ), - Effect.map((entries): Snapshot => ({ entries })), + { concurrency: "unbounded" }, ) -export function initialize(snapshot: Snapshot): Initialized { +/** Creates the immutable baseline and durable snapshot for a new generation. */ +export function initialize(value: SystemContext): Effect.Effect { + return observe(value).pipe(Effect.map(initializeObservation)) +} + +function initializeObservation(entries: ReadonlyArray): Generation { + const available = entries.filter((entry): entry is AvailableEntry => entry._tag === "Available") + const rendered = available.map((entry) => [entry.key, entry.baseline()] as const) return { - baseline: snapshot.entries.flatMap((entry) => - entry._tag === "Available" ? [{ key: entry.key, text: entry.baseline }] : [], - ), - checkpoint: nextCheckpoint(snapshot, {}), + baseline: render(rendered.map(([, result]) => result.text)), + snapshot: Object.fromEntries(rendered.map(([key, result]) => [key, result.snapshot])), } } -export function refresh(snapshot: Snapshot, previous: Checkpoint): Refreshed { - const keys = new Set(snapshot.entries.map((entry) => entry.key)) - return { - changes: [ - ...snapshot.entries.flatMap((entry) => - entry._tag === "Available" && getCheckpoint(previous, entry.key) !== entry.hash - ? [{ key: entry.key, text: entry.update }] - : [], - ), - ...Object.keys(previous).flatMap((key) => - keys.has(Key.make(key)) ? [] : [{ key: Key.make(key), text: `System context component removed: ${key}` }], - ), - ], - checkpoint: nextCheckpoint(snapshot, previous), - } -} - -export const replacementBlocked = (snapshot: Snapshot, previous: Checkpoint) => - snapshot.entries.some((entry) => entry._tag === "Unavailable" && getCheckpoint(previous, entry.key) !== undefined) - -function nextCheckpoint(snapshot: Snapshot, previous: Checkpoint) { - return Object.fromEntries( - snapshot.entries.flatMap((entry) => { - if (entry._tag === "Available") return [[entry.key, entry.hash]] - const hash = getCheckpoint(previous, entry.key) - return hash === undefined ? [] : [[entry.key, hash]] +/** Reconciles current source values with one active generation. */ +export function reconcile(value: SystemContext, previous: Snapshot): Effect.Effect { + return observe(value).pipe( + Effect.map((entries): ReconcileResult => { + const result = reconcileObservation(entries, previous) + if (result._tag === "Unchanged" || result._tag === "Updated") return result + return replaceObservation(entries, previous) }), ) } -function getCheckpoint(checkpoint: Checkpoint, key: Key) { - return Object.hasOwn(checkpoint, key) ? checkpoint[key] : undefined +function reconcileObservation( + entries: ReadonlyArray, + previous: Snapshot, +): { readonly _tag: "Unchanged" } | Updated | { readonly _tag: "Replace" } { + const keys = new Set(entries.map((entry) => entry.key)) + const comparisons = new Map() + for (const entry of entries) { + if (entry._tag === "Unavailable") continue + const stored = getSnapshot(previous, entry.key) + if (!stored) continue + const compared = entry.compare(stored.value) + if (compared._tag === "Incompatible") return { _tag: "Replace" } + comparisons.set(entry.key, compared) + } + for (const key of Object.keys(previous).sort()) { + if (keys.has(Key.make(key))) continue + if (previous[key].removed === undefined) return { _tag: "Replace" } + } + + const snapshot: Record = {} + const updates: string[] = [] + for (const entry of entries) { + const stored = getSnapshot(previous, entry.key) + if (entry._tag === "Unavailable") { + if (stored) snapshot[entry.key] = stored + continue + } + if (!stored) { + const rendered = entry.baseline() + updates.push(rendered.text) + snapshot[entry.key] = rendered.snapshot + continue + } + const compared = comparisons.get(entry.key) + if (!compared || compared._tag === "Incompatible") + throw new Error(`Missing comparison for system context source ${entry.key}`) + if (compared._tag === "Unchanged") { + snapshot[entry.key] = stored + continue + } + const rendered = compared.render() + updates.push(rendered.text) + snapshot[entry.key] = rendered.snapshot + } + for (const key of Object.keys(previous).sort()) { + if (keys.has(Key.make(key))) continue + const removed = previous[key].removed + if (removed === undefined) throw new Error(`Missing removal rendering for system context source ${key}`) + updates.push(removed) + } + if (updates.length === 0) return { _tag: "Unchanged" } + return { _tag: "Updated", text: render(updates), snapshot } } -function assertUniqueKeys(components: ReadonlyArray>) { +/** Creates a complete replacement generation or blocks while admitted context is unavailable. */ +export function replace(value: SystemContext, previous: Snapshot): Effect.Effect { + return observe(value).pipe(Effect.map((entries) => replaceObservation(entries, previous))) +} + +function replaceObservation(entries: ReadonlyArray, previous: Snapshot): ReplacementResult { + if (entries.some((entry) => entry._tag === "Unavailable" && getSnapshot(previous, entry.key) !== undefined)) + return { _tag: "ReplacementBlocked" } + return { _tag: "Replaced", generation: initializeObservation(entries) } +} + +function context(sources: ReadonlyArray): SystemContext { + return { [ContextTypeId]: sources } +} + +function render(parts: ReadonlyArray) { + return parts.join("\n\n") +} + +function getSnapshot(snapshot: Snapshot, key: Key) { + return Object.hasOwn(snapshot, key) ? snapshot[key] : undefined +} + +function isUnavailable(value: unknown): value is Unavailable { + return value === unavailable +} + +function requireText(key: Key, kind: string, text: string) { + if (text.length === 0) throw new Error(`System context source ${key} rendered an empty ${kind}`) + return text +} + +function assertUniqueKeys(sources: ReadonlyArray) { const keys = new Set() - for (const component of components) { - if (keys.has(component.key)) throw new DuplicateKeyError({ key: component.key }) - keys.add(component.key) + for (const source of sources) { + if (keys.has(source.key)) throw new DuplicateKeyError({ key: source.key }) + keys.add(source.key) } } diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index c91d34fcfa..9b454333b4 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -190,6 +190,53 @@ describe("EventV2", () => { }), ) + it.effect("commits local operational state inside a new synchronized event transaction", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const received = new Array() + yield* events.project(SyncMessage, () => Effect.sync(() => received.push("projector"))) + + yield* events.publish( + SyncMessage, + { id: "one", text: "hello" }, + { commit: (seq) => Effect.sync(() => received.push(`commit:${seq}`)) }, + ) + + expect(received).toEqual(["projector", "commit:0"]) + }), + ) + + it.effect("rolls back the synchronized event and projector when the local commit fails", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const aggregateID = EventV2.ID.create() + yield* db.run("CREATE TABLE IF NOT EXISTS event_commit_probe (value text NOT NULL)") + yield* db.run("DELETE FROM event_commit_probe") + yield* events.project(SyncMessage, () => + db.run("INSERT INTO event_commit_probe (value) VALUES ('projected')").pipe(Effect.orDie, Effect.asVoid), + ) + + const exit = yield* events + .publish(SyncMessage, { id: aggregateID, text: "hello" }, { commit: () => Effect.die("commit failed") }) + .pipe(Effect.exit) + + expect(String(exit)).toContain("commit failed") + expect(yield* db.all("SELECT value FROM event_commit_probe")).toEqual([]) + expect(yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all()).toEqual([]) + expect(yield* db.select().from(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).all()).toEqual([]) + }), + ) + + it.effect("rejects local commit hooks on live-only events", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const exit = yield* events.publish(Message, { text: "hello" }, { commit: () => Effect.void }).pipe(Effect.exit) + + expect(String(exit)).toContain("Local commit hooks require a synchronized event") + }), + ) + it.effect("runs projectors before publishing to streams", () => Effect.gen(function* () { const events = yield* EventV2.Service diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index e00346cd1e..854f24158f 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect } from "bun:test" import { Message, Model } from "@opencode-ai/llm" import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat" import { ModelV2 } from "@opencode-ai/core/model" @@ -8,15 +8,15 @@ import { AgentAttachment, FileAttachment, ReferenceAttachment } from "@opencode- import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message" import { SessionV2 } from "@opencode-ai/core/session" import { ToolOutput } from "@opencode-ai/core/tool-output" -import { DateTime } from "effect" -import { SystemContext } from "@opencode-ai/core/system-context" +import { DateTime, Effect } from "effect" +import { it } from "./lib/effect" const created = DateTime.makeUnsafe(0) const id = (value: string) => SessionMessage.ID.make(`msg_${value}`) const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route }) describe("toLLMMessages", () => { - test("maps every top-level V2 Session message type", () => { + it.effect("maps every top-level V2 Session message type", () => Effect.sync(() => { const file = new FileAttachment({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" }) const reference = new ReferenceAttachment({ name: "docs", kind: "local", uri: "file:///docs" }) const messages = toLLMMessages( @@ -85,31 +85,22 @@ describe("toLLMMessages", () => { [{ type: "text", text: "Shell command: pwd\n\n/project" }], [{ type: "text", text: "Summary of earlier conversation:\nEarlier work" }], ]) - }) + })) - test("maps hidden Session context updates into chronological system messages", () => { + it.effect("maps durable Session system messages into chronological system messages", () => Effect.sync(() => { expect( toLLMMessages( [ - { - type: "system-context", - parts: [ - { key: SystemContext.Key.make("test/context"), text: "Updated context" }, - { key: SystemContext.Key.make("test/other"), text: "Other context" }, - ], - }, + new SessionMessage.System({ id: id("system"), type: "system", text: "Updated context\n\nOther context", time: { created } }), ], model, ), ).toEqual([ - Message.system([ - { type: "text", text: "Updated context" }, - { type: "text", text: "Other context" }, - ]), + Message.system("Updated context\n\nOther context"), ]) - }) + })) - test("expands assistant tool calls and settled outcomes into canonical tool messages", () => { + it.effect("expands assistant tool calls and settled outcomes into canonical tool messages", () => Effect.sync(() => { const messages = toLLMMessages( [ new SessionMessage.Assistant({ @@ -264,9 +255,9 @@ describe("toLLMMessages", () => { }, }, ]) - }) + })) - test("restores OpenAI encrypted reasoning metadata", () => { + it.effect("restores OpenAI encrypted reasoning metadata", () => Effect.sync(() => { const messages = toLLMMessages( [ new SessionMessage.Assistant({ @@ -295,9 +286,9 @@ describe("toLLMMessages", () => { providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, }, ]) - }) + })) - test("drops provider-native continuation metadata after a model switch", () => { + it.effect("drops provider-native continuation metadata after a model switch", () => Effect.sync(() => { const messages = toLLMMessages( [ new SessionMessage.Assistant({ @@ -395,5 +386,5 @@ describe("toLLMMessages", () => { providerMetadata: undefined, }, ]) - }) + })) }) diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index edeb04ff6b..c9252b86bb 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -21,10 +21,9 @@ import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" import { SessionSystemContext } from "@opencode-ai/core/session-system-context" import { SystemContext } from "@opencode-ai/core/system-context" -import { Hash } from "@opencode-ai/core/util/hash" import { describe, expect } from "bun:test" import { eq } from "drizzle-orm" -import { Effect, Layer } from "effect" +import { Effect, Layer, Schema } from "effect" import path from "node:path" import { testEffect } from "./lib/effect" @@ -62,17 +61,16 @@ const systemContext = Layer.succeed( SessionSystemContext.Service, SessionSystemContext.Service.of({ load: () => - Effect.succeed({ - entries: [ - { - _tag: "Available" as const, - key: SystemContext.Key.make("test/context"), - baseline: "Recorded context", - update: "Recorded context", - hash: Hash.sha256("Recorded context"), - }, - ], - }), + Effect.succeed( + SystemContext.make({ + key: SystemContext.Key.make("test/context"), + codec: Schema.toCodecJson(Schema.String), + load: Effect.succeed("Recorded context"), + baseline: String, + update: (_previous, current) => current, + removed: () => "Recorded context removed", + }), + ), }), ) const runner = SessionRunnerLLM.defaultLayer.pipe( @@ -167,7 +165,6 @@ describe("SessionRunnerLLM recorded", () => { ).toEqual([ "session.next.prompt.admitted.1", "session.next.prompt.promoted.1", - "session.next.context.initialized.1", "session.next.step.started.1", "session.next.text.started.1", "session.next.text.ended.1", diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 7b31b91358..a960061f45 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -32,11 +32,10 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { NativeTool } from "@opencode-ai/core/tool/native" -import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" +import { SessionContextEpochTable, SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" import { SessionSystemContext } from "@opencode-ai/core/session-system-context" import { SystemContext } from "@opencode-ai/core/system-context" -import { Hash } from "@opencode-ai/core/util/hash" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { Cause, DateTime, Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect" @@ -150,21 +149,22 @@ const systemContext = Layer.succeed( SessionSystemContext.Service, SessionSystemContext.Service.of({ load: () => - Effect.sync(() => ({ - entries: systemRemoved - ? [] - : systemUnavailable - ? [{ _tag: "Unavailable" as const, key: systemContextKey }] + Effect.succeed( + SystemContext.combine( + systemRemoved + ? [] : [ - { - _tag: "Available" as const, + SystemContext.make({ key: systemContextKey, - baseline: systemBaseline, - update: systemBaseline, - hash: Hash.sha256(systemBaseline), - }, + codec: Schema.toCodecJson(Schema.String), + load: Effect.sync(() => (systemUnavailable ? SystemContext.unavailable : systemBaseline)), + baseline: String, + update: (_previous, current) => current, + removed: () => "System context source removed: test/context", + }), ], - })), + ), + ), }), ) const runner = SessionRunnerLLM.layer.pipe( @@ -568,16 +568,8 @@ describe("SessionRunnerLLM", () => { ]) expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"]) expect(requests[1]?.messages.at(-1)?.content).toEqual([{ type: "text", text: "Changed context" }]) - expect(yield* session.messages({ sessionID })).toHaveLength(2) + expect(yield* session.messages({ sessionID })).toHaveLength(3) const { db } = yield* Database.Service - expect( - yield* db - .select({ id: EventTable.id }) - .from(EventTable) - .where(eq(EventTable.type, "session.next.context.initialized.1")) - .all() - .pipe(Effect.orDie), - ).toHaveLength(1) expect( yield* db .select({ id: EventTable.id }) @@ -587,11 +579,11 @@ describe("SessionRunnerLLM", () => { .pipe(Effect.orDie), ).toHaveLength(1) yield* replaySessionProjection(sessionID) - expect(yield* session.messages({ sessionID })).toHaveLength(2) + expect(yield* session.messages({ sessionID })).toHaveLength(3) }), ) - it.effect("admits removed context as a hidden chronological tombstone", () => + it.effect("admits removed context as a chronological System message", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -606,13 +598,13 @@ describe("SessionRunnerLLM", () => { expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"]) expect(requests[1]?.messages.at(-1)?.content).toEqual([ - { type: "text", text: "System context component removed: test/context" }, + { type: "text", text: "System context source removed: test/context" }, ]) - expect(yield* session.messages({ sessionID })).toHaveLength(2) + expect(yield* session.messages({ sessionID })).toHaveLength(3) }), ) - it.effect("replaces the baseline lazily after a model switch and drops prior hidden updates", () => + it.effect("replaces the baseline lazily after a model switch and drops prior System updates", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -641,27 +633,16 @@ describe("SessionRunnerLLM", () => { ]) expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"]) expect(requests[2]?.messages.map((message) => message.role)).toEqual(["user", "user", "user"]) - const { db } = yield* Database.Service - expect( - yield* db - .select({ id: EventTable.id }) - .from(EventTable) - .where(eq(EventTable.type, "session.next.context.replaced.1")) - .all() - .pipe(Effect.orDie), - ).toHaveLength(1) + expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([ + "user", + "user", + "model-switched", + "user", + ]) yield* replaySessionProjection(sessionID) - expect(yield* session.messages({ sessionID })).toHaveLength(4) + expect(yield* session.messages({ sessionID })).toHaveLength(5) yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fourth" }), resume: false }) yield* session.resume(sessionID) - expect( - yield* db - .select({ id: EventTable.id }) - .from(EventTable) - .where(eq(EventTable.type, "session.next.context.replaced.1")) - .all() - .pipe(Effect.orDie), - ).toHaveLength(1) }), ) @@ -693,15 +674,39 @@ describe("SessionRunnerLLM", () => { ["Initial context"], ["Replacement context"], ]) + }), + ) + + it.effect("advances a pending replacement to the latest invalidation boundary", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service const { db } = yield* Database.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + response = [] + yield* session.resume(sessionID) + + yield* events.publish(SessionEvent.ModelSwitched, { + sessionID, + timestamp: DateTime.makeUnsafe(1), + model: { id: ModelV2.ID.make("replacement-1"), providerID: ProviderV2.ID.make("fake") }, + }) + yield* events.publish(SessionEvent.ModelSwitched, { + sessionID, + timestamp: DateTime.makeUnsafe(2), + model: { id: ModelV2.ID.make("replacement-2"), providerID: ProviderV2.ID.make("fake") }, + }) + const latest = yield* events.sequence(sessionID) + expect( yield* db - .select({ id: EventTable.id }) - .from(EventTable) - .where(eq(EventTable.type, "session.next.context.replaced.1")) - .all() + .select({ replacementSeq: SessionContextEpochTable.replacement_seq }) + .from(SessionContextEpochTable) + .where(eq(SessionContextEpochTable.session_id, sessionID)) + .get() .pipe(Effect.orDie), - ).toHaveLength(1) + ).toEqual({ replacementSeq: latest }) }), ) @@ -792,26 +797,41 @@ describe("SessionRunnerLLM", () => { ["Initial context"], ["Replacement context"], ]) - const { db } = yield* Database.Service - expect( - yield* db - .select({ id: EventTable.id }) - .from(EventTable) - .where(eq(EventTable.type, "session.next.context.replaced.1")) - .all() - .pipe(Effect.orDie), - ).toHaveLength(1) yield* replaySessionProjection(sessionID) yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false }) yield* session.resume(sessionID) - expect( - yield* db - .select({ id: EventTable.id }) - .from(EventTable) - .where(eq(EventTable.type, "session.next.context.replaced.1")) - .all() - .pipe(Effect.orDie), - ).toHaveLength(1) + }), + ) + + it.effect("preserves effective System updates while compaction replacement is blocked", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + + requests.length = 0 + response = [] + yield* session.resume(sessionID) + systemBaseline = "Changed context" + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.resume(sessionID) + yield* events.publish(SessionEvent.Compaction.Started, { + sessionID, + timestamp: DateTime.makeUnsafe(1), + reason: "manual", + }) + yield* events.publish(SessionEvent.Compaction.Ended, { + sessionID, + timestamp: DateTime.makeUnsafe(2), + text: "summary", + }) + systemUnavailable = true + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false }) + yield* session.resume(sessionID) + + expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Initial context"]) + expect(requests.at(-1)?.messages.some((message) => message.role === "system" && message.content[0]?.type === "text" && message.content[0].text === "Changed context")).toBe(true) }), ) diff --git a/packages/core/test/session-system-context.test.ts b/packages/core/test/session-system-context.test.ts index a2c979d069..d0fb03afa7 100644 --- a/packages/core/test/session-system-context.test.ts +++ b/packages/core/test/session-system-context.test.ts @@ -30,41 +30,48 @@ describe("SessionSystemContext", () => { Effect.gen(function* () { yield* TestClock.setTime(timestamp) const context = yield* SessionSystemContext.Service - const initialized = SystemContext.initialize(yield* context.load()) + const initialized = yield* SystemContext.initialize(yield* context.load()) - expect(initialized.baseline).toEqual([ - { - key: SystemContext.Key.make("core/environment"), - text: [ - "Here is some useful information about the environment you are running in:", - "", - ` Working directory: ${directory}`, - ` Workspace root folder: ${projectDirectory}`, - " Is directory a git repo: yes", - ` Platform: ${process.platform}`, - "", - ].join("\n"), - }, - { key: SystemContext.Key.make("core/date"), text: `Today's date: ${localDate(timestamp)}` }, - ]) + expect(initialized.baseline).toBe( + [ + "Here is some useful information about the environment you are running in:", + "", + ` Working directory: ${directory}`, + ` Workspace root folder: ${projectDirectory}`, + " Is directory a git repo: yes", + ` Platform: ${process.platform}`, + "", + "", + `Today's date: ${localDate(timestamp)}`, + ].join("\n"), + ) }), ) - it.effect("refreshes the date without repeating unchanged environment context", () => + it.effect("reconciles the date without repeating unchanged environment context", () => Effect.gen(function* () { yield* TestClock.setTime(timestamp) const context = yield* SessionSystemContext.Service - const initialized = SystemContext.initialize(yield* context.load()) + const initialized = yield* SystemContext.initialize(yield* context.load()) yield* TestClock.setTime(timestamp + 24 * 60 * 60 * 1000) - const refreshed = SystemContext.refresh(yield* context.load(), initialized.checkpoint) + const refreshed = yield* SystemContext.reconcile(yield* context.load(), initialized.snapshot) - expect(refreshed.changes).toEqual([ - { - key: SystemContext.Key.make("core/date"), - text: `Today's date is now: ${localDate(timestamp + 24 * 60 * 60 * 1000)}`, - }, - ]) + expect(refreshed).toMatchObject({ + _tag: "Updated", + text: `Today's date is now: ${localDate(timestamp + 24 * 60 * 60 * 1000)}`, + }) + }), + ) + + it.effect("does not update again within the same local calendar day", () => + Effect.gen(function* () { + yield* TestClock.setTime(timestamp) + const context = yield* SessionSystemContext.Service + const initialized = yield* SystemContext.initialize(yield* context.load()) + + yield* TestClock.setTime(timestamp + 60 * 60 * 1000) + expect(yield* SystemContext.reconcile(yield* context.load(), initialized.snapshot)).toEqual({ _tag: "Unchanged" }) }), ) }) diff --git a/packages/core/test/system-context.test.ts b/packages/core/test/system-context.test.ts index 92acf5ad30..6aa7cb1470 100644 --- a/packages/core/test/system-context.test.ts +++ b/packages/core/test/system-context.test.ts @@ -1,213 +1,300 @@ -import { describe, expect, test } from "bun:test" -import { Effect, Schema } from "effect" +import { describe, expect } from "bun:test" +import { Cause, Effect, Exit, Schema } from "effect" import { SystemContext } from "@opencode-ai/core/system-context" -import { Hash } from "@opencode-ai/core/util/hash" +import { it } from "./lib/effect" const key = SystemContext.Key.make +const stringContext = (input: { + key: string + value: string | SystemContext.Unavailable + baseline?: (value: string) => string + update?: (previous: string, current: string) => string + removed?: (value: string) => string +}) => + SystemContext.make({ + key: key(input.key), + codec: Schema.toCodecJson(Schema.String), + load: Effect.succeed(input.value), + baseline: input.baseline ?? String, + update: input.update ?? ((_previous, current) => current), + removed: input.removed, + }) describe("SystemContext", () => { - test("loads one coherent sample and initializes a deterministic baseline", async () => { - let loads = 0 - const context = SystemContext.struct({ - date: SystemContext.value({ + it.effect("stores the canonical JSON encoding of the loaded value", () => + Effect.gen(function* () { + const context = SystemContext.make({ key: key("core/date"), + codec: Schema.toCodecJson(Schema.DateFromString), + load: Effect.succeed(new Date("2026-06-03T12:00:00.000Z")), + baseline: (date) => date.toISOString(), + update: (_previous, date) => date.toISOString(), + removed: () => "Date removed", + }) + + expect((yield* SystemContext.initialize(context)).snapshot["core/date"].value).toBe("2026-06-03T12:00:00.000Z") + }), + ) + + it.effect("loads once and initializes a baseline with a structured snapshot", () => + Effect.gen(function* () { + let loads = 0 + const context = SystemContext.combine([ + SystemContext.make({ + key: key("core/date"), + codec: Schema.toCodecJson(Schema.String), + load: Effect.sync(() => { + loads++ + return "2026-06-03" + }), + baseline: (date) => `Today's date is ${date}.`, + update: (previous, current) => `The date changed from ${previous} to ${current}.`, + removed: () => "The date was removed.", + }), + stringContext({ key: "core/location", value: "/repo", baseline: (value) => `Directory: ${value}` }), + ]) + + expect(yield* SystemContext.initialize(context)).toEqual({ + baseline: "Today's date is 2026-06-03.\n\nDirectory: /repo", + snapshot: { + "core/date": { value: "2026-06-03", removed: "The date was removed." }, + "core/location": { value: "/repo" }, + }, + }) + expect(loads).toBe(1) + }), + ) + + it.effect("renders updates only after a structured value changes", () => + Effect.gen(function* () { + const previous = { + "core/date": { value: "2026-06-03", removed: "The date was removed." }, + "core/location": { value: "/repo", removed: "Removed: /repo" }, + } + const changed = SystemContext.combine([ + stringContext({ + key: "core/date", + value: "2026-06-04", + update: (before, current) => `The date changed from ${before} to ${current}.`, + removed: () => "The date was removed.", + }), + stringContext({ key: "core/location", value: "/repo" }), + ]) + + expect(yield* SystemContext.reconcile(changed, previous)).toEqual({ + _tag: "Updated", + text: "The date changed from 2026-06-03 to 2026-06-04.", + snapshot: { + "core/date": { value: "2026-06-04", removed: "The date was removed." }, + "core/location": { value: "/repo", removed: "Removed: /repo" }, + }, + }) + + expect( + yield* SystemContext.reconcile( + SystemContext.combine([ + stringContext({ key: "core/date", value: "2026-06-03", removed: () => "The date was removed." }), + stringContext({ key: "core/location", value: "/repo" }), + ]), + previous, + ), + ).toEqual({ _tag: "Unchanged" }) + }), + ) + + it.effect("uses the baseline for a newly added source", () => + Effect.gen(function* () { + const context = stringContext({ + key: "core/skills", + value: "effect", + baseline: (skill) => `Available skill: ${skill}`, + }) + + expect(yield* SystemContext.reconcile(context, {})).toEqual({ + _tag: "Updated", + text: "Available skill: effect", + snapshot: { "core/skills": { value: "effect" } }, + }) + }), + ) + + it.effect("retains admitted snapshots while a source is temporarily unavailable", () => + Effect.gen(function* () { + const previous = { "core/remote": { value: "instructions", removed: "Instructions removed" } } + const context = stringContext({ key: "core/remote", value: SystemContext.unavailable }) + + expect(yield* SystemContext.reconcile(context, previous)).toEqual({ _tag: "Unchanged" }) + expect(yield* SystemContext.replace(context, previous)).toEqual({ _tag: "ReplacementBlocked" }) + expect(yield* SystemContext.replace(context, {})).toMatchObject({ _tag: "Replaced" }) + }), + ) + + it.effect("omits unavailable sources from an initial baseline", () => + Effect.gen(function* () { + expect(yield* SystemContext.initialize(stringContext({ key: "core/remote", value: SystemContext.unavailable }))).toEqual({ + baseline: "", + snapshot: {}, + }) + }), + ) + + it.effect("emits the previously stored removal message", () => + Effect.gen(function* () { + expect( + yield* SystemContext.reconcile(SystemContext.empty, { + "core/instructions": { value: "contents", removed: "Instructions removed; stop applying them." }, + }), + ).toEqual({ + _tag: "Updated", + text: "Instructions removed; stop applying them.", + snapshot: {}, + }) + }), + ) + + it.effect("requests replacement when a source without removal text disappears", () => + Effect.gen(function* () { + expect(yield* SystemContext.reconcile(SystemContext.empty, { "core/date": { value: "2026-06-04" } })).toMatchObject({ + _tag: "Replaced", + }) + }), + ) + + it.effect("renders multiple removals in stable key order", () => + Effect.gen(function* () { + expect( + yield* SystemContext.reconcile(SystemContext.empty, { + "core/z": { value: "z", removed: "Removed z" }, + "core/a": { value: "a", removed: "Removed a" }, + }), + ).toMatchObject({ _tag: "Updated", text: "Removed a\n\nRemoved z" }) + }), + ) + + it.effect("rejects empty model-visible renderings", () => + Effect.gen(function* () { + const exit = yield* SystemContext.initialize( + stringContext({ key: "core/empty", value: "value", baseline: () => "" }), + ).pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("rendered an empty baseline") + }), + ) + + it.effect("requests replacement when a stored value no longer decodes", () => + Effect.gen(function* () { + expect( + yield* SystemContext.reconcile(stringContext({ key: "core/date", value: "2026-06-04" }), { + "core/date": { value: 42, removed: "Date removed" }, + }), + ).toMatchObject({ _tag: "Replaced" }) + }), + ) + + it.effect("replaces from one coherent source observation", () => + Effect.gen(function* () { + let loads = 0 + const context = SystemContext.make({ + key: key("core/date"), + codec: Schema.toCodecJson(Schema.String), load: Effect.sync(() => { loads++ - return { baseline: "Today's date is 2026-06-03.", update: "The current date is 2026-06-03." } + return "2026-06-04" }), - }), - location: SystemContext.value({ - key: key("core/location"), - load: Effect.succeed({ baseline: "Working directory: /repo", update: "The working directory is /repo." }), - }), - }) + baseline: String, + update: (_previous, current) => current, + }) - const initialized = SystemContext.initialize(await Effect.runPromise(SystemContext.load(context))) + expect(yield* SystemContext.reconcile(context, { "core/date": { value: 42 } })).toMatchObject({ + _tag: "Replaced", + generation: { baseline: "2026-06-04" }, + }) + expect(loads).toBe(1) + }), + ) - expect(loads).toBe(1) - expect(initialized).toEqual({ - baseline: [ - { key: key("core/date"), text: "Today's date is 2026-06-03." }, - { key: key("core/location"), text: "Working directory: /repo" }, - ], - checkpoint: { - "core/date": Hash.sha256("The current date is 2026-06-03."), - "core/location": Hash.sha256("The working directory is /repo."), - }, - }) - }) - - test("emits changed and newly registered components in declaration order", async () => { - const context = SystemContext.struct({ - date: SystemContext.value({ - key: key("core/date"), - load: Effect.succeed({ baseline: "Today's date is 2026-06-04.", update: "The current date is 2026-06-04." }), - }), - location: SystemContext.value({ - key: key("core/location"), - load: Effect.succeed({ baseline: "Working directory: /repo", update: "The working directory is /repo." }), - }), - skills: SystemContext.value({ - key: key("core/skills"), - load: Effect.succeed({ baseline: "Available skills: effect", update: "Available skills: effect" }), - }), - }) - - const refreshed = SystemContext.refresh(await Effect.runPromise(SystemContext.load(context)), { - "core/date": Hash.sha256("The current date is 2026-06-03."), - "core/location": Hash.sha256("The working directory is /repo."), - }) - - expect(refreshed).toEqual({ - changes: [ - { key: key("core/date"), text: "The current date is 2026-06-04." }, - { key: key("core/skills"), text: "Available skills: effect" }, - ], - checkpoint: { - "core/date": Hash.sha256("The current date is 2026-06-04."), - "core/location": Hash.sha256("The working directory is /repo."), - "core/skills": Hash.sha256("Available skills: effect"), - }, - }) - }) - - test("omits unavailable initial context and admits it after its first successful load", async () => { - let available = false - const context = SystemContext.struct({ - remote: SystemContext.value({ - key: key("core/remote-instructions"), - load: Effect.sync(() => - available - ? { baseline: "Remote instructions: available", update: "Remote instructions are now available." } - : SystemContext.unavailable, - ), - }), - }) - - const initialized = SystemContext.initialize(await Effect.runPromise(SystemContext.load(context))) - available = true - const refreshed = SystemContext.refresh( - await Effect.runPromise(SystemContext.load(context)), - initialized.checkpoint, - ) - - expect(initialized).toEqual({ baseline: [], checkpoint: {} }) - expect(refreshed.changes).toEqual([ - { key: key("core/remote-instructions"), text: "Remote instructions are now available." }, - ]) - }) - - test("retains an existing checkpoint while context is unavailable", async () => { - const previous = { "core/remote-instructions": Hash.sha256("Remote instructions: old") } - const context = SystemContext.struct({ - remote: SystemContext.value({ - key: key("core/remote-instructions"), - load: Effect.succeed(SystemContext.unavailable), - }), - }) - - const refreshed = SystemContext.refresh(await Effect.runPromise(SystemContext.load(context)), previous) - - expect(refreshed).toEqual({ changes: [], checkpoint: previous }) - }) - - test("blocks replacement while admitted context is unavailable", async () => { - const previous = { "core/remote-instructions": Hash.sha256("Remote instructions: old") } - const snapshot = await Effect.runPromise( - SystemContext.load( - SystemContext.struct({ - remote: SystemContext.value({ - key: key("core/remote-instructions"), - load: Effect.succeed(SystemContext.unavailable), - }), + it.effect("does not render discarded updates while replacing", () => + Effect.gen(function* () { + let updates = 0 + const context = SystemContext.combine([ + stringContext({ + key: "core/date", + value: "2026-06-04", + update: () => { + updates++ + return "updated" + }, }), - ), - ) + stringContext({ key: "core/location", value: "/repo" }), + ]) - expect(SystemContext.replacementBlocked(snapshot, previous)).toBe(true) - expect(SystemContext.replacementBlocked(snapshot, {})).toBe(false) - }) + expect( + yield* SystemContext.reconcile(context, { + "core/date": { value: "2026-06-03" }, + "core/location": { value: 42 }, + }), + ).toMatchObject({ _tag: "Replaced" }) + expect(updates).toBe(0) + }), + ) - test("emits tombstones and drops checkpoints for removed components", async () => { - const context = SystemContext.struct({ - date: SystemContext.value({ - key: key("core/date"), - load: Effect.succeed({ baseline: "Today's date is 2026-06-03.", update: "The current date is 2026-06-03." }), - }), - }) + it.effect("blocks an incompatible replacement while another admitted source is unavailable", () => + Effect.gen(function* () { + const previous = { + "core/date": { value: 42, removed: "Date removed" }, + "core/remote": { value: "instructions", removed: "Instructions removed" }, + } + const context = SystemContext.combine([ + stringContext({ key: "core/date", value: "2026-06-04" }), + stringContext({ key: "core/remote", value: SystemContext.unavailable }), + ]) - const refreshed = SystemContext.refresh(await Effect.runPromise(SystemContext.load(context)), { - "core/date": Hash.sha256("The current date is 2026-06-03."), - "plugin/removed": Hash.sha256("Removed plugin context"), - }) + expect(yield* SystemContext.reconcile(context, previous)).toEqual({ _tag: "ReplacementBlocked" }) + expect(yield* SystemContext.replace(context, previous)).toEqual({ _tag: "ReplacementBlocked" }) + }), + ) - expect(refreshed).toEqual({ - changes: [{ key: key("plugin/removed"), text: "System context component removed: plugin/removed" }], - checkpoint: { "core/date": Hash.sha256("The current date is 2026-06-03.") }, - }) - }) + it.effect("rejects duplicate source keys", () => + Effect.sync(() => { + expect(() => + SystemContext.combine([ + stringContext({ key: "core/date", value: "one" }), + stringContext({ key: "core/date", value: "two" }), + ]), + ).toThrow(new SystemContext.DuplicateKeyError({ key: key("core/date") })) + }), + ) - test("ignores inherited checkpoint properties", async () => { - const context = SystemContext.struct({ - date: SystemContext.value({ - key: key("core/date"), - load: Effect.succeed({ baseline: "Today's date is 2026-06-03.", update: "The current date is 2026-06-03." }), - }), - }) - const previous = Object.create({ - "core/date": Hash.sha256("The current date is 2026-06-03."), - }) as SystemContext.Checkpoint + it.effect("combines contexts in order", () => + Effect.gen(function* () { + expect( + (yield* SystemContext.initialize( + SystemContext.combine([ + stringContext({ key: "core/date", value: "date" }), + stringContext({ key: "core/location", value: "location" }), + ]), + )).baseline, + ).toBe("date\n\nlocation") + }), + ) - const refreshed = SystemContext.refresh(await Effect.runPromise(SystemContext.load(context)), previous) + it.effect("requires namespaced source keys", () => + Effect.sync(() => { + const decodeKey = Schema.decodeUnknownSync(SystemContext.Key) - expect(refreshed.changes).toEqual([{ key: key("core/date"), text: "The current date is 2026-06-03." }]) - expect(Object.hasOwn(refreshed.checkpoint, "core/date")).toBe(true) - }) + expect(decodeKey("core/date")).toBe(key("core/date")) + expect(() => decodeKey("date")).toThrow() + }), + ) - test("preserves unexpected loader failures", async () => { - const context = SystemContext.struct({ - broken: SystemContext.value({ - key: key("plugin/broken"), - load: Effect.fail("broken loader"), - }), - }) + it.effect("requires namespaced durable snapshot keys", () => + Effect.sync(() => { + const decodeSnapshot = Schema.decodeUnknownSync(SystemContext.Snapshot) - await expect(Effect.runPromise(SystemContext.load(context))).rejects.toBe("broken loader") - }) - - test("rejects duplicate component keys", () => { - expect(() => - SystemContext.struct({ - one: SystemContext.value({ key: key("core/date"), load: Effect.succeed({ baseline: "one", update: "one" }) }), - two: SystemContext.value({ key: key("core/date"), load: Effect.succeed({ baseline: "two", update: "two" }) }), - }), - ).toThrow(new SystemContext.DuplicateKeyError({ key: key("core/date") })) - }) - - test("rejects duplicate component keys at the interpreter boundary", async () => { - const component = SystemContext.value({ - key: key("core/date"), - load: Effect.succeed({ baseline: "date", update: "date" }), - }) - const context: SystemContext.SystemContext = { components: [component, component] } - - await expect(Effect.runPromise(SystemContext.load(context))).rejects.toBeInstanceOf(SystemContext.DuplicateKeyError) - }) - - test("requires namespaced component keys", () => { - const decode = Schema.decodeUnknownSync(SystemContext.Key) - - expect(decode("core/date")).toBe(key("core/date")) - expect(() => decode("date")).toThrow() - expect(() => decode("core/")).toThrow() - }) - - test("requires namespaced checkpoint keys", () => { - const decode = Schema.decodeUnknownSync(SystemContext.CheckpointSchema) - const valid = JSON.parse('{"core/date":"hash"}') - const invalid = JSON.parse('{"date":"hash"}') - - expect(decode(valid)).toEqual(valid) - expect(() => decode(invalid)).toThrow() - }) + expect(Object.keys(decodeSnapshot({ "core/date": { value: "date" } }))).toEqual(["core/date"]) + expect(() => decodeSnapshot({ date: { value: "date" } })).toThrow() + expect(() => decodeSnapshot({ "core/date": { value: "date", removed: "" } })).toThrow() + }), + ) }) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx index be13d2007d..57df7c3935 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx @@ -175,6 +175,16 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( }) }) break + case "session.next.context.updated": + update(event.properties.sessionID, (draft) => { + draft.unshift({ + id: event.properties.messageID, + type: "system", + text: event.properties.text, + time: { created: event.properties.timestamp }, + }) + }) + break case "session.next.synthetic": update(event.properties.sessionID, (draft) => { prepend(draft, { diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx index 9f2008d2b5..2e54a7365d 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -104,6 +104,9 @@ function View(props: { api: TuiPluginApi; sessionID: string }) { <> + + <> + diff --git a/specs/v2/refreshable-context-sources.md b/specs/v2/refreshable-context-sources.md index fd66499665..d1d8ff70ee 100644 --- a/specs/v2/refreshable-context-sources.md +++ b/specs/v2/refreshable-context-sources.md @@ -27,7 +27,7 @@ Watcher-backed caches are a later efficiency optimization for roots with proven | `EventV2.subscribe(...)` | Expose advisory events as scoped Effect streams. | | `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. | -| `SystemContext` | Convert coherent source samples into immutable baseline parts, chronological updates, unavailable state, and removal tombstones. | +| `SystemContext` | Convert coherent source samples into one immutable baseline, chronological updates, unavailable state, and removal tombstones. | | `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. @@ -128,7 +128,7 @@ sequenceDiagram ## Observation Units -Compose refreshables around coherent observations that share one invalidation policy. Do not create one uniformly per rendered Context Component or one aggregate cache for unrelated source kinds. +Compose refreshables around coherent observations that share one invalidation policy. Do not create one uniformly per rendered Context Source or one aggregate cache for unrelated source kinds. ```text local built-in discovery @@ -153,13 +153,13 @@ Add a Location-scoped service: ```ts export interface InstructionContext.Interface { - readonly loadAmbient: () => Effect.Effect> + readonly loadAmbient: () => Effect.Effect } ``` `InstructionContext` owns instruction discovery, stable source identity, deterministic ordering, and source loading. `SystemContext` remains unaware of files and URLs. -Each effective source becomes one independently keyed component: +Each effective instruction becomes one independently keyed `SystemContext.Source` closed into the aggregate context with `SystemContext.make(...)`: ```text core/instructions/file/ @@ -192,7 +192,7 @@ sequenceDiagram Runner->>Instructions: loadAmbient Instructions->>Files: discover and read AGENTS.md files Files-->>Instructions: coherent current observation - Instructions-->>Runner: independently keyed components + Instructions-->>Runner: composed SystemContext Runner->>Epoch: compare and durably admit changes ``` @@ -208,19 +208,19 @@ observation -> what bytes or temporary failure does each identity currently produce? ``` -| Observation | Component outcome | +| Observation | Source outcome | | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -| Local scan succeeds and discovers readable file | Available component with exact contents. | -| Local scan succeeds and a previously discovered file is absent | Remove component so `SystemContext` emits a tombstone. | +| Local scan succeeds and discovers readable file | Available source with exact contents. | +| Local scan succeeds and a previously discovered file is absent | Remove source so `SystemContext` emits a tombstone. | | Local scan fails transiently | Preserve the domain-owned prior source graph as unavailable or fail the current turn; never emit mass removals. | -| Known local file read fails transiently | Preserve the component as `SystemContext.unavailable`. | +| Known local file read fails transiently | Preserve the source as `SystemContext.unavailable`. | | Known local file read reports not-found after discovery | Invalidate discovery and report unavailable until a coherent rescan confirms removal. | | Empty local file | Available exact content, not absence. | -| URL returns `2xx` body | Available component with exact contents. | +| URL returns `2xx` body | Available source with exact contents. | | URL times out or returns transient failure | `SystemContext.unavailable`. | | URL returns `404` or `410` | Decide the explicit removal contract before URL implementation. | -Instruction removal text must be model-meaningful. If instruction component keys hash source identities, add source-specific removal rendering before unlink support is considered complete: +Instruction removal text must be model-meaningful. If instruction source keys hash source identities, add source-specific removal rendering before unlink support is considered complete: ```text Instructions removed: /repo/packages/core/AGENTS.md @@ -234,7 +234,7 @@ Implement only: ```text global config AGENTS.md + upward project AGENTS.md ancestors -+ one keyed component per file ++ one keyed source per file + direct safe-turn observation ``` @@ -263,7 +263,7 @@ empty file transient scan failure transient file-read failure deterministic ordering -restart with durable checkpoint +restart with durable structured snapshots ``` ## Future Watcher Optimization @@ -316,7 +316,7 @@ Start with direct safe-turn loading: ```text safe provider-turn boundary -> fetch URL --> emit available or unavailable component +-> emit available or unavailable source ``` If measurements show excessive requests, add a URL-specific invalidation policy later: @@ -375,7 +375,7 @@ Nested instructions discovered after successful read-tool activity remain a Sess 1. Add and unit-test `Refreshable.make(load)` with `get` and `invalidate`. 2. Add model-meaningful instruction removal rendering support before unlink lands. 3. Add Location-scoped `InstructionContext` for global and upward project `AGENTS.md` only. -4. Compose instruction components into `SessionSystemContext.load()`. +4. Compose instruction sources into `SessionSystemContext.load()`. 5. Observe local instructions directly at each safe provider boundary. 6. Test add, edit, unlink, empty file, transient scan failure, transient read failure, restart, and deterministic ordering. 7. Add configured local exact paths and globs. @@ -386,11 +386,10 @@ Nested instructions discovered after successful read-tool activity remain a Sess ## Open Questions -1. Should source-specific removal rendering extend `SystemContext.Component`, or should `InstructionContext` retain removal metadata in a separate component registry? -2. Should the first local scan failure preserve prior discovered sources as unavailable, or fail the current provider turn until a coherent rescan succeeds? -3. Should configured URL sources treat `404` and `410` as confirmed removals? -4. What root-specific watcher API cleanly models ignore policy and callback health? -5. Should own-process file mutations publish an advisory invalidation event synchronously after commit? +1. Should the first local scan failure preserve prior discovered sources as unavailable, or fail the current provider turn until a coherent rescan succeeds? +2. Should configured URL sources treat `404` and `410` as confirmed removals? +3. What root-specific watcher API cleanly models ignore policy and callback health? +4. Should own-process file mutations publish an advisory invalidation event synchronously after commit? ## Compression Line diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 441435eb5d..e9a2bc7f19 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -687,23 +687,23 @@ Compatibility: - Foreground V2 bash execution is unchanged. - Reintroduce background bash only with durable status observation, completion delivery, and explicit cancellation semantics. -## 2026-06-04: Initialize Durable Session Context Epochs +## 2026-06-04: Add Durable Session Context Snapshots Affected schema: -- Add synchronized `session.next.context.initialized.1` Session events. -- Add `session_context_epoch` for one active immutable keyed baseline, component-hash checkpoint, and baseline sequence per Session. +- Add `session_context_epoch` for one active immutable baseline string, structured JSON snapshot, and baseline sequence per Session. Change: -- Lazily initialize one durable Context Epoch at the first safe provider-turn boundary. -- Lower its exact keyed baseline parts through `LLMRequest.system` for every provider turn in the epoch. +- Lazily initialize one durable Context Epoch snapshot at the first safe provider-turn boundary. +- Lower its exact baseline string through `LLMRequest.system` for every provider turn in the epoch. - Reuse the stored baseline verbatim after restart or producer changes instead of resampling privileged initial context. -- Keep ordinary Session transcript APIs unchanged. +- Compare later observations against an overwriteable codec-encoded structured snapshot rather than rendered-text hashes. +- Expose admitted chronological context as first-class `system` Session messages while keeping the active baseline in bounded context state. Compatibility: -- The unpublished Context Epoch schema is consolidated into one database migration and this adds one synchronized Session event type. +- The unpublished Context Epoch schema is consolidated into one database migration; baseline and structured snapshots are operational state rather than synchronized event history. - Existing experimental V2 Session databases remain disposable across incompatible pre-launch event-schema changes. - Chronological context updates, replacement epochs after compaction or model switches, project instructions, skills guidance, and plugin transforms remain follow-up slices. @@ -711,21 +711,21 @@ Compatibility: Affected schema: -- Add synchronized `session.next.context.updated.1` Session events. -- Add `session_context_epoch.revision` for transactional checkpoint advancement. -- Add `session_context_message` for hidden chronological keyed context updates ordered by Session aggregate sequence. +- Add synchronized `session.next.context.updated.1` Session events containing only exact combined model-visible text. +- Add `session_context_epoch.revision` for transactional structured-snapshot advancement. +- Add the first-class `system` Session message projection for chronological context updates. Change: -- Refresh Location-scoped Context Components at each safe provider-turn boundary. -- Keep the stored baseline immutable while admitting changed component values as runner-private chronological `Message.system(...)` history. -- Emit an explicit tombstone update and advance component-hash checkpoints transactionally when a component is removed. -- Keep ordinary Session transcript APIs unchanged while runner history merges visible Session messages and hidden context updates by durable aggregate sequence. +- Reconcile Location-scoped Context Sources at each safe provider-turn boundary using one coherent observation. +- Keep the stored baseline immutable while admitting changed source renderings as chronological `Message.system(...)` history. +- Advance the overwriteable structured snapshot atomically with the rendered System-message event. +- Emit the previously stored model-meaningful removal rendering when a source is removed. - Reject chronological system updates that would split a local tool call from its result across provider protocols; use wrapped user fallback when Anthropic native system-update placement is unsupported. Compatibility: -- The unpublished Context Epoch schema remains consolidated into one database migration and this adds one synchronized Session event type. +- The synchronized event log retains only text actually shown to the model, not internal structured snapshots. - Existing experimental V2 Session databases remain disposable across incompatible pre-launch event-schema changes. - Replacement epochs after compaction or model switches, project instructions, skills guidance, and plugin transforms remain follow-up slices. @@ -733,18 +733,17 @@ Compatibility: Affected schema: -- Add synchronized `session.next.context.replaced.1` Session events. - Add nullable `session_context_epoch.replacement_seq` for idempotent lazy replacement requests. Change: - Mark the active Context Epoch for replacement after a model switch or completed compaction projection. - Persist the triggering aggregate sequence so same-target replay cannot reopen an already-settled replacement. -- Render and persist the fresh immutable baseline lazily at the next safe provider-turn boundary. -- Exclude hidden chronological updates from earlier epochs when assembling active provider history. +- Render and overwrite the fresh immutable baseline and structured snapshot lazily at the next safe provider-turn boundary. +- Exclude chronological System messages from earlier epochs when assembling active provider history. Compatibility: -- The unpublished Context Epoch schema remains consolidated into one database migration and this adds one synchronized Session event type. +- Baseline replacement is bounded operational state and does not add permanent synchronized events. - Existing experimental V2 Session databases remain disposable across incompatible pre-launch event-schema changes. - Compaction execution, project instructions, skills guidance, and plugin transforms remain follow-up slices. From b28546a6a5b8670671a746ca0f72bec1a296d408 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 4 Jun 2026 20:28:48 -0400 Subject: [PATCH 06/17] feat(core): register v2 system context sources --- CONTEXT.md | 11 +- packages/core/src/instruction-context.ts | 71 +++++++ packages/core/src/location-layer.ts | 7 +- packages/core/src/plugin/boot.ts | 4 + packages/core/src/session/context-epoch.ts | 4 +- packages/core/src/session/runner/llm.ts | 8 +- ...-context.ts => system-context-builtins.ts} | 22 +-- packages/core/src/system-context-registry.ts | 48 +++++ .../core/test/instruction-context.test.ts | 185 ++++++++++++++++++ .../core/test/session-runner-recorded.test.ts | 37 ++-- packages/core/test/session-runner.test.ts | 47 ++--- ...est.ts => system-context-builtins.test.ts} | 72 +++++-- .../core/test/system-context-registry.test.ts | 113 +++++++++++ specs/v2/refreshable-context-sources.md | 66 ++++--- 14 files changed, 588 insertions(+), 107 deletions(-) create mode 100644 packages/core/src/instruction-context.ts rename packages/core/src/{session-system-context.ts => system-context-builtins.ts} (70%) create mode 100644 packages/core/src/system-context-registry.ts create mode 100644 packages/core/test/instruction-context.test.ts rename packages/core/test/{session-system-context.test.ts => system-context-builtins.test.ts} (51%) create mode 100644 packages/core/test/system-context-registry.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index 2758a944cf..562cd43dcf 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -12,6 +12,9 @@ _Avoid_: System prompt One independently observed typed value within the **System Context**, represented by a stable key, JSON codec, infallible loader, pure baseline/update renderers, and an optional removal renderer for dynamic sources. _Avoid_: Prompt fragment +**System Context Registry**: +The Location-scoped registry of ordered, scoped producers that contribute to the current **System Context**. + **Mid-Conversation System Message**: A durable chronological instruction that tells the model the newly effective state of a changed **Context Source**. _Avoid_: System update, system notification, raw text diff @@ -35,6 +38,7 @@ The point immediately before a provider call, after durable input promotion and ## Relationships - A **System Context** is an opaque carrier composed from zero or more **Context Sources**. +- The **System Context Registry** uses stable-keyed scoped contributions to assemble the current **System Context**; contributor removal naturally removes its sources at the next **Safe Provider-Turn Boundary**. - A changed **Context Source** may produce one **Mid-Conversation System Message** containing its newly effective state. - A **Mid-Conversation System Message** persists the exact combined rendered text sent to the model. - The current **Context Snapshot** advances atomically with the corresponding durable **Mid-Conversation System Message**. @@ -45,7 +49,7 @@ The point immediately before a provider call, after durable input promotion and - The first provider turn renders the latest **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**. - 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**. -- **Context Source** keys are stable and namespaced; duplicate keys fail composition. `SystemContext.combine(...)` preserves caller order; future plugin-source assembly must append plugin-defined sources in lexicographic 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. - `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. @@ -56,7 +60,8 @@ The point immediately before a provider call, after durable input promotion and - 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. - Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values. -- Plugin-defined **Context Sources** register through a scoped replayable registry so plugin hot reload adds and removes sources predictably. +- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**. +- Built-in, 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. - 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. - **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them. @@ -67,7 +72,7 @@ The point immediately before a provider call, after durable input promotion and - Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache. - A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history. - A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise. -- When an effective instruction file changes, its **Mid-Conversation System Message** includes the complete current contents and supersedes the prior version from that source; when it is removed, the message states that it no longer applies. +- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply. ## Example dialogue diff --git a/packages/core/src/instruction-context.ts b/packages/core/src/instruction-context.ts new file mode 100644 index 0000000000..c764c13067 --- /dev/null +++ b/packages/core/src/instruction-context.ts @@ -0,0 +1,71 @@ +export * as InstructionContext from "./instruction-context" + +import { Array, Effect, Layer, Schema } from "effect" +import { join } from "path" +import { FSUtil } from "./fs-util" +import { Global } from "./global" +import { Location } from "./location" +import { AbsolutePath } from "./schema" +import { SystemContext } from "./system-context" +import { SystemContextRegistry } from "./system-context-registry" + +class File extends Schema.Class("InstructionContext.File")({ + path: AbsolutePath, + content: Schema.String, +}) {} + +const Files = Schema.Array(File) + +export const layer = Layer.effectDiscard( + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const global = yield* Global.Service + const location = yield* Location.Service + const registry = yield* SystemContextRegistry.Service + + const source = (value: ReadonlyArray | SystemContext.Unavailable) => + SystemContext.make({ + key: SystemContext.Key.make("core/instructions"), + codec: Schema.toCodecJson(Files), + load: Effect.succeed(value), + baseline: render, + update: (_previous, current) => render(current), + removed: () => "Previously loaded instructions no longer apply.", + }) + + const observe = Effect.fn("InstructionContext.observe")(function* () { + const discovered = new Set( + (yield* fs.up({ targets: ["AGENTS.md"], start: location.directory, stop: location.project.directory })).map( + FSUtil.resolve, + ), + ) + const paths = Array.dedupe([FSUtil.resolve(join(global.config, "AGENTS.md")), ...discovered]) + const files = yield* Effect.forEach( + paths, + (path) => + fs.readFileStringSafe(path).pipe( + Effect.map((content) => (content === undefined ? undefined : new File({ path: AbsolutePath.make(path), content }))), + ), + { concurrency: "unbounded" }, + ) + if (files.some((file, index) => file === undefined && discovered.has(paths[index]))) return SystemContext.unavailable + return files.filter((file): file is File => file !== undefined) + }) + + yield* registry.contribute({ + key: "core/instructions", + load: observe().pipe( + Effect.map((files) => + files === SystemContext.unavailable ? source(files) : files.length === 0 ? SystemContext.empty : source(files), + ), + Effect.catch(() => Effect.succeed(source(SystemContext.unavailable))), + ), + }) + }), +) + +export const locationLayer = layer + +function render(files: ReadonlyArray) { + return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n") +} diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index 194afd9013..d6c01e1c86 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -40,13 +40,14 @@ import { RequestExecutor } from "@opencode-ai/llm/route" import * as SessionRunnerLLM from "./session/runner/llm" import { SessionRunnerModel } from "./session/runner/model" import { SessionRunCoordinator } from "./session/run-coordinator" -import { SessionSystemContext } from "./session-system-context" +import { SystemContextBuiltIns } from "./system-context-builtins" import { FetchHttpClient } from "effect/unstable/http" export class LocationServiceMap extends LayerMap.Service()("@opencode/example/LocationServiceMap", { lookup: (ref: Location.Ref) => { const location = Location.layer(ref) const permissionsAndTools = ToolRegistry.layer.pipe(Layer.provideMerge(PermissionV2.locationLayer)) + const systemContext = SystemContextBuiltIns.locationLayer const services = Layer.mergeAll( location, Policy.locationLayer, @@ -56,12 +57,12 @@ export class LocationServiceMap extends LayerMap.Service()(" Catalog.locationLayer, CommandV2.locationLayer, AgentV2.locationLayer, - PluginBoot.locationLayer, + PluginBoot.locationLayer.pipe(Layer.provide(systemContext)), FileSystem.locationLayer, Watcher.locationLayer, Pty.locationLayer, SkillV2.locationLayer, - SessionSystemContext.locationLayer, + systemContext, permissionsAndTools, LocationMutation.locationLayer.pipe(Layer.orDie), ).pipe(Layer.provideMerge(location)) diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index 6a589a1ef0..7cdc54fcf2 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -25,6 +25,7 @@ import { EnvPlugin } from "./env" import { ModelsDevPlugin } from "./models-dev" import { ProviderPlugins } from "./provider" import { SkillV2 } from "../skill" +import { SystemContextRegistry } from "../system-context-registry" type Plugin = { id: PluginV2.ID @@ -42,6 +43,7 @@ type Plugin = { | Config.Service | ModelsDev.Service | SkillV2.Service + | SystemContextRegistry.Service > } @@ -67,6 +69,7 @@ export const layer = Layer.effect( const fs = yield* FSUtil.Service const global = yield* Global.Service const skill = yield* SkillV2.Service + const systemContext = yield* SystemContextRegistry.Service const done = yield* Deferred.make() const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) { @@ -86,6 +89,7 @@ export const layer = Layer.effect( Effect.provideService(Global.Service, global), Effect.provideService(SkillV2.Service, skill), Effect.provideService(PluginV2.Service, plugin), + Effect.provideService(SystemContextRegistry.Service, systemContext), ), }) }) diff --git a/packages/core/src/session/context-epoch.ts b/packages/core/src/session/context-epoch.ts index 2b9fdfb9f3..7374b58eff 100644 --- a/packages/core/src/session/context-epoch.ts +++ b/packages/core/src/session/context-epoch.ts @@ -4,8 +4,8 @@ import { and, eq, isNull, lt, or, sql } from "drizzle-orm" import { DateTime, Effect, Schema } from "effect" import type { Database } from "../database/database" import { EventV2 } from "../event" -import { SessionSystemContext } from "../session-system-context" import { SystemContext } from "../system-context" +import { SystemContextRegistry } from "../system-context-registry" import { SessionEvent } from "./event" import { SessionMessageID } from "./message-id" import { SessionSchema } from "./schema" @@ -16,7 +16,7 @@ type DatabaseService = Database.Interface["db"] export const prepare = Effect.fn("SessionContextEpoch.prepare")(function* ( db: DatabaseService, events: EventV2.Interface, - context: SessionSystemContext.Interface, + context: SystemContextRegistry.Interface, sessionID: SessionSchema.ID, ) { const [value, stored] = yield* Effect.all([context.load(), find(db, sessionID)], { concurrency: "unbounded" }) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 417bf8fd15..f67fe18f8e 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -14,7 +14,7 @@ import { SessionRunnerModel } from "./model" import { Database } from "../../database/database" import { SessionInput } from "../input" import { QuestionV2 } from "../../question" -import { SessionSystemContext } from "../../session-system-context" +import { SystemContextRegistry } from "../../system-context-registry" import { SessionContextEpoch } from "../context-epoch" /** @@ -36,8 +36,8 @@ import { SessionContextEpoch } from "../context-epoch" * - [x] Resolve the selected model through the location-scoped runner environment. * - [ ] Load the selected agent and effective permissions. * - [ ] Build provider/model-specific base instructions and environment facts. - * - [ ] Load configured project instructions such as `AGENTS.md`, remote instructions, and - * nearby nested instructions discovered while files are read. + * - [x] Load global and upward project `AGENTS.md` instructions. + * - [ ] Load configured and remote instructions plus nearby nested instructions discovered while files are read. * - [ ] List available skills in the system prompt and expose a tool for loading skill bodies. * - [ ] Resolve referenced files, directories, agents, repositories, MCP resources, and media. * - [ ] Apply steering reminders, plugin transforms, and structured-output policy. @@ -87,7 +87,7 @@ export const layer = Layer.effect( const tools = yield* ToolRegistry.Service const models = yield* SessionRunnerModel.Service const store = yield* SessionStore.Service - const systemContext = yield* SessionSystemContext.Service + const systemContext = yield* SystemContextRegistry.Service const db = (yield* Database.Service).db const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) { const session = yield* store.get(sessionID) diff --git a/packages/core/src/session-system-context.ts b/packages/core/src/system-context-builtins.ts similarity index 70% rename from packages/core/src/session-system-context.ts rename to packages/core/src/system-context-builtins.ts index 1b66da721e..f1ff09bc91 100644 --- a/packages/core/src/session-system-context.ts +++ b/packages/core/src/system-context-builtins.ts @@ -1,19 +1,15 @@ -export * as SessionSystemContext from "./session-system-context" +export * as SystemContextBuiltIns from "./system-context-builtins" -import { Context, DateTime, Effect, Layer, Schema } from "effect" +import { DateTime, Effect, Layer, Schema } from "effect" +import { InstructionContext } from "./instruction-context" import { Location } from "./location" import { SystemContext } from "./system-context" +import { SystemContextRegistry } from "./system-context-registry" -export interface Interface { - readonly load: () => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/v2/SessionSystemContext") {} - -export const layer = Layer.effect( - Service, +const builtIns = Layer.effectDiscard( Effect.gen(function* () { const location = yield* Location.Service + const registry = yield* SystemContextRegistry.Service const environment = [ "", ` Working directory: ${location.directory}`, @@ -40,8 +36,12 @@ export const layer = Layer.effect( }), ]) - return Service.of({ load: () => Effect.succeed(context) }) + yield* registry.contribute({ key: "core/builtins", load: Effect.succeed(context) }) }), ) +export const layer = Layer.mergeAll(builtIns, InstructionContext.layer).pipe( + Layer.provideMerge(SystemContextRegistry.layer), +) + export const locationLayer = layer diff --git a/packages/core/src/system-context-registry.ts b/packages/core/src/system-context-registry.ts new file mode 100644 index 0000000000..65c9b11a19 --- /dev/null +++ b/packages/core/src/system-context-registry.ts @@ -0,0 +1,48 @@ +export * as SystemContextRegistry from "./system-context-registry" + +import { Context, Effect, Layer, Ref, Scope } from "effect" +import { SystemContext } from "./system-context" + +export interface Contribution { + readonly key: string + readonly load: Effect.Effect +} + +export interface Interface { + readonly contribute: (contribution: Contribution) => Effect.Effect + readonly load: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/SystemContextRegistry") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const contributions = yield* Ref.make>([]) + + return Service.of({ + contribute: Effect.fn("SystemContextRegistry.contribute")(function* (contribution) { + yield* Effect.acquireRelease( + Ref.modify(contributions, (current) => { + if (current.some((item) => item.key === contribution.key)) return [false, current] + return [true, [...current, contribution]] + }).pipe( + Effect.flatMap((added) => + added ? Effect.void : Effect.die(`Duplicate system context contribution key: ${contribution.key}`), + ), + Effect.as(contribution), + ), + (entry) => Ref.update(contributions, (current) => current.filter((item) => item !== entry)), + ) + }), + load: Effect.fn("SystemContextRegistry.load")(function* () { + const current = (yield* Ref.get(contributions)).toSorted((a, b) => a.key.localeCompare(b.key)) + return SystemContext.combine( + yield* Effect.forEach(current, (contribution) => contribution.load, { concurrency: "unbounded" }), + ) + }), + }) + }), +) + +export const locationLayer = layer diff --git a/packages/core/test/instruction-context.test.ts b/packages/core/test/instruction-context.test.ts new file mode 100644 index 0000000000..2eea3586fb --- /dev/null +++ b/packages/core/test/instruction-context.test.ts @@ -0,0 +1,185 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import fs from "fs/promises" +import path from "path" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Global } from "@opencode-ai/core/global" +import { InstructionContext } from "@opencode-ai/core/instruction-context" +import { Location } from "@opencode-ai/core/location" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SystemContext } from "@opencode-ai/core/system-context" +import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry" +import { location } from "./fixture/location" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" + +const it = testEffect(Layer.empty) + +describe("InstructionContext", () => { + it.live("loads global and upward project AGENTS.md files as one aggregate context", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const global = path.join(tmp.path, "global") + const project = path.join(tmp.path, "project") + const directory = path.join(project, "packages", "core") + const outside = path.join(tmp.path, "AGENTS.md") + const globalFile = path.join(global, "AGENTS.md") + const projectFile = path.join(project, "AGENTS.md") + const packageFile = path.join(directory, "AGENTS.md") + yield* Effect.promise(async () => { + await fs.mkdir(global, { recursive: true }) + await fs.mkdir(directory, { recursive: true }) + await fs.writeFile(outside, "outside") + await fs.writeFile(globalFile, "global") + await fs.writeFile(projectFile, "project") + await fs.writeFile(packageFile, "package") + }) + + const load = SystemContextRegistry.Service.pipe( + Effect.flatMap((service) => service.load()), + Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), + Effect.provide(FSUtil.defaultLayer), + Effect.provide(Global.layerWith({ config: global })), + Effect.provide( + Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory: AbsolutePath.make(directory) }, + { projectDirectory: AbsolutePath.make(project) }, + ), + ), + ), + ), + ) + + const initialized = yield* SystemContext.initialize(yield* load) + expect(initialized.baseline).toBe( + [ + `Instructions from: ${globalFile}\nglobal`, + `Instructions from: ${packageFile}\npackage`, + `Instructions from: ${projectFile}\nproject`, + ].join("\n\n"), + ) + expect(initialized.baseline).not.toContain("outside") + + yield* Effect.promise(() => fs.writeFile(packageFile, "changed")) + expect(yield* SystemContext.reconcile(yield* load, initialized.snapshot)).toMatchObject({ + _tag: "Updated", + text: expect.stringContaining(`Instructions from: ${packageFile}\nchanged`), + }) + + yield* Effect.promise(() => Promise.all([fs.rm(globalFile), fs.rm(packageFile), fs.rm(projectFile)])) + expect(yield* SystemContext.reconcile(yield* load, initialized.snapshot)).toEqual({ + _tag: "Updated", + text: "Previously loaded instructions no longer apply.", + snapshot: {}, + }) + }), + ), + ), + ) + + it.live("keeps an empty AGENTS.md as available context", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const file = path.join(tmp.path, "AGENTS.md") + yield* Effect.promise(() => fs.writeFile(file, "")) + const context = yield* SystemContextRegistry.Service.pipe( + Effect.flatMap((service) => service.load()), + Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), + Effect.provide(FSUtil.defaultLayer), + Effect.provide(Global.layerWith({ config: path.join(tmp.path, "global") })), + Effect.provide( + Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })), + ), + ), + ) + + expect((yield* SystemContext.initialize(context)).baseline).toBe(`Instructions from: ${file}\n`) + }), + ), + ), + ) + + it.effect("preserves admitted instructions while observation is unavailable", () => + Effect.gen(function* () { + const failingFS = Layer.effect( + FSUtil.Service, + FSUtil.Service.pipe( + Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) })), + ), + ).pipe(Layer.provide(FSUtil.defaultLayer)) + const context = yield* SystemContextRegistry.Service.pipe( + Effect.flatMap((service) => service.load()), + Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), + Effect.provide(failingFS), + Effect.provide(Global.layerWith({ config: "/global" })), + Effect.provide( + Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make("/repo") })), + ), + ), + ) + + expect( + yield* SystemContext.reconcile(context, { + "core/instructions": { + value: [{ path: "/repo/AGENTS.md", content: "old" }], + removed: "Previously loaded instructions no longer apply.", + }, + }), + ).toEqual({ _tag: "Unchanged" }) + }), + ) + + it.effect("preserves admitted instructions when a discovered file disappears before read", () => + Effect.gen(function* () { + const file = AbsolutePath.make("/repo/AGENTS.md") + const racingFS = Layer.effect( + FSUtil.Service, + FSUtil.Service.pipe( + Effect.map((fs) => + FSUtil.Service.of({ + ...fs, + up: () => Effect.succeed([file]), + readFileStringSafe: () => Effect.succeed(undefined), + }), + ), + ), + ).pipe(Layer.provide(FSUtil.defaultLayer)) + const context = yield* SystemContextRegistry.Service.pipe( + Effect.flatMap((service) => service.load()), + Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), + Effect.provide(racingFS), + Effect.provide(Global.layerWith({ config: "/global" })), + Effect.provide( + Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make("/repo") })), + ), + ), + ) + + expect( + yield* SystemContext.reconcile(context, { + "core/instructions": { + value: [{ path: file, content: "old" }], + removed: "Previously loaded instructions no longer apply.", + }, + }), + ).toEqual({ _tag: "Unchanged" }) + }), + ) +}) diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index c9252b86bb..edd244e432 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -19,8 +19,8 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" -import { SessionSystemContext } from "@opencode-ai/core/session-system-context" import { SystemContext } from "@opencode-ai/core/system-context" +import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry" import { describe, expect } from "bun:test" import { eq } from "drizzle-orm" import { Effect, Layer, Schema } from "effect" @@ -57,22 +57,25 @@ const model = OpenAIChat.route }) .model({ id: "gpt-4o-mini" }) const models = SessionRunnerModel.layerWith(() => Effect.succeed(model)) -const systemContext = Layer.succeed( - SessionSystemContext.Service, - SessionSystemContext.Service.of({ - load: () => - Effect.succeed( - SystemContext.make({ - key: SystemContext.Key.make("test/context"), - codec: Schema.toCodecJson(Schema.String), - load: Effect.succeed("Recorded context"), - baseline: String, - update: (_previous, current) => current, - removed: () => "Recorded context removed", - }), - ), - }), -) +const systemContext = Layer.effectDiscard( + SystemContextRegistry.Service.pipe( + Effect.flatMap((registry) => + registry.contribute({ + key: "test/context", + load: Effect.succeed( + SystemContext.make({ + key: SystemContext.Key.make("test/context"), + codec: Schema.toCodecJson(Schema.String), + load: Effect.succeed("Recorded context"), + baseline: String, + update: (_previous, current) => current, + removed: () => "Recorded context removed", + }), + ), + }), + ), + ), +).pipe(Layer.provideMerge(SystemContextRegistry.layer)) const runner = SessionRunnerLLM.defaultLayer.pipe( Layer.provide(database), Layer.provide(store), diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index a960061f45..ef8b8617c8 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -34,8 +34,8 @@ import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { NativeTool } from "@opencode-ai/core/tool/native" import { SessionContextEpochTable, SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" -import { SessionSystemContext } from "@opencode-ai/core/session-system-context" import { SystemContext } from "@opencode-ai/core/system-context" +import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { Cause, DateTime, Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect" @@ -145,28 +145,31 @@ const systemContextKey = SystemContext.Key.make("test/context") let systemBaseline = "Initial context" let systemRemoved = false let systemUnavailable = false -const systemContext = Layer.succeed( - SessionSystemContext.Service, - SessionSystemContext.Service.of({ - load: () => - Effect.succeed( - SystemContext.combine( - systemRemoved - ? [] - : [ - SystemContext.make({ - key: systemContextKey, - codec: Schema.toCodecJson(Schema.String), - load: Effect.sync(() => (systemUnavailable ? SystemContext.unavailable : systemBaseline)), - baseline: String, - update: (_previous, current) => current, - removed: () => "System context source removed: test/context", - }), - ], +const systemContext = Layer.effectDiscard( + SystemContextRegistry.Service.pipe( + Effect.flatMap((registry) => + registry.contribute({ + key: "test/context", + load: Effect.sync(() => + SystemContext.combine( + systemRemoved + ? [] + : [ + SystemContext.make({ + key: systemContextKey, + codec: Schema.toCodecJson(Schema.String), + load: Effect.sync(() => (systemUnavailable ? SystemContext.unavailable : systemBaseline)), + baseline: String, + update: (_previous, current) => current, + removed: () => "System context source removed: test/context", + }), + ], + ), ), - ), - }), -) + }), + ), + ), +).pipe(Layer.provideMerge(SystemContextRegistry.layer)) const runner = SessionRunnerLLM.layer.pipe( Layer.provide(database), Layer.provide(store), diff --git a/packages/core/test/session-system-context.test.ts b/packages/core/test/system-context-builtins.test.ts similarity index 51% rename from packages/core/test/session-system-context.test.ts rename to packages/core/test/system-context-builtins.test.ts index d0fb03afa7..ed854abcd8 100644 --- a/packages/core/test/session-system-context.test.ts +++ b/packages/core/test/system-context-builtins.test.ts @@ -2,9 +2,12 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import * as TestClock from "effect/testing/TestClock" import { Location } from "@opencode-ai/core/location" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Global } from "@opencode-ai/core/global" import { AbsolutePath } from "@opencode-ai/core/schema" -import { SessionSystemContext } from "@opencode-ai/core/session-system-context" import { SystemContext } from "@opencode-ai/core/system-context" +import { SystemContextBuiltIns } from "@opencode-ai/core/system-context-builtins" +import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry" import { location } from "./fixture/location" import { testEffect } from "./lib/effect" @@ -12,24 +15,44 @@ const directory = AbsolutePath.make("/repo/packages/core") const projectDirectory = AbsolutePath.make("/repo") const timestamp = Date.parse("2026-06-03T12:00:00.000Z") const localDate = (time: number) => new Date(time).toDateString() +const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of( + location({ directory }, { projectDirectory, vcs: { type: "git", store: AbsolutePath.make("/repo/.git") } }), + ), +) const it = testEffect( - SessionSystemContext.locationLayer.pipe( - Layer.provide( - Layer.succeed( - Location.Service, - Location.Service.of( - location({ directory }, { projectDirectory, vcs: { type: "git", store: AbsolutePath.make("/repo/.git") } }), - ), - ), + SystemContextBuiltIns.locationLayer.pipe( + Layer.provide(FSUtil.defaultLayer), + Layer.provide(Global.layerWith({ config: "/global" })), + Layer.provide(locationLayer), + ), +) +const instructionFS = Layer.effect( + FSUtil.Service, + FSUtil.Service.pipe( + Effect.map((fs) => + FSUtil.Service.of({ + ...fs, + up: () => Effect.succeed(["/repo/AGENTS.md"]), + readFileStringSafe: (path) => Effect.succeed(path === "/repo/AGENTS.md" ? "Be precise." : undefined), + }), ), ), +).pipe(Layer.provide(FSUtil.defaultLayer)) +const itWithInstructions = testEffect( + SystemContextBuiltIns.locationLayer.pipe( + Layer.provide(instructionFS), + Layer.provide(Global.layerWith({ config: "/global" })), + Layer.provide(locationLayer), + ), ) -describe("SessionSystemContext", () => { +describe("SystemContextBuiltIns", () => { it.effect("loads location-scoped environment and host-local date context", () => Effect.gen(function* () { yield* TestClock.setTime(timestamp) - const context = yield* SessionSystemContext.Service + const context = yield* SystemContextRegistry.Service const initialized = yield* SystemContext.initialize(yield* context.load()) expect(initialized.baseline).toBe( @@ -51,7 +74,7 @@ describe("SessionSystemContext", () => { it.effect("reconciles the date without repeating unchanged environment context", () => Effect.gen(function* () { yield* TestClock.setTime(timestamp) - const context = yield* SessionSystemContext.Service + const context = yield* SystemContextRegistry.Service const initialized = yield* SystemContext.initialize(yield* context.load()) yield* TestClock.setTime(timestamp + 24 * 60 * 60 * 1000) @@ -67,11 +90,34 @@ describe("SessionSystemContext", () => { it.effect("does not update again within the same local calendar day", () => Effect.gen(function* () { yield* TestClock.setTime(timestamp) - const context = yield* SessionSystemContext.Service + const context = yield* SystemContextRegistry.Service const initialized = yield* SystemContext.initialize(yield* context.load()) yield* TestClock.setTime(timestamp + 60 * 60 * 1000) expect(yield* SystemContext.reconcile(yield* context.load(), initialized.snapshot)).toEqual({ _tag: "Unchanged" }) }), ) + + itWithInstructions.effect("composes ambient instructions after built-in context", () => + Effect.gen(function* () { + yield* TestClock.setTime(timestamp) + const context = yield* SystemContextRegistry.Service + + expect((yield* SystemContext.initialize(yield* context.load())).baseline).toBe( + [ + "Here is some useful information about the environment you are running in:", + "", + ` Working directory: ${directory}`, + ` Workspace root folder: ${projectDirectory}`, + " Is directory a git repo: yes", + ` Platform: ${process.platform}`, + "", + "", + `Today's date: ${localDate(timestamp)}`, + "", + "Instructions from: /repo/AGENTS.md\nBe precise.", + ].join("\n"), + ) + }), + ) }) diff --git a/packages/core/test/system-context-registry.test.ts b/packages/core/test/system-context-registry.test.ts new file mode 100644 index 0000000000..b1191cb065 --- /dev/null +++ b/packages/core/test/system-context-registry.test.ts @@ -0,0 +1,113 @@ +import { describe, expect } from "bun:test" +import { Cause, Effect, Exit, Schema, Scope } from "effect" +import { SystemContext } from "@opencode-ai/core/system-context" +import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry" +import { testEffect } from "./lib/effect" + +const contribution = (key: string, text: string, sourceKey = key) => ({ + key, + load: Effect.succeed( + SystemContext.make({ + key: SystemContext.Key.make(sourceKey), + codec: Schema.toCodecJson(Schema.String), + load: Effect.succeed(text), + baseline: String, + update: (_previous, current) => current, + }), + ), +}) + +const it = testEffect(SystemContextRegistry.layer) + +describe("SystemContextRegistry", () => { + it.effect("loads empty system context when there are no contributions", () => + Effect.gen(function* () { + const registry = yield* SystemContextRegistry.Service + + expect(yield* SystemContext.initialize(yield* registry.load())).toEqual({ baseline: "", snapshot: {} }) + }), + ) + + it.effect("loads scoped contributions in stable key order", () => + Effect.gen(function* () { + const registry = yield* SystemContextRegistry.Service + yield* registry.contribute(contribution("test/second", "second")) + yield* registry.contribute(contribution("test/first", "first")) + + expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("first\n\nsecond") + }), + ) + + it.effect("re-evaluates contribution producers on each load", () => + Effect.gen(function* () { + const registry = yield* SystemContextRegistry.Service + let loads = 0 + yield* registry.contribute({ + key: "test/dynamic", + load: Effect.sync(() => { + loads++ + return SystemContext.empty + }), + }) + + yield* registry.load() + yield* registry.load() + + expect(loads).toBe(2) + }), + ) + + it.effect("propagates contribution producer failures", () => + Effect.gen(function* () { + const registry = yield* SystemContextRegistry.Service + const failure = new Error("contribution failed") + yield* registry.contribute({ key: "test/failure", load: Effect.die(failure) }) + + const exit = yield* registry.load().pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBe(failure) + }), + ) + + it.effect("rejects duplicate source keys from separate contributions", () => + Effect.gen(function* () { + const registry = yield* SystemContextRegistry.Service + yield* registry.contribute(contribution("test/first", "first", "test/duplicate")) + yield* registry.contribute(contribution("test/second", "second", "test/duplicate")) + + const exit = yield* registry.load().pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toBeInstanceOf(SystemContext.DuplicateKeyError) + expect(Cause.squash(exit.cause)).toMatchObject({ key: SystemContext.Key.make("test/duplicate") }) + } + }), + ) + + it.effect("rejects duplicate contribution keys", () => + Effect.gen(function* () { + const registry = yield* SystemContextRegistry.Service + yield* registry.contribute(contribution("test/duplicate", "first")) + + const exit = yield* registry.contribute(contribution("test/duplicate", "second", "test/other")).pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("Duplicate system context contribution key") + }), + ) + + it.effect("removes a contribution when its owning scope closes", () => + Effect.gen(function* () { + const registry = yield* SystemContextRegistry.Service + const scope = yield* Scope.make() + yield* registry.contribute(contribution("test/scoped", "scoped")).pipe(Scope.provide(scope)) + + expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("scoped") + + yield* Scope.close(scope, Exit.void) + expect(yield* SystemContext.initialize(yield* registry.load())).toEqual({ baseline: "", snapshot: {} }) + }), + ) +}) diff --git a/specs/v2/refreshable-context-sources.md b/specs/v2/refreshable-context-sources.md index d1d8ff70ee..29a6ce4579 100644 --- a/specs/v2/refreshable-context-sources.md +++ b/specs/v2/refreshable-context-sources.md @@ -15,7 +15,7 @@ source signal -> Context Epoch compares and admits exact changed bytes durably ``` -The first ambient `AGENTS.md` slice will not depend on filesystem watching. It will directly observe local instruction state whenever `SessionSystemContext.load()` naturally runs before a provider turn. +The first ambient `AGENTS.md` slice will not depend on filesystem watching. Its scoped contributor will directly observe local instruction state whenever `SystemContextRegistry.load()` naturally runs before a provider turn. Watcher-backed caches are a later efficiency optimization for roots with proven subscription coverage. URLs remain separate observations with an independently chosen refresh policy. @@ -28,6 +28,7 @@ Watcher-backed caches are a later efficiency optimization for roots with proven | `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. | | `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. | | `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. @@ -91,9 +92,9 @@ stateDiagram-v2 Stale --> Stale: invalidate or reload fails ``` -### Why Custom Instead Of Effect `Cache` +### Why Custom Instead Of Existing Effect Caches -Effect `Cache`, `ScopedCache`, and `Effect.cachedInvalidateWithTTL(...)` cache failed exits. Context-source observation requires a narrower rule: +Effect `Cache`, `ScopedCache`, and `Effect.cachedInvalidateWithTTL(...)` cache failed exits. Effect `Resource` preserves its prior value after a failed refresh, but eagerly acquires and does not reload lazily after explicit invalidation. Context-source observation requires the narrower lazy `Empty` / `Fresh` / `Stale` rule: ```text failed refresh @@ -147,23 +148,24 @@ embedded skill -> direct value, no refreshable ``` -## Ambient Instruction Service +## Ambient Instruction Contributor -Add a Location-scoped service: +Add a Location-scoped contributor to `SystemContextRegistry`: ```ts -export interface InstructionContext.Interface { - readonly loadAmbient: () => Effect.Effect -} +yield* registry.contribute({ + key: "core/instructions", + load: loadAmbientInstructions(), +}) ``` -`InstructionContext` owns instruction discovery, stable source identity, deterministic ordering, and source loading. `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. -Each effective instruction becomes one independently keyed `SystemContext.Source` closed into the aggregate context with `SystemContext.make(...)`: +The first slice closes one coherent ordered instruction set into an aggregate source: ```text -core/instructions/file/ -core/instructions/url/ +core/instructions +-> [{ path, content }, ...] ``` Rendered text retains the human-readable source identity: @@ -185,46 +187,46 @@ The first implementation directly observes global and upward project `AGENTS.md` ```mermaid sequenceDiagram participant Runner as Safe Provider Boundary + participant Registry as System Context Registry participant Instructions as Instruction Context participant Files participant Epoch as Context Epoch - Runner->>Instructions: loadAmbient + Runner->>Registry: load + Registry->>Instructions: run contribution Instructions->>Files: discover and read AGENTS.md files Files-->>Instructions: coherent current observation - Instructions-->>Runner: composed SystemContext + Instructions-->>Registry: instruction SystemContext + Registry-->>Runner: composed SystemContext Runner->>Epoch: compare and durably admit changes ``` ## Source Outcomes -Discovery state and per-source byte observation are separate concerns. +Discovery and file reads form one coherent aggregate observation in the first slice. ```text -discovery --> which stable source identities currently exist? +successful discovery and reads +-> one ordered aggregate instruction value -observation --> what bytes or temporary failure does each identity currently produce? +temporary discovery or read failure +-> aggregate SystemContext.unavailable ``` | Observation | Source outcome | | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -| Local scan succeeds and discovers readable file | Available source with exact contents. | -| Local scan succeeds and a previously discovered file is absent | Remove source so `SystemContext` emits a tombstone. | -| Local scan fails transiently | Preserve the domain-owned prior source graph as unavailable or fail the current turn; never emit mass removals. | -| Known local file read fails transiently | Preserve the source as `SystemContext.unavailable`. | -| Known local file read reports not-found after discovery | Invalidate discovery and report unavailable until a coherent rescan confirms removal. | -| Empty local file | Available exact content, not absence. | +| 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 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. | | URL returns `2xx` body | Available source with exact contents. | | URL times out or returns transient failure | `SystemContext.unavailable`. | | URL returns `404` or `410` | Decide the explicit removal contract before URL implementation. | -Instruction removal text must be model-meaningful. If instruction source keys hash source identities, add source-specific removal rendering before unlink support is considered complete: +Aggregate instruction removal text must be model-meaningful: ```text -Instructions removed: /repo/packages/core/AGENTS.md -Do not continue applying instructions previously loaded from this source. +Previously loaded instructions no longer apply. ``` ## First Ambient Slice @@ -234,7 +236,7 @@ Implement only: ```text global config AGENTS.md + upward project AGENTS.md ancestors -+ one keyed source per file ++ one aggregate core/instructions source + direct safe-turn observation ``` @@ -362,7 +364,7 @@ Nested instructions discovered after successful read-tool activity remain a Sess ## Lifecycle -- Location scope owns instruction services, optional watcher-consumer fibers, and refreshable state. +- Location scope owns the System Context Registry, scoped context contributions, optional watcher-consumer fibers, and refreshable state. - `Effect.forkScoped(...)` interrupts watcher-consumer fibers when the cached Location runtime is disposed. - Stream finalization unsubscribes `EventV2` PubSub subscriptions. - `Watcher.locationLayer` separately finalizes native Parcel watcher subscriptions. @@ -374,8 +376,8 @@ Nested instructions discovered after successful read-tool activity remain a Sess 1. Add and unit-test `Refreshable.make(load)` with `get` and `invalidate`. 2. Add model-meaningful instruction removal rendering support before unlink lands. -3. Add Location-scoped `InstructionContext` for global and upward project `AGENTS.md` only. -4. Compose instruction sources into `SessionSystemContext.load()`. +3. Add the Location-scoped `SystemContextRegistry` backed by stable-keyed scoped contributions. +4. Register built-in and ambient instruction producers with `SystemContextRegistry`. 5. Observe local instructions directly at each safe provider boundary. 6. Test add, edit, unlink, empty file, transient scan failure, transient read failure, restart, and deterministic ordering. 7. Add configured local exact paths and globs. From cd812e2045e1825ca92596e8d7f5a2ddf34510a1 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 4 Jun 2026 21:17:05 -0400 Subject: [PATCH 07/17] fix(core): finalize v2 session context epochs --- CONTEXT.md | 11 +- .../migration.sql | 0 .../snapshot.json | 260 +++---- packages/core/src/database/migration.gen.ts | 2 +- ...605003541_add_session_context_snapshot.ts} | 2 +- packages/core/src/event.ts | 37 +- packages/core/src/instruction-context.ts | 27 +- packages/core/src/session/context-epoch.ts | 127 ++- packages/core/src/session/context.ts | 10 +- packages/core/src/session/message.ts | 11 +- packages/core/src/session/projector.ts | 4 +- packages/core/src/session/runner/index.ts | 8 +- packages/core/src/session/runner/llm.ts | 3 +- packages/core/src/system-context-builtins.ts | 2 +- packages/core/src/system-context-registry.ts | 6 +- packages/core/src/system-context.ts | 23 +- packages/core/test/database-migration.test.ts | 159 ++-- packages/core/test/event.test.ts | 7 +- .../core/test/instruction-context.test.ts | 14 +- .../core/test/session-runner-message.test.ts | 727 +++++++++--------- .../core/test/session-runner-recorded.test.ts | 5 +- packages/core/test/session-runner.test.ts | 109 ++- .../core/test/system-context-registry.test.ts | 6 +- packages/core/test/system-context.test.ts | 29 +- ...haiku-4-5-chronological-system-update.json | 8 +- ...2-5-flash-chronological-system-update.json | 8 +- ...t-4o-mini-chronological-system-update.json | 8 +- .../src/cli/cmd/tui/context/sync-v2.tsx | 2 +- .../opencode/test/cli/tui/sync-v2.test.tsx | 97 +++ packages/sdk/js/src/v2/gen/types.gen.ts | 53 ++ packages/sdk/openapi.json | 169 ++++ specs/v2/refreshable-context-sources.md | 94 ++- specs/v2/schema-changelog.md | 24 +- 33 files changed, 1253 insertions(+), 799 deletions(-) rename packages/core/migration/{20260604234609_add_session_context_snapshot => 20260605003541_add_session_context_snapshot}/migration.sql (100%) rename packages/core/migration/{20260604234609_add_session_context_snapshot => 20260605003541_add_session_context_snapshot}/snapshot.json (94%) rename packages/core/src/database/migration/{20260604234609_add_session_context_snapshot.ts => 20260605003541_add_session_context_snapshot.ts} (92%) diff --git a/CONTEXT.md b/CONTEXT.md index 562cd43dcf..d5ccd6de6d 100644 --- a/CONTEXT.md +++ b/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**. - 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**. -- 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. - 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. - Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed. - `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**. -- `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, 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. +- 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. - 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**. -- 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. +- 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**. - 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. - 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. - 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. diff --git a/packages/core/migration/20260604234609_add_session_context_snapshot/migration.sql b/packages/core/migration/20260605003541_add_session_context_snapshot/migration.sql similarity index 100% rename from packages/core/migration/20260604234609_add_session_context_snapshot/migration.sql rename to packages/core/migration/20260605003541_add_session_context_snapshot/migration.sql diff --git a/packages/core/migration/20260604234609_add_session_context_snapshot/snapshot.json b/packages/core/migration/20260605003541_add_session_context_snapshot/snapshot.json similarity index 94% rename from packages/core/migration/20260604234609_add_session_context_snapshot/snapshot.json rename to packages/core/migration/20260605003541_add_session_context_snapshot/snapshot.json index 22c68919c5..6e1cce1361 100644 --- a/packages/core/migration/20260604234609_add_session_context_snapshot/snapshot.json +++ b/packages/core/migration/20260605003541_add_session_context_snapshot/snapshot.json @@ -1,10 +1,8 @@ { "version": "7", "dialect": "sqlite", - "id": "65b52d0f-2bbe-483f-a7ec-9d2a7fa29f57", - "prevIds": [ - "fc92fa34-8074-44c3-88f0-a5417f7fd92d" - ], + "id": "40f7b9b8-83b4-4ea0-a59f-76a489679d88", + "prevIds": ["84c6ad6c-6116-48e1-b973-6fee4593496b"], "ddl": [ { "name": "workspace", @@ -838,19 +836,9 @@ "entityType": "columns", "table": "session_context_epoch" }, - { - "type": "integer", - "notNull": false, - "autoincrement": true, - "default": null, - "generated": null, - "name": "seq", - "entityType": "columns", - "table": "session_input" - }, { "type": "text", - "notNull": true, + "notNull": false, "autoincrement": false, "default": null, "generated": null, @@ -888,6 +876,16 @@ "entityType": "columns", "table": "session_input" }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "admitted_seq", + "entityType": "columns", + "table": "session_input" + }, { "type": "integer", "notNull": false, @@ -1399,13 +1397,9 @@ "table": "session_share" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1414,13 +1408,9 @@ "table": "workspace" }, { - "columns": [ - "active_account_id" - ], + "columns": ["active_account_id"], "tableTo": "account", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "SET NULL", "nameExplicit": false, @@ -1429,13 +1419,9 @@ "table": "account_state" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "tableTo": "event_sequence", - "columnsTo": [ - "aggregate_id" - ], + "columnsTo": ["aggregate_id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1444,13 +1430,9 @@ "table": "event" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1459,13 +1441,9 @@ "table": "permission" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1474,13 +1452,9 @@ "table": "project_directory" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1489,13 +1463,9 @@ "table": "message" }, { - "columns": [ - "message_id" - ], + "columns": ["message_id"], "tableTo": "message", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1504,13 +1474,9 @@ "table": "part" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1519,13 +1485,9 @@ "table": "session_context_epoch" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1534,13 +1496,9 @@ "table": "session_input" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1549,13 +1507,9 @@ "table": "session_message" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1564,13 +1518,9 @@ "table": "session" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1579,13 +1529,9 @@ "table": "todo" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1594,165 +1540,126 @@ "table": "session_share" }, { - "columns": [ - "email", - "url" - ], + "columns": ["email", "url"], "nameExplicit": false, "name": "control_account_pk", "entityType": "pks", "table": "control_account" }, { - "columns": [ - "project_id", - "directory" - ], + "columns": ["project_id", "directory"], "nameExplicit": false, "name": "project_directory_pk", "entityType": "pks", "table": "project_directory" }, { - "columns": [ - "session_id", - "position" - ], + "columns": ["session_id", "position"], "nameExplicit": false, "name": "todo_pk", "entityType": "pks", "table": "todo" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "workspace_pk", "table": "workspace", "entityType": "pks" }, { - "columns": [ - "name" - ], + "columns": ["name"], "nameExplicit": false, "name": "data_migration_pk", "table": "data_migration", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_state_pk", "table": "account_state", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_pk", "table": "account", "entityType": "pks" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "nameExplicit": false, "name": "event_sequence_pk", "table": "event_sequence", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "event_pk", "table": "event", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "permission_pk", "table": "permission", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "project_pk", "table": "project", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "message_pk", "table": "message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "part_pk", "table": "part", "entityType": "pks" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "nameExplicit": false, "name": "session_context_epoch_pk", "table": "session_context_epoch", "entityType": "pks" }, { - "columns": [ - "seq" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_input_pk", "table": "session_input", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_message_pk", "table": "session_message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_pk", "table": "session", "entityType": "pks" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "nameExplicit": false, "name": "session_share_pk", "table": "session_share", @@ -1769,7 +1676,7 @@ "isExpression": false } ], - "isUnique": false, + "isUnique": true, "where": null, "origin": "manual", "name": "event_aggregate_seq_idx", @@ -1889,7 +1796,7 @@ "isExpression": false }, { - "value": "seq", + "value": "admitted_seq", "isExpression": false } ], @@ -1900,6 +1807,42 @@ "entityType": "indexes", "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": [ { @@ -1911,7 +1854,7 @@ "isExpression": false } ], - "isUnique": false, + "isUnique": true, "where": null, "origin": "manual", "name": "session_message_session_seq_idx", @@ -2031,16 +1974,7 @@ "name": "todo_session_idx", "entityType": "indexes", "table": "todo" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "session_input_id_unique", - "entityType": "uniques", - "table": "session_input" } ], "renames": [] -} \ No newline at end of file +} diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 949521287d..299ff846a5 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -32,6 +32,6 @@ export const migrations = ( import("./migration/20260603141458_session_input_inbox"), import("./migration/20260603160727_jittery_ezekiel_stane"), 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[] diff --git a/packages/core/src/database/migration/20260604234609_add_session_context_snapshot.ts b/packages/core/src/database/migration/20260605003541_add_session_context_snapshot.ts similarity index 92% rename from packages/core/src/database/migration/20260604234609_add_session_context_snapshot.ts rename to packages/core/src/database/migration/20260605003541_add_session_context_snapshot.ts index f035e53cbd..d1648969dd 100644 --- a/packages/core/src/database/migration/20260604234609_add_session_context_snapshot.ts +++ b/packages/core/src/database/migration/20260605003541_add_session_context_snapshot.ts @@ -2,7 +2,7 @@ import { Effect } from "effect" import type { DatabaseMigration } from "../migration" export default { - id: "20260604234609_add_session_context_snapshot", + id: "20260605003541_add_session_context_snapshot", up(tx) { return Effect.gen(function* () { yield* tx.run(` diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 8ae993967f..1362bf1e41 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -155,7 +155,6 @@ export interface Interface { readonly aggregateID: string readonly after?: Cursor }) => Stream.Stream - readonly sequence: (aggregateID: string) => Effect.Effect readonly sync: (handler: Sync) => Effect.Effect readonly listen: (listener: Listener) => Effect.Effect readonly beforeCommit: (guard: CommitGuard) => Effect.Effect @@ -337,9 +336,6 @@ export const layerWith = (options?: LayerOptions) => yield* projector({ ...event, seq } as Payload) } if (commit) yield* commit(seq) - const encoded = syncRegistry - .get(versionedType(definition.type, sync.version))! - .encode(event.data) yield* db .insert(EventSequenceTable) .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 if (!durable && options?.commit) 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) { const committed = yield* commitSyncEvent(event as Payload, undefined, options?.commit) @@ -438,14 +437,17 @@ export const layerWith = (options?: LayerOptions) => (serviceLocation ? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID } : undefined) - return yield* publishEvent({ - id: options?.id ?? ID.create(), - ...(options?.metadata ? { metadata: options.metadata } : {}), - type: definition.type, - ...(definition.sync === undefined ? {} : { version: definition.sync.version }), - ...(location ? { location } : {}), - data, - } as Payload, options) + return yield* publishEvent( + { + id: options?.id ?? ID.create(), + ...(options?.metadata ? { metadata: options.metadata } : {}), + type: definition.type, + ...(definition.sync === undefined ? {} : { version: definition.sync.version }), + ...(location ? { location } : {}), + data, + } as Payload, + options, + ) }) } @@ -534,14 +536,6 @@ export const layerWith = (options?: LayerOptions) => .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 = (definition: D): Stream.Stream> => Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe( Stream.map((event) => event as Payload), @@ -669,7 +663,6 @@ export const layerWith = (options?: LayerOptions) => subscribe, all: streamAll, aggregateEvents: streamEvents, - sequence, sync, listen, beforeCommit, diff --git a/packages/core/src/instruction-context.ts b/packages/core/src/instruction-context.ts index c764c13067..be258e2a1c 100644 --- a/packages/core/src/instruction-context.ts +++ b/packages/core/src/instruction-context.ts @@ -15,6 +15,7 @@ class File extends Schema.Class("InstructionContext.File")({ }) {} const Files = Schema.Array(File) +const key = SystemContext.Key.make("core/instructions") export const layer = Layer.effectDiscard( Effect.gen(function* () { @@ -25,7 +26,7 @@ export const layer = Layer.effectDiscard( const source = (value: ReadonlyArray | SystemContext.Unavailable) => SystemContext.make({ - key: SystemContext.Key.make("core/instructions"), + key, codec: Schema.toCodecJson(Files), load: Effect.succeed(value), baseline: render, @@ -43,29 +44,37 @@ export const layer = Layer.effectDiscard( const files = yield* Effect.forEach( paths, (path) => - fs.readFileStringSafe(path).pipe( - Effect.map((content) => (content === undefined ? undefined : new File({ path: AbsolutePath.make(path), content }))), - ), + fs + .readFileStringSafe(path) + .pipe( + Effect.map((content) => + content === undefined ? undefined : new File({ path: AbsolutePath.make(path), content }), + ), + ), { concurrency: "unbounded" }, ) - if (files.some((file, index) => file === undefined && discovered.has(paths[index]))) return SystemContext.unavailable + if (files.some((file, index) => file === undefined && discovered.has(paths[index]))) + return SystemContext.unavailable return files.filter((file): file is File => file !== undefined) }) yield* registry.contribute({ - key: "core/instructions", + key, load: observe().pipe( 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.catchDefect(() => Effect.succeed(source(SystemContext.unavailable))), ), }) }), ) -export const locationLayer = layer - function render(files: ReadonlyArray) { return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n") } diff --git a/packages/core/src/session/context-epoch.ts b/packages/core/src/session/context-epoch.ts index 7374b58eff..06465771ef 100644 --- a/packages/core/src/session/context-epoch.ts +++ b/packages/core/src/session/context-epoch.ts @@ -7,13 +7,51 @@ import { EventV2 } from "../event" import { SystemContext } from "../system-context" import { SystemContextRegistry } from "../system-context-registry" import { SessionEvent } from "./event" +import { SessionInput } from "./input" import { SessionMessageID } from "./message-id" import { SessionSchema } from "./schema" import { SessionContextEpochTable } from "./sql" type DatabaseService = Database.Interface["db"] -export const prepare = Effect.fn("SessionContextEpoch.prepare")(function* ( +class RevisionMismatch extends Error {} + +const retryRevisionMismatch = (attempt: () => Effect.Effect): Effect.Effect => + 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 { + 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 { + return retryRevisionMismatch(() => prepareOnce(db, events, context, sessionID)).pipe( + Effect.withSpan("SessionContextEpoch.prepare"), + ) +} + +const prepareOnce = Effect.fnUntraced(function* ( db: DatabaseService, events: EventV2.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" }) if (!stored) { 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 } } const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe(Effect.orDie) const result = - stored.replacement_seq === null ? yield* SystemContext.reconcile(value, snapshot) : yield* SystemContext.replace(value, snapshot) + stored.replacement_seq === null + ? yield* SystemContext.reconcile(value, snapshot) + : yield* SystemContext.replace(value, snapshot) if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked") return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } - if (result._tag === "Replaced") { - const replacementSeq = stored.replacement_seq ?? (yield* events.sequence(sessionID)) + if (result._tag === "ReplacementReady") { + const replacementSeq = stored.replacement_seq ?? (yield* SessionInput.latestSeq(db, sessionID)) yield* replace(db, sessionID, stored.revision, replacementSeq, result.generation) return { baseline: result.generation.baseline, baselineSeq: replacementSeq } } @@ -45,6 +85,28 @@ export const prepare = Effect.fn("SessionContextEpoch.prepare")(function* ( 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) { return yield* db .select() @@ -73,9 +135,8 @@ export const requestReplacement = Effect.fn("SessionContextEpoch.requestReplacem .pipe(Effect.orDie) }) -const initialize = Effect.fnUntraced(function* ( +const insert = Effect.fnUntraced(function* ( db: DatabaseService, - events: EventV2.Interface, sessionID: SessionSchema.ID, generation: SystemContext.Generation, ) { @@ -83,7 +144,7 @@ const initialize = Effect.fnUntraced(function* ( .transaction( () => Effect.gen(function* () { - const baselineSeq = yield* events.sequence(sessionID) + const baselineSeq = yield* SessionInput.latestSeq(db, sessionID) yield* db .insert(SessionContextEpochTable) .values({ @@ -93,8 +154,13 @@ const initialize = Effect.fnUntraced(function* ( baseline_seq: baselineSeq, revision: 0, }) - .run() - .pipe(Effect.orDie) + .onConflictDoNothing() + .returning({ sessionID: SessionContextEpochTable.session_id }) + .get() + .pipe( + Effect.orDie, + Effect.flatMap((inserted) => (inserted ? Effect.void : Effect.die(new RevisionMismatch()))), + ) return baselineSeq }), { behavior: "immediate" }, @@ -109,33 +175,22 @@ const replace = Effect.fnUntraced(function* ( baselineSeq: number, generation: SystemContext.Generation, ) { - yield* db - .transaction( - () => - Effect.gen(function* () { - const updated = yield* db - .update(SessionContextEpochTable) - .set({ - baseline: generation.baseline, - snapshot: generation.snapshot, - baseline_seq: baselineSeq, - replacement_seq: null, - revision: expectedRevision + 1, - }) - .where( - and( - eq(SessionContextEpochTable.session_id, sessionID), - eq(SessionContextEpochTable.revision, expectedRevision), - ), - ) - .returning({ revision: SessionContextEpochTable.revision }) - .get() - .pipe(Effect.orDie) - if (!updated) return yield* Effect.die("Session context epoch revision mismatch") - }), - { behavior: "immediate" }, + const updated = yield* db + .update(SessionContextEpochTable) + .set({ + baseline: generation.baseline, + snapshot: generation.snapshot, + baseline_seq: baselineSeq, + replacement_seq: null, + revision: expectedRevision + 1, + }) + .where( + and(eq(SessionContextEpochTable.session_id, sessionID), eq(SessionContextEpochTable.revision, expectedRevision)), ) + .returning({ revision: SessionContextEpochTable.revision }) + .get() .pipe(Effect.orDie) + if (!updated) return yield* Effect.die(new RevisionMismatch()) }) const advance = Effect.fnUntraced(function* ( @@ -157,5 +212,5 @@ const advance = Effect.fnUntraced(function* ( .returning({ revision: SessionContextEpochTable.revision }) .get() .pipe(Effect.orDie) - if (!updated) return yield* Effect.die("Session context epoch revision mismatch") + if (!updated) return yield* Effect.die(new RevisionMismatch()) }) diff --git a/packages/core/src/session/context.ts b/packages/core/src/session/context.ts index 1b41c24fe4..3148dbb8fe 100644 --- a/packages/core/src/session/context.ts +++ b/packages/core/src/session/context.ts @@ -75,10 +75,7 @@ export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseServ ], { concurrency: "unbounded" }, ) - return yield* Effect.forEach( - yield* messageRows(db, sessionID, compaction, epoch?.baselineSeq), - decodeMessageRow, - ) + return yield* Effect.forEach(yield* messageRows(db, sessionID, compaction, epoch?.baselineSeq), decodeMessageRow) }) export const loadForRunner = Effect.fn("SessionContext.loadForRunner")(function* ( @@ -86,7 +83,10 @@ export const loadForRunner = Effect.fn("SessionContext.loadForRunner")(function* sessionID: SessionSchema.ID, 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" diff --git a/packages/core/src/session/message.ts b/packages/core/src/session/message.ts index 3a379e72d3..98360cd936 100644 --- a/packages/core/src/session/message.ts +++ b/packages/core/src/session/message.ts @@ -176,7 +176,16 @@ export class Compaction extends Schema.Class("Session.Message.Compac ...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")) .annotate({ identifier: "Session.Message" }) diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 51da5b245e..4d7e07c90b 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -422,7 +422,9 @@ export const layer = Layer.effectDiscard( ) yield* events.project(SessionEvent.ContextUpdated, (event) => { if (!event.replay || event.seq === undefined) return run(db, event) - return run(db, event).pipe(Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq))) + 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.Shell.Started, (event) => run(db, event)) diff --git a/packages/core/src/session/runner/index.ts b/packages/core/src/session/runner/index.ts index fe17284b8a..cfdc6d8943 100644 --- a/packages/core/src/session/runner/index.ts +++ b/packages/core/src/session/runner/index.ts @@ -5,6 +5,7 @@ import { Context, Effect, Schema } from "effect" import { SessionSchema } from "../schema" import type { MessageDecodeError } from "../error" import { SessionRunnerModel } from "./model" +import type { SystemContext } from "../../system-context" export class StepLimitExceededError extends Schema.TaggedErrorClass()( "SessionRunner.StepLimitExceededError", @@ -14,7 +15,12 @@ export class StepLimitExceededError extends Schema.TaggedErrorClass() let needsContinuation = false @@ -150,7 +151,7 @@ export const layer = Layer.effect( 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 request = LLM.request({ model, diff --git a/packages/core/src/system-context-builtins.ts b/packages/core/src/system-context-builtins.ts index f1ff09bc91..8e74788e75 100644 --- a/packages/core/src/system-context-builtins.ts +++ b/packages/core/src/system-context-builtins.ts @@ -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) }) }), ) diff --git a/packages/core/src/system-context-registry.ts b/packages/core/src/system-context-registry.ts index 65c9b11a19..3e28f30c6e 100644 --- a/packages/core/src/system-context-registry.ts +++ b/packages/core/src/system-context-registry.ts @@ -4,7 +4,7 @@ import { Context, Effect, Layer, Ref, Scope } from "effect" import { SystemContext } from "./system-context" export interface Contribution { - readonly key: string + readonly key: SystemContext.Key readonly load: Effect.Effect } @@ -36,7 +36,7 @@ export const layer = Layer.effect( ) }), 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( yield* Effect.forEach(current, (contribution) => contribution.load, { concurrency: "unbounded" }), ) @@ -44,5 +44,3 @@ export const layer = Layer.effect( }) }), ) - -export const locationLayer = layer diff --git a/packages/core/src/system-context.ts b/packages/core/src/system-context.ts index b69053d9bd..739305bfeb 100644 --- a/packages/core/src/system-context.ts +++ b/packages/core/src/system-context.ts @@ -67,8 +67,8 @@ export interface Updated { readonly snapshot: Snapshot } -export interface Replaced { - readonly _tag: "Replaced" +export interface ReplacementReady { + readonly _tag: "ReplacementReady" readonly generation: Generation } @@ -76,9 +76,14 @@ export interface ReplacementBlocked { readonly _tag: "ReplacementBlocked" } -export type ReplacementResult = Replaced | ReplacementBlocked +export type ReplacementResult = ReplacementReady | ReplacementBlocked export type ReconcileResult = { readonly _tag: "Unchanged" } | Updated | ReplacementResult +export class InitializationBlocked extends Schema.TaggedErrorClass()( + "SystemContext.InitializationBlocked", + { keys: Schema.Array(Key) }, +) {} + export class DuplicateKeyError extends Schema.TaggedErrorClass()("SystemContext.DuplicateKeyError", { key: Key, }) { @@ -186,8 +191,14 @@ const observe = (value: SystemContext) => ) /** Creates the immutable baseline and durable snapshot for a new generation. */ -export function initialize(value: SystemContext): Effect.Effect { - return observe(value).pipe(Effect.map(initializeObservation)) +export function initialize(value: SystemContext): Effect.Effect { + 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): Generation { @@ -272,7 +283,7 @@ export function replace(value: SystemContext, previous: Snapshot): Effect.Effect function replaceObservation(entries: ReadonlyArray, previous: Snapshot): ReplacementResult { if (entries.some((entry) => entry._tag === "Unavailable" && getSnapshot(previous, entry.key) !== undefined)) return { _tag: "ReplacementBlocked" } - return { _tag: "Replaced", generation: initializeObservation(entries) } + return { _tag: "ReplacementReady", generation: initializeObservation(entries) } } function context(sources: ReadonlyArray): SystemContext { diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index d840c2c6d8..2cd5510be7 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect } from "bun:test" import { $ } from "bun" import { fileURLToPath } from "url" import path from "path" @@ -7,6 +7,7 @@ import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" import { Effect, Layer } from "effect" import { eq, inArray, sql } from "drizzle-orm" 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 normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths" 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 { SessionTable } from "@opencode-ai/core/session/sql" 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 { tmpdir } from "./fixture/tmpdir" - -const run = (effect: Effect.Effect) => - Effect.runPromise( - effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped), - ) +import { testEffect } from "./lib/effect" const makeDb = EffectDrizzleSqlite.makeWithDefaults() +const it = testEffect(SqliteClient.layer({ filename: ":memory:", disableWAL: true })) describe("DatabaseMigration", () => { - test("serializes concurrent embedded initialization for one database path", async () => { - await using tmp = await tmpdir() - const filename = path.join(tmp.path, "embedded.sqlite") - const layers = [Database.layerFromPath(filename), Database.layerFromPath(filename)] + it.effect("serializes concurrent embedded initialization for one database path", () => + Effect.promise(async () => { + await using tmp = await tmpdir() + const filename = path.join(tmp.path, "embedded.sqlite") + const layers = [Database.layerFromPath(filename), Database.layerFromPath(filename)] - await Effect.runPromise( - Effect.all( - layers.map((layer) => Effect.scoped(Layer.build(layer))), - { concurrency: "unbounded" }, - ), - ) - }) + await Effect.runPromise( + Effect.all( + layers.map((layer) => Effect.scoped(Layer.build(layer))), + { concurrency: "unbounded" }, + ), + ) + }), + ) if (process.platform === "linux") { - test("declared schema has no ungenerated migrations", async () => { - const result = await $`bun ${fileURLToPath(new URL("../script/migration.ts", import.meta.url))} --check` - .quiet() - .nothrow() - expect(result.exitCode, result.stderr.toString()).toBe(0) - expect(result.stdout.toString()).toContain("No schema changes, nothing to migrate") - }, 30_000) + it.effect( + "declared schema has no ungenerated migrations", + () => + Effect.promise(async () => { + const result = await $`bun ${fileURLToPath(new URL("../script/migration.ts", import.meta.url))} --check` + .quiet() + .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 () => { - await run( - Effect.gen(function* () { + it.effect("applies tracked migrations to an empty database", () => + Effect.gen(function* () { const db = yield* makeDb yield* DatabaseMigration.apply(db) @@ -66,10 +69,7 @@ describe("DatabaseMigration", () => { expect( yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_context_epoch'`), ).toEqual({ name: "session_context_epoch" }) - expect( - yield* db.get(sql`SELECT 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(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length }) expect( 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`, @@ -84,13 +84,11 @@ describe("DatabaseMigration", () => { { name: "session_message_session_time_created_id_idx" }, { name: "session_message_session_type_seq_idx" }, ]) - }), - ) - }) + }), + ) - test("resets beta history and rebuilds event-sourced Session input storage", async () => { - await run( - Effect.gen(function* () { + it.effect("resets beta history and rebuilds event-sourced Session input storage", () => + Effect.gen(function* () { const db = yield* makeDb 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)`) @@ -160,13 +158,11 @@ describe("DatabaseMigration", () => { expect.objectContaining({ name: "session_input_session_promoted_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 () => { - await run( - Effect.gen(function* () { + it.effect("resets incompatible projected Session messages before adding sequence order", () => + Effect.gen(function* () { const db = yield* makeDb yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`) 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, '{}')`, ) 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 () => { - await run( - Effect.gen(function* () { + it.effect("runs session usage backfill in order with schema changes", () => + Effect.gen(function* () { 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 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_write: 6, }) - }), - ) - }) + }), + ) - test("normalizes Windows storage paths and leaves POSIX paths untouched", async () => { - await run( - Effect.gen(function* () { + it.effect("normalizes Windows storage paths and leaves POSIX paths untouched", () => + Effect.gen(function* () { 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 session (id text PRIMARY KEY, directory text NOT NULL, path text)`) @@ -295,14 +287,12 @@ describe("DatabaseMigration", () => { directory: "/home/me/we\\ird", path: "src\\weird", }) - }), - ) - }) + }), + ) - test("maps native Windows paths through database columns", async () => { - if (process.platform !== "win32") return - await run( - Effect.gen(function* () { + it.effect("maps native Windows paths through database columns", () => { + if (process.platform !== "win32") return Effect.void + return Effect.gen(function* () { const db = yield* makeDb yield* DatabaseMigration.apply(db) const projectID = ProjectV2.ID.make("codec_project") @@ -405,13 +395,11 @@ describe("DatabaseMigration", () => { expect(() => Effect.runSync(db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get()), ).toThrow() - }), - ) + }) }) - test("imports existing drizzle migration state", async () => { - await run( - Effect.gen(function* () { + it.effect("imports existing drizzle migration state", () => + Effect.gen(function* () { const db = yield* makeDb yield* db.run( 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, []) 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 () => { - await run( - Effect.gen(function* () { + it.effect("does not replay a migrated session metadata column", () => + Effect.gen(function* () { const db = yield* makeDb yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`) yield* db.run( @@ -444,13 +430,11 @@ describe("DatabaseMigration", () => { yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration]) expect(yield* db.all(sql`SELECT id FROM migration`)).toEqual([{ id: "20260511173437_session-metadata" }]) - }), - ) - }) + }), + ) - test("accepts the temporary replacement session metadata migration id", async () => { - await run( - Effect.gen(function* () { + it.effect("accepts the temporary replacement session metadata migration id", () => + Effect.gen(function* () { const db = yield* makeDb 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)`) @@ -462,13 +446,11 @@ describe("DatabaseMigration", () => { { id: "20260511173437_session-metadata" }, { id: "20260530232709_lovely_romulus" }, ]) - }), - ) - }) + }), + ) - test("skips drizzle import when migration table already has state", async () => { - await run( - Effect.gen(function* () { + it.effect("skips drizzle import when migration table already has state", () => + Effect.gen(function* () { const db = yield* makeDb 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)`) @@ -483,7 +465,6 @@ describe("DatabaseMigration", () => { yield* DatabaseMigration.applyOnly(db, []) expect(yield* db.all(sql`SELECT id FROM migration ORDER BY id`)).toEqual([{ id: "existing" }]) - }), - ) - }) + }), + ) }) diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index 9b454333b4..cd8ba69253 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -194,11 +194,12 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const received = new Array() + const aggregateID = EventV2.ID.create() yield* events.project(SyncMessage, () => Effect.sync(() => received.push("projector"))) yield* events.publish( SyncMessage, - { id: "one", text: "hello" }, + { id: aggregateID, text: "hello" }, { commit: (seq) => Effect.sync(() => received.push(`commit:${seq}`)) }, ) @@ -224,7 +225,9 @@ describe("EventV2", () => { expect(String(exit)).toContain("commit failed") expect(yield* db.all("SELECT value FROM event_commit_probe")).toEqual([]) expect(yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all()).toEqual([]) - expect(yield* db.select().from(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).all()).toEqual([]) + expect( + yield* db.select().from(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).all(), + ).toEqual([]) }), ) diff --git a/packages/core/test/instruction-context.test.ts b/packages/core/test/instruction-context.test.ts index 2eea3586fb..9b1657caac 100644 --- a/packages/core/test/instruction-context.test.ts +++ b/packages/core/test/instruction-context.test.ts @@ -117,7 +117,9 @@ describe("InstructionContext", () => { const failingFS = Layer.effect( FSUtil.Service, 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)) const context = yield* SystemContextRegistry.Service.pipe( @@ -126,10 +128,7 @@ describe("InstructionContext", () => { Effect.provide(failingFS), Effect.provide(Global.layerWith({ config: "/global" })), Effect.provide( - Layer.succeed( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make("/repo") })), - ), + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))), ), ) @@ -165,10 +164,7 @@ describe("InstructionContext", () => { Effect.provide(racingFS), Effect.provide(Global.layerWith({ config: "/global" })), Effect.provide( - Layer.succeed( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make("/repo") })), - ), + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))), ), ) diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 854f24158f..66c90583ab 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -16,375 +16,388 @@ const id = (value: string) => SessionMessage.ID.make(`msg_${value}`) const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route }) describe("toLLMMessages", () => { - it.effect("maps every top-level V2 Session message type", () => Effect.sync(() => { - const file = new FileAttachment({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" }) - 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( + it.effect("maps every top-level V2 Session message type", () => + Effect.sync(() => { + const file = new FileAttachment({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" }) + const reference = new ReferenceAttachment({ name: "docs", kind: "local", uri: "file:///docs" }) + const messages = 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, - ), - ).toEqual([ - Message.system("Updated context\n\nOther context"), - ]) - })) + ) - it.effect("expands assistant tool calls and settled outcomes into canonical tool messages", () => Effect.sync(() => { - const messages = toLLMMessages( - [ - new SessionMessage.Assistant({ - id: id("assistant"), - type: "assistant", - agent: "build", - model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, + expect(messages.map((message) => message.role)).toEqual(["user", "user", "user", "user"]) + expect(messages[0]).toEqual( + Message.make({ + id: id("user"), + role: "user", content: [ - new SessionMessage.AssistantText({ type: "text", id: "text-1", text: "Checking" }), - new SessionMessage.AssistantReasoning({ - type: "reasoning", - id: "reasoning-1", - text: "Think", - providerMetadata: { anthropic: { signature: "sig_1" } }, - }), - new SessionMessage.AssistantTool({ - type: "tool", - id: "pending", - name: "read", - state: new SessionMessage.ToolStatePending({ status: "pending", input: '{"path":"README.md"}' }), + { 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.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, - ) + model, + ), + ).toEqual([Message.system("Updated context\n\nOther context")]) + }), + ) - 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", - id: "hosted", - name: "web_search", - input: { query: "Effect" }, - providerExecuted: true, - providerMetadata: { fake: { continuation: "hosted-call" } }, - }, - { - 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: {} }, + it.effect("expands assistant tool calls and settled outcomes into canonical tool messages", () => + Effect.sync(() => { + const messages = toLLMMessages( + [ + new SessionMessage.Assistant({ + id: id("assistant"), + type: "assistant", + agent: "build", + model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, + content: [ + new SessionMessage.AssistantText({ type: "text", id: "text-1", text: "Checking" }), + new SessionMessage.AssistantReasoning({ + type: "reasoning", + id: "reasoning-1", + text: "Think", + providerMetadata: { anthropic: { signature: "sig_1" } }, + }), + new SessionMessage.AssistantTool({ + type: "tool", + id: "pending", + name: "read", + state: new SessionMessage.ToolStatePending({ status: "pending", input: '{"path":"README.md"}' }), + 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, + ) + + 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" }, }, - }, - ]) - 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" }, - ], + { + type: "tool-call", + id: "hosted", + name: "web_search", + input: { query: "Effect" }, + providerExecuted: true, + providerMetadata: { fake: { continuation: "hosted-call" } }, }, - }, - ]) - })) + { + 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(() => { - const messages = toLLMMessages( - [ - new SessionMessage.Assistant({ - id: id("assistant-openai-reasoning"), - type: "assistant", - agent: "build", - model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, - content: [ - new SessionMessage.AssistantReasoning({ - type: "reasoning", - id: "reasoning-openai", - 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" } }, + it.effect("restores OpenAI encrypted reasoning metadata", () => + Effect.sync(() => { + const messages = toLLMMessages( + [ + new SessionMessage.Assistant({ + id: id("assistant-openai-reasoning"), + type: "assistant", + agent: "build", + model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, + content: [ + new SessionMessage.AssistantReasoning({ + type: "reasoning", + id: "reasoning-openai", + text: "Think", + providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, }), - 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, - ) + ], + 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, - }, - ]) - })) + 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 }, + }), + 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, + }, + ]) + }), + ) }) diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index edd244e432..99410e6efd 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -57,14 +57,15 @@ const model = OpenAIChat.route }) .model({ id: "gpt-4o-mini" }) const models = SessionRunnerModel.layerWith(() => Effect.succeed(model)) +const systemContextKey = SystemContext.Key.make("test/context") const systemContext = Layer.effectDiscard( SystemContextRegistry.Service.pipe( Effect.flatMap((registry) => registry.contribute({ - key: "test/context", + key: systemContextKey, load: Effect.succeed( SystemContext.make({ - key: SystemContext.Key.make("test/context"), + key: systemContextKey, codec: Schema.toCodecJson(Schema.String), load: Effect.succeed("Recorded context"), baseline: String, diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index ef8b8617c8..10a87130be 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -32,13 +32,18 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" 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 { SystemContext } from "@opencode-ai/core/system-context" import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry" import { ModelV2 } from "@opencode-ai/core/model" 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 { testEffect } from "./lib/effect" @@ -145,11 +150,12 @@ const systemContextKey = SystemContext.Key.make("test/context") let systemBaseline = "Initial context" let systemRemoved = false let systemUnavailable = false +let systemLoadHook = Effect.void const systemContext = Layer.effectDiscard( SystemContextRegistry.Service.pipe( Effect.flatMap((registry) => registry.contribute({ - key: "test/context", + key: systemContextKey, load: Effect.sync(() => SystemContext.combine( systemRemoved @@ -158,7 +164,11 @@ const systemContext = Layer.effectDiscard( SystemContext.make({ key: systemContextKey, 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, update: (_previous, current) => current, removed: () => "System context source removed: test/context", @@ -240,6 +250,7 @@ const setup = Effect.gen(function* () { systemBaseline = "Initial context" systemRemoved = false systemUnavailable = false + systemLoadHook = Effect.void responses = undefined streamFailure = 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", () => Effect.gen(function* () { yield* setup @@ -622,6 +666,7 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) yield* events.publish(SessionEvent.ModelSwitched, { sessionID, + messageID: SessionMessage.ID.create(), timestamp: DateTime.makeUnsafe(1), model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, }) @@ -661,6 +706,7 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) yield* events.publish(SessionEvent.ModelSwitched, { sessionID, + messageID: SessionMessage.ID.create(), timestamp: DateTime.makeUnsafe(1), model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, }) @@ -692,15 +738,17 @@ describe("SessionRunnerLLM", () => { yield* events.publish(SessionEvent.ModelSwitched, { sessionID, + messageID: SessionMessage.ID.create(), timestamp: DateTime.makeUnsafe(1), model: { id: ModelV2.ID.make("replacement-1"), providerID: ProviderV2.ID.make("fake") }, }) yield* events.publish(SessionEvent.ModelSwitched, { sessionID, + messageID: SessionMessage.ID.create(), timestamp: DateTime.makeUnsafe(2), model: { id: ModelV2.ID.make("replacement-2"), providerID: ProviderV2.ID.make("fake") }, }) - const latest = yield* events.sequence(sessionID) + const latest = yield* SessionInput.latestSeq(db, sessionID) expect( 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", () => Effect.gen(function* () { yield* setup @@ -728,6 +810,7 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) yield* events.publish(SessionEvent.ModelSwitched, { sessionID, + messageID: SessionMessage.ID.create(), timestamp: DateTime.makeUnsafe(1), model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, }) @@ -752,6 +835,7 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) yield* events.publish(SessionEvent.ModelSwitched, { sessionID, + messageID: SessionMessage.ID.create(), timestamp: DateTime.makeUnsafe(1), model: { id: ModelV2.ID.make("replacement-1"), providerID: ProviderV2.ID.make("fake") }, }) @@ -760,6 +844,7 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) yield* events.publish(SessionEvent.ModelSwitched, { sessionID, + messageID: SessionMessage.ID.create(), timestamp: DateTime.makeUnsafe(2), model: { id: ModelV2.ID.make("replacement-2"), providerID: ProviderV2.ID.make("fake") }, }) @@ -784,6 +869,7 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) yield* events.publish(SessionEvent.Compaction.Started, { sessionID, + messageID: SessionMessage.ID.create(), timestamp: DateTime.makeUnsafe(1), reason: "manual", }) @@ -821,6 +907,7 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) yield* events.publish(SessionEvent.Compaction.Started, { sessionID, + messageID: SessionMessage.ID.create(), timestamp: DateTime.makeUnsafe(1), reason: "manual", }) @@ -834,7 +921,16 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Initial context"]) - expect(requests.at(-1)?.messages.some((message) => message.role === "system" && message.content[0]?.type === "text" && message.content[0].text === "Changed context")).toBe(true) + 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* events.publish(SessionEvent.ModelSwitched, { sessionID, + messageID: SessionMessage.ID.create(), timestamp: DateTime.makeUnsafe(1), model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, }) diff --git a/packages/core/test/system-context-registry.test.ts b/packages/core/test/system-context-registry.test.ts index b1191cb065..9625276f1e 100644 --- a/packages/core/test/system-context-registry.test.ts +++ b/packages/core/test/system-context-registry.test.ts @@ -5,7 +5,7 @@ import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry import { testEffect } from "./lib/effect" const contribution = (key: string, text: string, sourceKey = key) => ({ - key, + key: SystemContext.Key.make(key), load: Effect.succeed( SystemContext.make({ key: SystemContext.Key.make(sourceKey), @@ -43,7 +43,7 @@ describe("SystemContextRegistry", () => { const registry = yield* SystemContextRegistry.Service let loads = 0 yield* registry.contribute({ - key: "test/dynamic", + key: SystemContext.Key.make("test/dynamic"), load: Effect.sync(() => { loads++ return SystemContext.empty @@ -61,7 +61,7 @@ describe("SystemContextRegistry", () => { Effect.gen(function* () { const registry = yield* SystemContextRegistry.Service 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) diff --git a/packages/core/test/system-context.test.ts b/packages/core/test/system-context.test.ts index 6aa7cb1470..a20f8977b3 100644 --- a/packages/core/test/system-context.test.ts +++ b/packages/core/test/system-context.test.ts @@ -125,16 +125,21 @@ describe("SystemContext", () => { expect(yield* SystemContext.reconcile(context, previous)).toEqual({ _tag: "Unchanged" }) expect(yield* SystemContext.replace(context, previous)).toEqual({ _tag: "ReplacementBlocked" }) - expect(yield* SystemContext.replace(context, {})).toMatchObject({ _tag: "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* () { - expect(yield* SystemContext.initialize(stringContext({ key: "core/remote", value: SystemContext.unavailable }))).toEqual({ - baseline: "", - snapshot: {}, - }) + const exit = yield* SystemContext.initialize( + stringContext({ key: "core/remote", value: SystemContext.unavailable }), + ).pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) + expect(Cause.squash(exit.cause)).toEqual( + new SystemContext.InitializationBlocked({ keys: [key("core/remote")] }), + ) }), ) @@ -154,8 +159,10 @@ describe("SystemContext", () => { it.effect("requests replacement when a source without removal text disappears", () => Effect.gen(function* () { - expect(yield* SystemContext.reconcile(SystemContext.empty, { "core/date": { value: "2026-06-04" } })).toMatchObject({ - _tag: "Replaced", + expect( + 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" }), { "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({ - _tag: "Replaced", + _tag: "ReplacementReady", generation: { baseline: "2026-06-04" }, }) expect(loads).toBe(1) @@ -234,7 +241,7 @@ describe("SystemContext", () => { "core/date": { value: "2026-06-03" }, "core/location": { value: 42 }, }), - ).toMatchObject({ _tag: "Replaced" }) + ).toMatchObject({ _tag: "ReplacementReady" }) expect(updates).toBe(0) }), ) diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-haiku-4-5-chronological-system-update.json b/packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-haiku-4-5-chronological-system-update.json index 2708b2ec6f..72e4d7b21b 100644 --- a/packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-haiku-4-5-chronological-system-update.json +++ b/packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-haiku-4-5-chronological-system-update.json @@ -7,13 +7,7 @@ "route": "anthropic-messages", "transport": "http", "model": "claude-haiku-4-5-20251001", - "tags": [ - "prefix:anthropic-messages", - "provider:anthropic", - "system", - "chronological-system-update", - "golden" - ] + "tags": ["prefix:anthropic-messages", "provider:anthropic", "system", "chronological-system-update", "golden"] }, "interactions": [ { diff --git a/packages/llm/test/fixtures/recordings/gemini/gemini-2-5-flash-chronological-system-update.json b/packages/llm/test/fixtures/recordings/gemini/gemini-2-5-flash-chronological-system-update.json index e7bb189294..c4fd142fc7 100644 --- a/packages/llm/test/fixtures/recordings/gemini/gemini-2-5-flash-chronological-system-update.json +++ b/packages/llm/test/fixtures/recordings/gemini/gemini-2-5-flash-chronological-system-update.json @@ -7,13 +7,7 @@ "route": "gemini", "transport": "http", "model": "gemini-2.5-flash", - "tags": [ - "prefix:gemini", - "provider:google", - "system", - "chronological-system-update", - "golden" - ] + "tags": ["prefix:gemini", "provider:google", "system", "chronological-system-update", "golden"] }, "interactions": [ { diff --git a/packages/llm/test/fixtures/recordings/openai-chat/openai-chat-gpt-4o-mini-chronological-system-update.json b/packages/llm/test/fixtures/recordings/openai-chat/openai-chat-gpt-4o-mini-chronological-system-update.json index 6d002e4b94..4c01f328d7 100644 --- a/packages/llm/test/fixtures/recordings/openai-chat/openai-chat-gpt-4o-mini-chronological-system-update.json +++ b/packages/llm/test/fixtures/recordings/openai-chat/openai-chat-gpt-4o-mini-chronological-system-update.json @@ -7,13 +7,7 @@ "route": "openai-chat", "transport": "http", "model": "gpt-4o-mini", - "tags": [ - "prefix:openai-chat", - "provider:openai", - "system", - "chronological-system-update", - "golden" - ] + "tags": ["prefix:openai-chat", "provider:openai", "system", "chronological-system-update", "golden"] }, "interactions": [ { diff --git a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx index 57df7c3935..31ff7d7478 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx @@ -177,7 +177,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( break case "session.next.context.updated": update(event.properties.sessionID, (draft) => { - draft.unshift({ + prepend(draft, { id: event.properties.messageID, type: "system", text: event.properties.text, diff --git a/packages/opencode/test/cli/tui/sync-v2.test.tsx b/packages/opencode/test/cli/tui/sync-v2.test.tsx index 1b995d2943..a408e38e74 100644 --- a/packages/opencode/test/cli/tui/sync-v2.test.tsx +++ b/packages/opencode/test/cli/tui/sync-v2.test.tsx @@ -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 + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + sync = useSyncV2() + onMount(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + )) + + 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 () => { const events = createEventSource() const response = Promise.withResolvers() @@ -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() + const calls = createFetch((url) => { + if (url.pathname === "/api/session/session-1/message") return response.promise + return undefined + }) + let sync!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + sync = useSyncV2() + onMount(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + )) + + 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 () => { const events = createEventSource() const response = Promise.withResolvers() diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index f3a9cca90a..69ab21185f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -21,6 +21,7 @@ export type Event = | EventSessionNextPrompted | EventSessionNextPromptAdmitted | EventSessionNextPromptPromoted + | EventSessionNextContextUpdated | EventSessionNextSynthetic | EventSessionNextShellStarted | EventSessionNextShellEnded @@ -867,6 +868,16 @@ export type GlobalEvent = { timeCreated: number } } + | { + id: string + type: "session.next.context.updated" + properties: { + timestamp: number + sessionID: string + messageID: string + text: string + } + } | { id: string type: "session.next.synthetic" @@ -1615,6 +1626,7 @@ export type GlobalEvent = { | SyncEventSessionNextPrompted | SyncEventSessionNextPromptAdmitted | SyncEventSessionNextPromptPromoted + | SyncEventSessionNextContextUpdated | SyncEventSessionNextSynthetic | SyncEventSessionNextShellStarted | 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 = { type: "sync" id: string @@ -3822,6 +3851,18 @@ export type SessionMessageSynthetic = { type: "synthetic" } +export type SessionMessageSystem = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + type: "system" + text: string +} + export type SessionMessageShell = { id: string metadata?: { @@ -3980,6 +4021,7 @@ export type SessionMessage = | SessionMessageModelSwitched | SessionMessageUser | SessionMessageSynthetic + | SessionMessageSystem | SessionMessageShell | SessionMessageAssistant | 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 = { id: string type: "session.next.synthetic" diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 0a3d5fac38..6e350a71e2 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -12026,6 +12026,9 @@ { "$ref": "#/components/schemas/EventSessionNextPromptPromoted" }, + { + "$ref": "#/components/schemas/EventSessionNextContextUpdated" + }, { "$ref": "#/components/schemas/EventSessionNextSynthetic" }, @@ -14621,6 +14624,42 @@ "required": ["id", "type", "properties"], "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", "properties": { @@ -17142,6 +17181,9 @@ { "$ref": "#/components/schemas/SyncEventSessionNextPromptPromoted" }, + { + "$ref": "#/components/schemas/SyncEventSessionNextContextUpdated" + }, { "$ref": "#/components/schemas/SyncEventSessionNextSynthetic" }, @@ -21870,6 +21912,63 @@ "required": ["type", "id", "syncEvent"], "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": { "type": "object", "properties": { @@ -23634,6 +23733,37 @@ "required": ["id", "time", "sessionID", "text", "type"], "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": { "type": "object", "properties": { @@ -24071,6 +24201,9 @@ { "$ref": "#/components/schemas/SessionMessageSynthetic" }, + { + "$ref": "#/components/schemas/SessionMessageSystem" + }, { "$ref": "#/components/schemas/SessionMessageShell" }, @@ -25174,6 +25307,42 @@ "required": ["id", "type", "properties"], "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": { "type": "object", "properties": { diff --git a/specs/v2/refreshable-context-sources.md b/specs/v2/refreshable-context-sources.md index 29a6ce4579..7e21a245f0 100644 --- a/specs/v2/refreshable-context-sources.md +++ b/specs/v2/refreshable-context-sources.md @@ -21,15 +21,15 @@ Watcher-backed caches are a later efficiency optimization for roots with proven ## Existing Pieces -| Existing piece | Responsibility | -| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| `Watcher.locationLayer` | Publish advisory `file.watcher.updated` events for local filesystem changes. | -| `EventV2.subscribe(...)` | Expose advisory events as scoped Effect streams. | -| `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. | -| `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. | -| `LocationServiceMap` | Own and clean up Location-scoped services, watcher subscriptions, and observation caches together. | +| Existing piece | Responsibility | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `Watcher.locationLayer` | Publish advisory `file.watcher.updated` events for local filesystem changes. | +| `EventV2.subscribe(...)` | Expose advisory events as scoped Effect streams. | +| `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. | +| `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. | +| `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. @@ -153,10 +153,11 @@ embedded skill Add a Location-scoped contributor to `SystemContextRegistry`: ```ts -yield* registry.contribute({ - key: "core/instructions", - load: loadAmbientInstructions(), -}) +yield * + registry.contribute({ + 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. @@ -213,15 +214,15 @@ temporary discovery or read failure -> aggregate SystemContext.unavailable ``` -| Observation | Source outcome | -| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -| 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 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. | -| URL returns `2xx` body | Available source with exact contents. | -| URL times out or returns transient failure | `SystemContext.unavailable`. | -| URL returns `404` or `410` | Decide the explicit removal contract before URL implementation. | +| Observation | Source outcome | +| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| 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 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. | +| URL returns `2xx` body | Available source with exact contents. | +| URL times out or returns transient failure | `SystemContext.unavailable`. | +| URL returns `404` or `410` | Decide the explicit removal contract before URL implementation. | 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. +When coverage is proven, cache each known candidate instruction path independently rather than invalidating one aggregate instruction cache: + +```text +candidate instruction path +-> one Refreshable +-> 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 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. - 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`. -2. Add model-meaningful instruction removal rendering support before unlink lands. -3. Add the Location-scoped `SystemContextRegistry` backed by stable-keyed scoped contributions. -4. Register built-in and ambient instruction producers with `SystemContextRegistry`. -5. Observe local instructions directly at each safe provider boundary. -6. Test add, edit, unlink, empty file, transient scan failure, transient read failure, restart, and deterministic ordering. -7. Add configured local exact paths and globs. -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. +2. Add truthful root-specific watcher registration. +3. Move ambient instructions from one directly observed aggregate to one watcher-invalidated Refreshable and Context Source per candidate file. +4. Add configured local exact paths and globs. +5. Add configured URL observations with explicit `404` and `410` semantics. +6. Migrate local `SkillV2` directory observations to per-source refreshables after skill failure semantics are corrected. +7. Add durable Session-scoped nested read discovery. ## 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? -2. Should configured URL sources treat `404` and `410` as confirmed removals? -3. What root-specific watcher API cleanly models ignore policy and callback health? -4. Should own-process file mutations publish an advisory invalidation event synchronously after commit? +1. 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. Should own-process file mutations publish an advisory invalidation event synchronously after commit? ## Compression Line ```text -State remembers what should be loaded. +SystemContextRegistry remembers which context producers participate. Refreshable remembers whether a successful observation needs loading again. Context Epoch remembers what the model was told. ``` diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index e9a2bc7f19..39b00e5eb8 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -711,7 +711,7 @@ Compatibility: 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 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. - 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 @@ -746,4 +746,22 @@ Compatibility: - Baseline replacement is bounded operational state and does not add permanent synchronized events. - Existing experimental V2 Session databases remain disposable across incompatible pre-launch event-schema changes. -- Compaction execution, 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. From 5e6c50317e516212c87cc7a716c2b6ab80b13f46 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 4 Jun 2026 21:20:00 -0400 Subject: [PATCH 08/17] chore: generate --- packages/core/test/database-migration.test.ts | 650 +++++++++--------- 1 file changed, 323 insertions(+), 327 deletions(-) diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 2cd5510be7..f246c536f3 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -57,414 +57,410 @@ describe("DatabaseMigration", () => { it.effect("applies tracked migrations to an empty database", () => Effect.gen(function* () { - const db = yield* makeDb - yield* DatabaseMigration.apply(db) + const db = yield* makeDb + yield* DatabaseMigration.apply(db) - expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({ - name: "session", - }) - expect( - yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`), - ).toEqual({ name: "session_input" }) - expect( - yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_context_epoch'`), - ).toEqual({ name: "session_context_epoch" }) - expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length }) - expect( - 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`, - ), - ).toEqual([ - { name: "event_aggregate_seq_idx" }, - { name: "event_aggregate_type_seq_idx" }, - { name: "session_input_session_admitted_seq_idx" }, - { name: "session_input_session_pending_delivery_seq_idx" }, - { name: "session_input_session_promoted_seq_idx" }, - { name: "session_message_session_seq_idx" }, - { name: "session_message_session_time_created_id_idx" }, - { name: "session_message_session_type_seq_idx" }, - ]) + expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({ + name: "session", + }) + expect( + yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`), + ).toEqual({ name: "session_input" }) + expect( + yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_context_epoch'`), + ).toEqual({ name: "session_context_epoch" }) + expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length }) + expect( + 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`, + ), + ).toEqual([ + { name: "event_aggregate_seq_idx" }, + { name: "event_aggregate_type_seq_idx" }, + { name: "session_input_session_admitted_seq_idx" }, + { name: "session_input_session_pending_delivery_seq_idx" }, + { name: "session_input_session_promoted_seq_idx" }, + { name: "session_message_session_seq_idx" }, + { name: "session_message_session_time_created_id_idx" }, + { name: "session_message_session_type_seq_idx" }, + ]) }), ) it.effect("resets beta history and rebuilds event-sourced Session input storage", () => Effect.gen(function* () { - const db = yield* makeDb - 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 message (id text PRIMARY KEY)`) - yield* db.run(sql`CREATE TABLE part (id text PRIMARY KEY)`) - yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`) - yield* db.run( - sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`, - ) - yield* db.run(sql`CREATE INDEX event_aggregate_seq_idx ON event (aggregate_id, seq)`) - yield* db.run(sql`CREATE INDEX event_aggregate_type_seq_idx ON event (aggregate_id, type, seq)`) - yield* db.run( - sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`, - ) - yield* db.run(sql`CREATE INDEX session_message_session_seq_idx ON session_message (session_id, seq)`) - yield* db.run( - sql`CREATE TABLE session_input (seq integer PRIMARY KEY AUTOINCREMENT, id text NOT NULL UNIQUE, session_id text NOT NULL, prompt text NOT NULL, delivery text NOT NULL, promoted_seq integer, time_created integer NOT NULL)`, - ) - yield* db.run( - sql`CREATE INDEX session_input_session_pending_delivery_seq_idx ON session_input (session_id, promoted_seq, delivery, seq)`, - ) - yield* db.run(sql`INSERT INTO session (id, workspace_id) VALUES ('session', 'wrk_old')`) - yield* db.run(sql`INSERT INTO workspace (id) VALUES ('wrk_old')`) - yield* db.run(sql`INSERT INTO message (id) VALUES ('message')`) - yield* db.run(sql`INSERT INTO part (id) VALUES ('part')`) - yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 0)`) - yield* db.run( - sql`INSERT INTO event (id, aggregate_id, seq, type, data) VALUES ('evt_old', 'session', 0, 'old.1', '{}')`, - ) - yield* db.run( - sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('msg_old', 'session', 'user', 0, 1, 1, '{}')`, - ) - yield* db.run( - sql`INSERT INTO session_input (id, session_id, prompt, delivery, time_created) VALUES ('msg_pending', 'session', '{}', 'steer', 1)`, - ) + const db = yield* makeDb + 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 message (id text PRIMARY KEY)`) + yield* db.run(sql`CREATE TABLE part (id text PRIMARY KEY)`) + yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`) + yield* db.run( + sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`, + ) + yield* db.run(sql`CREATE INDEX event_aggregate_seq_idx ON event (aggregate_id, seq)`) + yield* db.run(sql`CREATE INDEX event_aggregate_type_seq_idx ON event (aggregate_id, type, seq)`) + yield* db.run( + sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`, + ) + yield* db.run(sql`CREATE INDEX session_message_session_seq_idx ON session_message (session_id, seq)`) + yield* db.run( + sql`CREATE TABLE session_input (seq integer PRIMARY KEY AUTOINCREMENT, id text NOT NULL UNIQUE, session_id text NOT NULL, prompt text NOT NULL, delivery text NOT NULL, promoted_seq integer, time_created integer NOT NULL)`, + ) + yield* db.run( + sql`CREATE INDEX session_input_session_pending_delivery_seq_idx ON session_input (session_id, promoted_seq, delivery, seq)`, + ) + yield* db.run(sql`INSERT INTO session (id, workspace_id) VALUES ('session', 'wrk_old')`) + yield* db.run(sql`INSERT INTO workspace (id) VALUES ('wrk_old')`) + yield* db.run(sql`INSERT INTO message (id) VALUES ('message')`) + yield* db.run(sql`INSERT INTO part (id) VALUES ('part')`) + yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 0)`) + yield* db.run( + sql`INSERT INTO event (id, aggregate_id, seq, type, data) VALUES ('evt_old', 'session', 0, 'old.1', '{}')`, + ) + yield* db.run( + sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('msg_old', 'session', 'user', 0, 1, 1, '{}')`, + ) + yield* db.run( + sql`INSERT INTO session_input (id, session_id, prompt, delivery, time_created) VALUES ('msg_pending', 'session', '{}', 'steer', 1)`, + ) - yield* DatabaseMigration.applyOnly(db, [eventSourcedSessionInputMigration]) + yield* DatabaseMigration.applyOnly(db, [eventSourcedSessionInputMigration]) - expect(yield* db.all(sql`SELECT id, workspace_id FROM session`)).toEqual([ - { id: "session", workspace_id: null }, - ]) - expect(yield* db.all(sql`SELECT id FROM workspace`)).toEqual([]) - expect(yield* db.all(sql`SELECT id FROM message`)).toEqual([{ id: "message" }]) - expect(yield* db.all(sql`SELECT id FROM part`)).toEqual([{ id: "part" }]) - expect(yield* db.all(sql`SELECT id FROM event`)).toEqual([]) - expect(yield* db.all(sql`SELECT aggregate_id FROM event_sequence`)).toEqual([]) - expect(yield* db.all(sql`SELECT id FROM session_message`)).toEqual([]) - expect(yield* db.all(sql`SELECT id FROM session_input`)).toEqual([]) - expect( - (yield* db.all<{ name: string }>(sql`PRAGMA table_info(session_input)`)).map((column) => column.name), - ).toEqual(["id", "session_id", "prompt", "delivery", "admitted_seq", "promoted_seq", "time_created"]) - expect( - (yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(session_message)`)).find( - (index) => index.name === "session_message_session_seq_idx", - ), - ).toMatchObject({ unique: 1 }) - expect( - (yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(event)`)).find( - (index) => index.name === "event_aggregate_seq_idx", - ), - ).toMatchObject({ unique: 1 }) - expect( - (yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(session_input)`)).filter((index) => - ["session_input_session_admitted_seq_idx", "session_input_session_promoted_seq_idx"].includes(index.name), - ), - ).toEqual([ - expect.objectContaining({ name: "session_input_session_promoted_seq_idx", unique: 1 }), - expect.objectContaining({ name: "session_input_session_admitted_seq_idx", unique: 1 }), - ]) + expect(yield* db.all(sql`SELECT id, workspace_id FROM session`)).toEqual([{ id: "session", workspace_id: null }]) + expect(yield* db.all(sql`SELECT id FROM workspace`)).toEqual([]) + expect(yield* db.all(sql`SELECT id FROM message`)).toEqual([{ id: "message" }]) + expect(yield* db.all(sql`SELECT id FROM part`)).toEqual([{ id: "part" }]) + expect(yield* db.all(sql`SELECT id FROM event`)).toEqual([]) + expect(yield* db.all(sql`SELECT aggregate_id FROM event_sequence`)).toEqual([]) + expect(yield* db.all(sql`SELECT id FROM session_message`)).toEqual([]) + expect(yield* db.all(sql`SELECT id FROM session_input`)).toEqual([]) + expect( + (yield* db.all<{ name: string }>(sql`PRAGMA table_info(session_input)`)).map((column) => column.name), + ).toEqual(["id", "session_id", "prompt", "delivery", "admitted_seq", "promoted_seq", "time_created"]) + expect( + (yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(session_message)`)).find( + (index) => index.name === "session_message_session_seq_idx", + ), + ).toMatchObject({ unique: 1 }) + expect( + (yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(event)`)).find( + (index) => index.name === "event_aggregate_seq_idx", + ), + ).toMatchObject({ unique: 1 }) + expect( + (yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(session_input)`)).filter((index) => + ["session_input_session_admitted_seq_idx", "session_input_session_promoted_seq_idx"].includes(index.name), + ), + ).toEqual([ + expect.objectContaining({ name: "session_input_session_promoted_seq_idx", unique: 1 }), + expect.objectContaining({ name: "session_input_session_admitted_seq_idx", unique: 1 }), + ]) }), ) it.effect("resets incompatible projected Session messages before adding sequence order", () => Effect.gen(function* () { - const db = yield* makeDb - yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`) - yield* db.run( - sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`, - ) - yield* db.run( - sql`CREATE TABLE part (id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`, - ) - yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY, seq integer NOT NULL)`) - yield* db.run( - sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`, - ) - yield* db.run( - sql`CREATE INDEX session_message_session_time_created_id_idx ON session_message (session_id, time_created, id)`, - ) - yield* db.run( - sql`CREATE INDEX session_message_session_type_time_created_id_idx ON session_message (session_id, type, time_created, id)`, - ) - yield* db.run(sql`INSERT INTO session (id) VALUES ('session')`) - yield* db.run( - sql`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES ('legacy_message', 'session', 1, 1, '{"role":"user"}')`, - ) - yield* db.run( - sql`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES ('legacy_part', 'legacy_message', 'session', 1, 1, '{"type":"text","text":"hello"}')`, - ) - yield* db.run( - sql`INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES ('stale_projection', 'session', 'user', 1, 1, '{}')`, - ) + const db = yield* makeDb + yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`) + yield* db.run( + sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`, + ) + yield* db.run( + sql`CREATE TABLE part (id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`, + ) + yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY, seq integer NOT NULL)`) + yield* db.run( + sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`, + ) + yield* db.run( + sql`CREATE INDEX session_message_session_time_created_id_idx ON session_message (session_id, time_created, id)`, + ) + yield* db.run( + sql`CREATE INDEX session_message_session_type_time_created_id_idx ON session_message (session_id, type, time_created, id)`, + ) + yield* db.run(sql`INSERT INTO session (id) VALUES ('session')`) + yield* db.run( + sql`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES ('legacy_message', 'session', 1, 1, '{"role":"user"}')`, + ) + yield* db.run( + sql`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES ('legacy_part', 'legacy_message', 'session', 1, 1, '{"type":"text","text":"hello"}')`, + ) + yield* db.run( + sql`INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES ('stale_projection', 'session', 'user', 1, 1, '{}')`, + ) - yield* DatabaseMigration.applyOnly(db, [sessionMessageProjectionOrderMigration]) + yield* DatabaseMigration.applyOnly(db, [sessionMessageProjectionOrderMigration]) - expect(yield* db.all(sql`SELECT id, session_id, data FROM message`)).toEqual([ - { id: "legacy_message", session_id: "session", data: '{"role":"user"}' }, - ]) - expect(yield* db.all(sql`SELECT id, message_id, session_id, data FROM part`)).toEqual([ - { - id: "legacy_part", - message_id: "legacy_message", - session_id: "session", - data: '{"type":"text","text":"hello"}', - }, - ]) - expect(yield* db.all(sql`SELECT id FROM session_message`)).toEqual([]) + expect(yield* db.all(sql`SELECT id, session_id, data FROM message`)).toEqual([ + { id: "legacy_message", session_id: "session", data: '{"role":"user"}' }, + ]) + expect(yield* db.all(sql`SELECT id, message_id, session_id, data FROM part`)).toEqual([ + { + id: "legacy_part", + message_id: "legacy_message", + session_id: "session", + data: '{"type":"text","text":"hello"}', + }, + ]) + expect(yield* db.all(sql`SELECT id FROM session_message`)).toEqual([]) - yield* db.run( - 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 }) + yield* db.run( + 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 }) }), ) it.effect("runs session usage backfill in order with schema changes", () => Effect.gen(function* () { - 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 message (id text PRIMARY KEY, session_id text NOT NULL, data text NOT NULL)`) - yield* db.run(sql`INSERT INTO session (id, time_updated) VALUES ('session_1', 1)`) - yield* db.run( - sql`INSERT INTO message (id, session_id, data) VALUES ('message_1', 'session_1', '{"role":"assistant","cost":1.25,"tokens":{"input":2,"output":3,"reasoning":4,"cache":{"read":5,"write":6}}}')`, - ) + 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 message (id text PRIMARY KEY, session_id text NOT NULL, data text NOT NULL)`) + yield* db.run(sql`INSERT INTO session (id, time_updated) VALUES ('session_1', 1)`) + yield* db.run( + sql`INSERT INTO message (id, session_id, data) VALUES ('message_1', 'session_1', '{"role":"assistant","cost":1.25,"tokens":{"input":2,"output":3,"reasoning":4,"cache":{"read":5,"write":6}}}')`, + ) - yield* DatabaseMigration.applyOnly(db, [sessionUsageMigration]) + yield* DatabaseMigration.applyOnly(db, [sessionUsageMigration]) - expect( - yield* db.get( - sql`SELECT cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write FROM session WHERE id = 'session_1'`, - ), - ).toEqual({ - cost: 1.25, - tokens_input: 2, - tokens_output: 3, - tokens_reasoning: 4, - tokens_cache_read: 5, - tokens_cache_write: 6, - }) + expect( + yield* db.get( + sql`SELECT cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write FROM session WHERE id = 'session_1'`, + ), + ).toEqual({ + cost: 1.25, + tokens_input: 2, + tokens_output: 3, + tokens_reasoning: 4, + tokens_cache_read: 5, + tokens_cache_write: 6, + }) }), ) it.effect("normalizes Windows storage paths and leaves POSIX paths untouched", () => Effect.gen(function* () { - 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 session (id text PRIMARY KEY, directory text NOT NULL, path text)`) - // Windows-shaped rows (drive + backslash) must be normalized. - yield* db.run( - sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"win"}, ${"C:\\Repo\\Thing"}, ${JSON.stringify([ - "C:\\Repo\\Thing\\sandbox", - ])})`, - ) - yield* db.run( - sql`INSERT INTO session (id, directory, path) VALUES (${"win"}, ${"C:\\Repo\\Thing\\packages\\api"}, ${"packages\\api"})`, - ) - // UNC worktrees and their sandboxes must normalize too (not just drive paths). - yield* db.run( - sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"unc"}, ${"\\\\server\\share"}, ${JSON.stringify([ - "\\\\server\\share\\sandbox", - ])})`, - ) - // The "/" worktree sentinel and POSIX paths (including a pathological - // backslash in a POSIX filename) must survive byte-for-byte. - yield* db.run(sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"global"}, ${"/"}, ${"[]"})`) - yield* db.run( - sql`INSERT INTO session (id, directory, path) VALUES (${"posix"}, ${"/home/me/we\\ird"}, ${"src\\weird"})`, - ) + 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 session (id text PRIMARY KEY, directory text NOT NULL, path text)`) + // Windows-shaped rows (drive + backslash) must be normalized. + yield* db.run( + sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"win"}, ${"C:\\Repo\\Thing"}, ${JSON.stringify([ + "C:\\Repo\\Thing\\sandbox", + ])})`, + ) + yield* db.run( + sql`INSERT INTO session (id, directory, path) VALUES (${"win"}, ${"C:\\Repo\\Thing\\packages\\api"}, ${"packages\\api"})`, + ) + // UNC worktrees and their sandboxes must normalize too (not just drive paths). + yield* db.run( + sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"unc"}, ${"\\\\server\\share"}, ${JSON.stringify([ + "\\\\server\\share\\sandbox", + ])})`, + ) + // The "/" worktree sentinel and POSIX paths (including a pathological + // backslash in a POSIX filename) must survive byte-for-byte. + yield* db.run(sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"global"}, ${"/"}, ${"[]"})`) + yield* db.run( + sql`INSERT INTO session (id, directory, path) VALUES (${"posix"}, ${"/home/me/we\\ird"}, ${"src\\weird"})`, + ) - yield* DatabaseMigration.applyOnly(db, [normalizeStoragePathsMigration]) + yield* DatabaseMigration.applyOnly(db, [normalizeStoragePathsMigration]) - expect(yield* db.get(sql`SELECT worktree, sandboxes FROM project WHERE id = 'win'`)).toEqual({ - worktree: "C:/Repo/Thing", - sandboxes: JSON.stringify(["C:/Repo/Thing/sandbox"]), - }) - expect(yield* db.get(sql`SELECT directory, path FROM session WHERE id = 'win'`)).toEqual({ - directory: "C:/Repo/Thing/packages/api", - path: "packages/api", - }) - expect(yield* db.get(sql`SELECT worktree, sandboxes FROM project WHERE id = 'unc'`)).toEqual({ - worktree: "//server/share", - sandboxes: JSON.stringify(["//server/share/sandbox"]), - }) - expect(yield* db.get(sql`SELECT worktree FROM project WHERE id = 'global'`)).toEqual({ worktree: "/" }) - expect(yield* db.get(sql`SELECT directory, path FROM session WHERE id = 'posix'`)).toEqual({ - directory: "/home/me/we\\ird", - path: "src\\weird", - }) + expect(yield* db.get(sql`SELECT worktree, sandboxes FROM project WHERE id = 'win'`)).toEqual({ + worktree: "C:/Repo/Thing", + sandboxes: JSON.stringify(["C:/Repo/Thing/sandbox"]), + }) + expect(yield* db.get(sql`SELECT directory, path FROM session WHERE id = 'win'`)).toEqual({ + directory: "C:/Repo/Thing/packages/api", + path: "packages/api", + }) + expect(yield* db.get(sql`SELECT worktree, sandboxes FROM project WHERE id = 'unc'`)).toEqual({ + worktree: "//server/share", + sandboxes: JSON.stringify(["//server/share/sandbox"]), + }) + expect(yield* db.get(sql`SELECT worktree FROM project WHERE id = 'global'`)).toEqual({ worktree: "/" }) + expect(yield* db.get(sql`SELECT directory, path FROM session WHERE id = 'posix'`)).toEqual({ + directory: "/home/me/we\\ird", + path: "src\\weird", + }) }), ) it.effect("maps native Windows paths through database columns", () => { if (process.platform !== "win32") return Effect.void return Effect.gen(function* () { - const db = yield* makeDb - yield* DatabaseMigration.apply(db) - const projectID = ProjectV2.ID.make("codec_project") - const worktree = AbsolutePath.make("C:\\Repo\\Thing") - const sandbox = AbsolutePath.make("C:\\Repo\\Thing\\sandbox") - const directory = "C:\\Repo\\Thing\\packages\\api" - const sessionID = SessionSchema.ID.make("ses_codec") + const db = yield* makeDb + yield* DatabaseMigration.apply(db) + const projectID = ProjectV2.ID.make("codec_project") + const worktree = AbsolutePath.make("C:\\Repo\\Thing") + const sandbox = AbsolutePath.make("C:\\Repo\\Thing\\sandbox") + const directory = "C:\\Repo\\Thing\\packages\\api" + const sessionID = SessionSchema.ID.make("ses_codec") - expect(() => - Effect.runSync( - db - .insert(ProjectTable) - .values({ - id: ProjectV2.ID.make("invalid_path"), - worktree: AbsolutePath.make("not-absolute"), - sandboxes: [], - time_created: 1, - time_updated: 1, - }) - .run(), - ), - ).toThrow() + expect(() => + Effect.runSync( + db + .insert(ProjectTable) + .values({ + id: ProjectV2.ID.make("invalid_path"), + worktree: AbsolutePath.make("not-absolute"), + sandboxes: [], + time_created: 1, + time_updated: 1, + }) + .run(), + ), + ).toThrow() - yield* db - .insert(ProjectTable) - .values({ - id: projectID, - worktree, - sandboxes: [sandbox], - time_created: 1, - time_updated: 1, - }) - .run() - yield* db - .insert(SessionTable) - .values({ - id: sessionID, - project_id: projectID, - slug: "codec", - directory, - path: "packages\\api", - title: "Codec", - version: "test", - time_created: 1, - time_updated: 1, - }) - .run() - - expect( - yield* db.get<{ worktree: string; sandboxes: string }>( - sql`SELECT worktree, sandboxes FROM project WHERE id = ${projectID}`, - ), - ).toEqual({ - worktree: "C:/Repo/Thing", - sandboxes: JSON.stringify(["C:/Repo/Thing/sandbox"]), + yield* db + .insert(ProjectTable) + .values({ + id: projectID, + worktree, + sandboxes: [sandbox], + time_created: 1, + time_updated: 1, }) - expect( - yield* db.get<{ directory: string; path: string }>( - sql`SELECT directory, path FROM session WHERE id = ${sessionID}`, - ), - ).toEqual({ - directory: "C:/Repo/Thing/packages/api", - path: "packages/api", + .run() + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: projectID, + slug: "codec", + directory, + path: "packages\\api", + title: "Codec", + version: "test", + time_created: 1, + time_updated: 1, }) + .run() - const project = yield* db.select().from(ProjectTable).where(eq(ProjectTable.worktree, worktree)).get() - const session = yield* db.select().from(SessionTable).where(eq(SessionTable.directory, directory)).get() - expect(project?.worktree).toBe(worktree) - expect(project?.sandboxes).toEqual([sandbox]) - expect(session?.directory).toBe(directory) - expect(session?.path).toBe("packages/api") + expect( + yield* db.get<{ worktree: string; sandboxes: string }>( + sql`SELECT worktree, sandboxes FROM project WHERE id = ${projectID}`, + ), + ).toEqual({ + worktree: "C:/Repo/Thing", + sandboxes: JSON.stringify(["C:/Repo/Thing/sandbox"]), + }) + expect( + yield* db.get<{ directory: string; path: string }>( + sql`SELECT directory, path FROM session WHERE id = ${sessionID}`, + ), + ).toEqual({ + directory: "C:/Repo/Thing/packages/api", + path: "packages/api", + }) - expect((yield* db.select().from(SessionTable).where(eq(SessionTable.path, "packages\\api")).get())?.id).toBe( - sessionID, - ) + const project = yield* db.select().from(ProjectTable).where(eq(ProjectTable.worktree, worktree)).get() + const session = yield* db.select().from(SessionTable).where(eq(SessionTable.directory, directory)).get() + expect(project?.worktree).toBe(worktree) + expect(project?.sandboxes).toEqual([sandbox]) + expect(session?.directory).toBe(directory) + expect(session?.path).toBe("packages/api") - const moved = AbsolutePath.make("D:\\Moved\\Thing") - const updated = yield* db - .update(ProjectTable) - .set({ worktree: moved, sandboxes: [moved] }) - .where(eq(ProjectTable.id, projectID)) - .returning() - .get() - expect(updated?.worktree).toBe(moved) - expect(updated?.sandboxes).toEqual([moved]) - expect( - yield* db.get<{ worktree: string; sandboxes: string }>( - sql`SELECT worktree, sandboxes FROM project WHERE id = ${projectID}`, - ), - ).toEqual({ worktree: "D:/Moved/Thing", sandboxes: JSON.stringify(["D:/Moved/Thing"]) }) - expect( - (yield* db - .select() - .from(ProjectTable) - .where(inArray(ProjectTable.worktree, [moved])) - .get())?.id, - ).toBe(projectID) + expect((yield* db.select().from(SessionTable).where(eq(SessionTable.path, "packages\\api")).get())?.id).toBe( + sessionID, + ) - yield* db.run(sql`UPDATE project SET worktree = ${"not-absolute"} WHERE id = ${projectID}`) - expect(() => - Effect.runSync(db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get()), - ).toThrow() + const moved = AbsolutePath.make("D:\\Moved\\Thing") + const updated = yield* db + .update(ProjectTable) + .set({ worktree: moved, sandboxes: [moved] }) + .where(eq(ProjectTable.id, projectID)) + .returning() + .get() + expect(updated?.worktree).toBe(moved) + expect(updated?.sandboxes).toEqual([moved]) + expect( + yield* db.get<{ worktree: string; sandboxes: string }>( + sql`SELECT worktree, sandboxes FROM project WHERE id = ${projectID}`, + ), + ).toEqual({ worktree: "D:/Moved/Thing", sandboxes: JSON.stringify(["D:/Moved/Thing"]) }) + expect( + (yield* db + .select() + .from(ProjectTable) + .where(inArray(ProjectTable.worktree, [moved])) + .get())?.id, + ).toBe(projectID) + + yield* db.run(sql`UPDATE project SET worktree = ${"not-absolute"} WHERE id = ${projectID}`) + expect(() => Effect.runSync(db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get())).toThrow() }) }) it.effect("imports existing drizzle migration state", () => Effect.gen(function* () { - const db = yield* makeDb - yield* db.run( - sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`, - ) - yield* db.run(sql` + const db = yield* makeDb + yield* db.run( + sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`, + ) + yield* db.run(sql` INSERT INTO __drizzle_migrations (hash, created_at, name, applied_at) VALUES ('hash', 1, '20260127222353_familiar_lady_ursula', ${new Date().toISOString()}) `) - 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" }) }), ) it.effect("does not replay a migrated session metadata column", () => Effect.gen(function* () { - const db = yield* makeDb - yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`) - yield* db.run( - sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`, - ) - yield* db.run(sql` + const db = yield* makeDb + yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`) + yield* db.run( + sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`, + ) + yield* db.run(sql` INSERT INTO __drizzle_migrations (hash, created_at, name, applied_at) VALUES ('hash', 1, '20260511173437_session-metadata', ${new Date().toISOString()}) `) - 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" }]) }), ) it.effect("accepts the temporary replacement session metadata migration id", () => Effect.gen(function* () { - const db = yield* makeDb - 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`INSERT INTO migration (id, time_completed) VALUES ('20260530232709_lovely_romulus', 1)`) + const db = yield* makeDb + 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`INSERT INTO migration (id, time_completed) VALUES ('20260530232709_lovely_romulus', 1)`) - yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration]) + yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration]) - expect(yield* db.all(sql`SELECT id FROM migration ORDER BY id`)).toEqual([ - { id: "20260511173437_session-metadata" }, - { id: "20260530232709_lovely_romulus" }, - ]) + expect(yield* db.all(sql`SELECT id FROM migration ORDER BY id`)).toEqual([ + { id: "20260511173437_session-metadata" }, + { id: "20260530232709_lovely_romulus" }, + ]) }), ) it.effect("skips drizzle import when migration table already has state", () => Effect.gen(function* () { - const db = yield* makeDb - 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`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`, - ) - yield* db.run(sql` + const db = yield* makeDb + 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`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`, + ) + yield* db.run(sql` INSERT INTO __drizzle_migrations (hash, created_at, name, applied_at) VALUES ('hash', 1, '20260127222353_familiar_lady_ursula', ${new Date().toISOString()}) `) - 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" }]) }), ) }) From 5ca39084e42317d2275a080a51cba347deaf65c9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 4 Jun 2026 21:24:06 -0400 Subject: [PATCH 09/17] Revert "chore: generate" This reverts commit d3863003311dac4bfa7ed19fac7b3863a5a2c2c7. --- packages/core/test/database-migration.test.ts | 650 +++++++++--------- 1 file changed, 327 insertions(+), 323 deletions(-) diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index f246c536f3..2cd5510be7 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -57,410 +57,414 @@ describe("DatabaseMigration", () => { it.effect("applies tracked migrations to an empty database", () => Effect.gen(function* () { - const db = yield* makeDb - yield* DatabaseMigration.apply(db) + const db = yield* makeDb + yield* DatabaseMigration.apply(db) - expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({ - name: "session", - }) - expect( - yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`), - ).toEqual({ name: "session_input" }) - expect( - yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_context_epoch'`), - ).toEqual({ name: "session_context_epoch" }) - expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length }) - expect( - 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`, - ), - ).toEqual([ - { name: "event_aggregate_seq_idx" }, - { name: "event_aggregate_type_seq_idx" }, - { name: "session_input_session_admitted_seq_idx" }, - { name: "session_input_session_pending_delivery_seq_idx" }, - { name: "session_input_session_promoted_seq_idx" }, - { name: "session_message_session_seq_idx" }, - { name: "session_message_session_time_created_id_idx" }, - { name: "session_message_session_type_seq_idx" }, - ]) + expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({ + name: "session", + }) + expect( + yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`), + ).toEqual({ name: "session_input" }) + expect( + yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_context_epoch'`), + ).toEqual({ name: "session_context_epoch" }) + expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length }) + expect( + 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`, + ), + ).toEqual([ + { name: "event_aggregate_seq_idx" }, + { name: "event_aggregate_type_seq_idx" }, + { name: "session_input_session_admitted_seq_idx" }, + { name: "session_input_session_pending_delivery_seq_idx" }, + { name: "session_input_session_promoted_seq_idx" }, + { name: "session_message_session_seq_idx" }, + { name: "session_message_session_time_created_id_idx" }, + { name: "session_message_session_type_seq_idx" }, + ]) }), ) it.effect("resets beta history and rebuilds event-sourced Session input storage", () => Effect.gen(function* () { - const db = yield* makeDb - 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 message (id text PRIMARY KEY)`) - yield* db.run(sql`CREATE TABLE part (id text PRIMARY KEY)`) - yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`) - yield* db.run( - sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`, - ) - yield* db.run(sql`CREATE INDEX event_aggregate_seq_idx ON event (aggregate_id, seq)`) - yield* db.run(sql`CREATE INDEX event_aggregate_type_seq_idx ON event (aggregate_id, type, seq)`) - yield* db.run( - sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`, - ) - yield* db.run(sql`CREATE INDEX session_message_session_seq_idx ON session_message (session_id, seq)`) - yield* db.run( - sql`CREATE TABLE session_input (seq integer PRIMARY KEY AUTOINCREMENT, id text NOT NULL UNIQUE, session_id text NOT NULL, prompt text NOT NULL, delivery text NOT NULL, promoted_seq integer, time_created integer NOT NULL)`, - ) - yield* db.run( - sql`CREATE INDEX session_input_session_pending_delivery_seq_idx ON session_input (session_id, promoted_seq, delivery, seq)`, - ) - yield* db.run(sql`INSERT INTO session (id, workspace_id) VALUES ('session', 'wrk_old')`) - yield* db.run(sql`INSERT INTO workspace (id) VALUES ('wrk_old')`) - yield* db.run(sql`INSERT INTO message (id) VALUES ('message')`) - yield* db.run(sql`INSERT INTO part (id) VALUES ('part')`) - yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 0)`) - yield* db.run( - sql`INSERT INTO event (id, aggregate_id, seq, type, data) VALUES ('evt_old', 'session', 0, 'old.1', '{}')`, - ) - yield* db.run( - sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('msg_old', 'session', 'user', 0, 1, 1, '{}')`, - ) - yield* db.run( - sql`INSERT INTO session_input (id, session_id, prompt, delivery, time_created) VALUES ('msg_pending', 'session', '{}', 'steer', 1)`, - ) + const db = yield* makeDb + 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 message (id text PRIMARY KEY)`) + yield* db.run(sql`CREATE TABLE part (id text PRIMARY KEY)`) + yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`) + yield* db.run( + sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`, + ) + yield* db.run(sql`CREATE INDEX event_aggregate_seq_idx ON event (aggregate_id, seq)`) + yield* db.run(sql`CREATE INDEX event_aggregate_type_seq_idx ON event (aggregate_id, type, seq)`) + yield* db.run( + sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`, + ) + yield* db.run(sql`CREATE INDEX session_message_session_seq_idx ON session_message (session_id, seq)`) + yield* db.run( + sql`CREATE TABLE session_input (seq integer PRIMARY KEY AUTOINCREMENT, id text NOT NULL UNIQUE, session_id text NOT NULL, prompt text NOT NULL, delivery text NOT NULL, promoted_seq integer, time_created integer NOT NULL)`, + ) + yield* db.run( + sql`CREATE INDEX session_input_session_pending_delivery_seq_idx ON session_input (session_id, promoted_seq, delivery, seq)`, + ) + yield* db.run(sql`INSERT INTO session (id, workspace_id) VALUES ('session', 'wrk_old')`) + yield* db.run(sql`INSERT INTO workspace (id) VALUES ('wrk_old')`) + yield* db.run(sql`INSERT INTO message (id) VALUES ('message')`) + yield* db.run(sql`INSERT INTO part (id) VALUES ('part')`) + yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 0)`) + yield* db.run( + sql`INSERT INTO event (id, aggregate_id, seq, type, data) VALUES ('evt_old', 'session', 0, 'old.1', '{}')`, + ) + yield* db.run( + sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('msg_old', 'session', 'user', 0, 1, 1, '{}')`, + ) + yield* db.run( + sql`INSERT INTO session_input (id, session_id, prompt, delivery, time_created) VALUES ('msg_pending', 'session', '{}', 'steer', 1)`, + ) - yield* DatabaseMigration.applyOnly(db, [eventSourcedSessionInputMigration]) + yield* DatabaseMigration.applyOnly(db, [eventSourcedSessionInputMigration]) - expect(yield* db.all(sql`SELECT id, workspace_id FROM session`)).toEqual([{ id: "session", workspace_id: null }]) - expect(yield* db.all(sql`SELECT id FROM workspace`)).toEqual([]) - expect(yield* db.all(sql`SELECT id FROM message`)).toEqual([{ id: "message" }]) - expect(yield* db.all(sql`SELECT id FROM part`)).toEqual([{ id: "part" }]) - expect(yield* db.all(sql`SELECT id FROM event`)).toEqual([]) - expect(yield* db.all(sql`SELECT aggregate_id FROM event_sequence`)).toEqual([]) - expect(yield* db.all(sql`SELECT id FROM session_message`)).toEqual([]) - expect(yield* db.all(sql`SELECT id FROM session_input`)).toEqual([]) - expect( - (yield* db.all<{ name: string }>(sql`PRAGMA table_info(session_input)`)).map((column) => column.name), - ).toEqual(["id", "session_id", "prompt", "delivery", "admitted_seq", "promoted_seq", "time_created"]) - expect( - (yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(session_message)`)).find( - (index) => index.name === "session_message_session_seq_idx", - ), - ).toMatchObject({ unique: 1 }) - expect( - (yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(event)`)).find( - (index) => index.name === "event_aggregate_seq_idx", - ), - ).toMatchObject({ unique: 1 }) - expect( - (yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(session_input)`)).filter((index) => - ["session_input_session_admitted_seq_idx", "session_input_session_promoted_seq_idx"].includes(index.name), - ), - ).toEqual([ - expect.objectContaining({ name: "session_input_session_promoted_seq_idx", unique: 1 }), - expect.objectContaining({ name: "session_input_session_admitted_seq_idx", unique: 1 }), - ]) + expect(yield* db.all(sql`SELECT id, workspace_id FROM session`)).toEqual([ + { id: "session", workspace_id: null }, + ]) + expect(yield* db.all(sql`SELECT id FROM workspace`)).toEqual([]) + expect(yield* db.all(sql`SELECT id FROM message`)).toEqual([{ id: "message" }]) + expect(yield* db.all(sql`SELECT id FROM part`)).toEqual([{ id: "part" }]) + expect(yield* db.all(sql`SELECT id FROM event`)).toEqual([]) + expect(yield* db.all(sql`SELECT aggregate_id FROM event_sequence`)).toEqual([]) + expect(yield* db.all(sql`SELECT id FROM session_message`)).toEqual([]) + expect(yield* db.all(sql`SELECT id FROM session_input`)).toEqual([]) + expect( + (yield* db.all<{ name: string }>(sql`PRAGMA table_info(session_input)`)).map((column) => column.name), + ).toEqual(["id", "session_id", "prompt", "delivery", "admitted_seq", "promoted_seq", "time_created"]) + expect( + (yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(session_message)`)).find( + (index) => index.name === "session_message_session_seq_idx", + ), + ).toMatchObject({ unique: 1 }) + expect( + (yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(event)`)).find( + (index) => index.name === "event_aggregate_seq_idx", + ), + ).toMatchObject({ unique: 1 }) + expect( + (yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(session_input)`)).filter((index) => + ["session_input_session_admitted_seq_idx", "session_input_session_promoted_seq_idx"].includes(index.name), + ), + ).toEqual([ + expect.objectContaining({ name: "session_input_session_promoted_seq_idx", unique: 1 }), + expect.objectContaining({ name: "session_input_session_admitted_seq_idx", unique: 1 }), + ]) }), ) it.effect("resets incompatible projected Session messages before adding sequence order", () => Effect.gen(function* () { - const db = yield* makeDb - yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`) - yield* db.run( - sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`, - ) - yield* db.run( - sql`CREATE TABLE part (id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`, - ) - yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY, seq integer NOT NULL)`) - yield* db.run( - sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`, - ) - yield* db.run( - sql`CREATE INDEX session_message_session_time_created_id_idx ON session_message (session_id, time_created, id)`, - ) - yield* db.run( - sql`CREATE INDEX session_message_session_type_time_created_id_idx ON session_message (session_id, type, time_created, id)`, - ) - yield* db.run(sql`INSERT INTO session (id) VALUES ('session')`) - yield* db.run( - sql`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES ('legacy_message', 'session', 1, 1, '{"role":"user"}')`, - ) - yield* db.run( - sql`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES ('legacy_part', 'legacy_message', 'session', 1, 1, '{"type":"text","text":"hello"}')`, - ) - yield* db.run( - sql`INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES ('stale_projection', 'session', 'user', 1, 1, '{}')`, - ) + const db = yield* makeDb + yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`) + yield* db.run( + sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`, + ) + yield* db.run( + sql`CREATE TABLE part (id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`, + ) + yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY, seq integer NOT NULL)`) + yield* db.run( + sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`, + ) + yield* db.run( + sql`CREATE INDEX session_message_session_time_created_id_idx ON session_message (session_id, time_created, id)`, + ) + yield* db.run( + sql`CREATE INDEX session_message_session_type_time_created_id_idx ON session_message (session_id, type, time_created, id)`, + ) + yield* db.run(sql`INSERT INTO session (id) VALUES ('session')`) + yield* db.run( + sql`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES ('legacy_message', 'session', 1, 1, '{"role":"user"}')`, + ) + yield* db.run( + sql`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES ('legacy_part', 'legacy_message', 'session', 1, 1, '{"type":"text","text":"hello"}')`, + ) + yield* db.run( + sql`INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES ('stale_projection', 'session', 'user', 1, 1, '{}')`, + ) - yield* DatabaseMigration.applyOnly(db, [sessionMessageProjectionOrderMigration]) + yield* DatabaseMigration.applyOnly(db, [sessionMessageProjectionOrderMigration]) - expect(yield* db.all(sql`SELECT id, session_id, data FROM message`)).toEqual([ - { id: "legacy_message", session_id: "session", data: '{"role":"user"}' }, - ]) - expect(yield* db.all(sql`SELECT id, message_id, session_id, data FROM part`)).toEqual([ - { - id: "legacy_part", - message_id: "legacy_message", - session_id: "session", - data: '{"type":"text","text":"hello"}', - }, - ]) - expect(yield* db.all(sql`SELECT id FROM session_message`)).toEqual([]) + expect(yield* db.all(sql`SELECT id, session_id, data FROM message`)).toEqual([ + { id: "legacy_message", session_id: "session", data: '{"role":"user"}' }, + ]) + expect(yield* db.all(sql`SELECT id, message_id, session_id, data FROM part`)).toEqual([ + { + id: "legacy_part", + message_id: "legacy_message", + session_id: "session", + data: '{"type":"text","text":"hello"}', + }, + ]) + expect(yield* db.all(sql`SELECT id FROM session_message`)).toEqual([]) - yield* db.run( - 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 }) + yield* db.run( + 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 }) }), ) it.effect("runs session usage backfill in order with schema changes", () => Effect.gen(function* () { - 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 message (id text PRIMARY KEY, session_id text NOT NULL, data text NOT NULL)`) - yield* db.run(sql`INSERT INTO session (id, time_updated) VALUES ('session_1', 1)`) - yield* db.run( - sql`INSERT INTO message (id, session_id, data) VALUES ('message_1', 'session_1', '{"role":"assistant","cost":1.25,"tokens":{"input":2,"output":3,"reasoning":4,"cache":{"read":5,"write":6}}}')`, - ) + 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 message (id text PRIMARY KEY, session_id text NOT NULL, data text NOT NULL)`) + yield* db.run(sql`INSERT INTO session (id, time_updated) VALUES ('session_1', 1)`) + yield* db.run( + sql`INSERT INTO message (id, session_id, data) VALUES ('message_1', 'session_1', '{"role":"assistant","cost":1.25,"tokens":{"input":2,"output":3,"reasoning":4,"cache":{"read":5,"write":6}}}')`, + ) - yield* DatabaseMigration.applyOnly(db, [sessionUsageMigration]) + yield* DatabaseMigration.applyOnly(db, [sessionUsageMigration]) - expect( - yield* db.get( - sql`SELECT cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write FROM session WHERE id = 'session_1'`, - ), - ).toEqual({ - cost: 1.25, - tokens_input: 2, - tokens_output: 3, - tokens_reasoning: 4, - tokens_cache_read: 5, - tokens_cache_write: 6, - }) + expect( + yield* db.get( + sql`SELECT cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write FROM session WHERE id = 'session_1'`, + ), + ).toEqual({ + cost: 1.25, + tokens_input: 2, + tokens_output: 3, + tokens_reasoning: 4, + tokens_cache_read: 5, + tokens_cache_write: 6, + }) }), ) it.effect("normalizes Windows storage paths and leaves POSIX paths untouched", () => Effect.gen(function* () { - 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 session (id text PRIMARY KEY, directory text NOT NULL, path text)`) - // Windows-shaped rows (drive + backslash) must be normalized. - yield* db.run( - sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"win"}, ${"C:\\Repo\\Thing"}, ${JSON.stringify([ - "C:\\Repo\\Thing\\sandbox", - ])})`, - ) - yield* db.run( - sql`INSERT INTO session (id, directory, path) VALUES (${"win"}, ${"C:\\Repo\\Thing\\packages\\api"}, ${"packages\\api"})`, - ) - // UNC worktrees and their sandboxes must normalize too (not just drive paths). - yield* db.run( - sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"unc"}, ${"\\\\server\\share"}, ${JSON.stringify([ - "\\\\server\\share\\sandbox", - ])})`, - ) - // The "/" worktree sentinel and POSIX paths (including a pathological - // backslash in a POSIX filename) must survive byte-for-byte. - yield* db.run(sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"global"}, ${"/"}, ${"[]"})`) - yield* db.run( - sql`INSERT INTO session (id, directory, path) VALUES (${"posix"}, ${"/home/me/we\\ird"}, ${"src\\weird"})`, - ) + 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 session (id text PRIMARY KEY, directory text NOT NULL, path text)`) + // Windows-shaped rows (drive + backslash) must be normalized. + yield* db.run( + sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"win"}, ${"C:\\Repo\\Thing"}, ${JSON.stringify([ + "C:\\Repo\\Thing\\sandbox", + ])})`, + ) + yield* db.run( + sql`INSERT INTO session (id, directory, path) VALUES (${"win"}, ${"C:\\Repo\\Thing\\packages\\api"}, ${"packages\\api"})`, + ) + // UNC worktrees and their sandboxes must normalize too (not just drive paths). + yield* db.run( + sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"unc"}, ${"\\\\server\\share"}, ${JSON.stringify([ + "\\\\server\\share\\sandbox", + ])})`, + ) + // The "/" worktree sentinel and POSIX paths (including a pathological + // backslash in a POSIX filename) must survive byte-for-byte. + yield* db.run(sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"global"}, ${"/"}, ${"[]"})`) + yield* db.run( + sql`INSERT INTO session (id, directory, path) VALUES (${"posix"}, ${"/home/me/we\\ird"}, ${"src\\weird"})`, + ) - yield* DatabaseMigration.applyOnly(db, [normalizeStoragePathsMigration]) + yield* DatabaseMigration.applyOnly(db, [normalizeStoragePathsMigration]) - expect(yield* db.get(sql`SELECT worktree, sandboxes FROM project WHERE id = 'win'`)).toEqual({ - worktree: "C:/Repo/Thing", - sandboxes: JSON.stringify(["C:/Repo/Thing/sandbox"]), - }) - expect(yield* db.get(sql`SELECT directory, path FROM session WHERE id = 'win'`)).toEqual({ - directory: "C:/Repo/Thing/packages/api", - path: "packages/api", - }) - expect(yield* db.get(sql`SELECT worktree, sandboxes FROM project WHERE id = 'unc'`)).toEqual({ - worktree: "//server/share", - sandboxes: JSON.stringify(["//server/share/sandbox"]), - }) - expect(yield* db.get(sql`SELECT worktree FROM project WHERE id = 'global'`)).toEqual({ worktree: "/" }) - expect(yield* db.get(sql`SELECT directory, path FROM session WHERE id = 'posix'`)).toEqual({ - directory: "/home/me/we\\ird", - path: "src\\weird", - }) + expect(yield* db.get(sql`SELECT worktree, sandboxes FROM project WHERE id = 'win'`)).toEqual({ + worktree: "C:/Repo/Thing", + sandboxes: JSON.stringify(["C:/Repo/Thing/sandbox"]), + }) + expect(yield* db.get(sql`SELECT directory, path FROM session WHERE id = 'win'`)).toEqual({ + directory: "C:/Repo/Thing/packages/api", + path: "packages/api", + }) + expect(yield* db.get(sql`SELECT worktree, sandboxes FROM project WHERE id = 'unc'`)).toEqual({ + worktree: "//server/share", + sandboxes: JSON.stringify(["//server/share/sandbox"]), + }) + expect(yield* db.get(sql`SELECT worktree FROM project WHERE id = 'global'`)).toEqual({ worktree: "/" }) + expect(yield* db.get(sql`SELECT directory, path FROM session WHERE id = 'posix'`)).toEqual({ + directory: "/home/me/we\\ird", + path: "src\\weird", + }) }), ) it.effect("maps native Windows paths through database columns", () => { if (process.platform !== "win32") return Effect.void return Effect.gen(function* () { - const db = yield* makeDb - yield* DatabaseMigration.apply(db) - const projectID = ProjectV2.ID.make("codec_project") - const worktree = AbsolutePath.make("C:\\Repo\\Thing") - const sandbox = AbsolutePath.make("C:\\Repo\\Thing\\sandbox") - const directory = "C:\\Repo\\Thing\\packages\\api" - const sessionID = SessionSchema.ID.make("ses_codec") + const db = yield* makeDb + yield* DatabaseMigration.apply(db) + const projectID = ProjectV2.ID.make("codec_project") + const worktree = AbsolutePath.make("C:\\Repo\\Thing") + const sandbox = AbsolutePath.make("C:\\Repo\\Thing\\sandbox") + const directory = "C:\\Repo\\Thing\\packages\\api" + const sessionID = SessionSchema.ID.make("ses_codec") - expect(() => - Effect.runSync( - db - .insert(ProjectTable) - .values({ - id: ProjectV2.ID.make("invalid_path"), - worktree: AbsolutePath.make("not-absolute"), - sandboxes: [], - time_created: 1, - time_updated: 1, - }) - .run(), - ), - ).toThrow() + expect(() => + Effect.runSync( + db + .insert(ProjectTable) + .values({ + id: ProjectV2.ID.make("invalid_path"), + worktree: AbsolutePath.make("not-absolute"), + sandboxes: [], + time_created: 1, + time_updated: 1, + }) + .run(), + ), + ).toThrow() - yield* db - .insert(ProjectTable) - .values({ - id: projectID, - worktree, - sandboxes: [sandbox], - time_created: 1, - time_updated: 1, + yield* db + .insert(ProjectTable) + .values({ + id: projectID, + worktree, + sandboxes: [sandbox], + time_created: 1, + time_updated: 1, + }) + .run() + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: projectID, + slug: "codec", + directory, + path: "packages\\api", + title: "Codec", + version: "test", + time_created: 1, + time_updated: 1, + }) + .run() + + expect( + yield* db.get<{ worktree: string; sandboxes: string }>( + sql`SELECT worktree, sandboxes FROM project WHERE id = ${projectID}`, + ), + ).toEqual({ + worktree: "C:/Repo/Thing", + sandboxes: JSON.stringify(["C:/Repo/Thing/sandbox"]), }) - .run() - yield* db - .insert(SessionTable) - .values({ - id: sessionID, - project_id: projectID, - slug: "codec", - directory, - path: "packages\\api", - title: "Codec", - version: "test", - time_created: 1, - time_updated: 1, + expect( + yield* db.get<{ directory: string; path: string }>( + sql`SELECT directory, path FROM session WHERE id = ${sessionID}`, + ), + ).toEqual({ + directory: "C:/Repo/Thing/packages/api", + path: "packages/api", }) - .run() - expect( - yield* db.get<{ worktree: string; sandboxes: string }>( - sql`SELECT worktree, sandboxes FROM project WHERE id = ${projectID}`, - ), - ).toEqual({ - worktree: "C:/Repo/Thing", - sandboxes: JSON.stringify(["C:/Repo/Thing/sandbox"]), - }) - expect( - yield* db.get<{ directory: string; path: string }>( - sql`SELECT directory, path FROM session WHERE id = ${sessionID}`, - ), - ).toEqual({ - directory: "C:/Repo/Thing/packages/api", - path: "packages/api", - }) + const project = yield* db.select().from(ProjectTable).where(eq(ProjectTable.worktree, worktree)).get() + const session = yield* db.select().from(SessionTable).where(eq(SessionTable.directory, directory)).get() + expect(project?.worktree).toBe(worktree) + expect(project?.sandboxes).toEqual([sandbox]) + expect(session?.directory).toBe(directory) + expect(session?.path).toBe("packages/api") - const project = yield* db.select().from(ProjectTable).where(eq(ProjectTable.worktree, worktree)).get() - const session = yield* db.select().from(SessionTable).where(eq(SessionTable.directory, directory)).get() - expect(project?.worktree).toBe(worktree) - expect(project?.sandboxes).toEqual([sandbox]) - expect(session?.directory).toBe(directory) - expect(session?.path).toBe("packages/api") + expect((yield* db.select().from(SessionTable).where(eq(SessionTable.path, "packages\\api")).get())?.id).toBe( + sessionID, + ) - expect((yield* db.select().from(SessionTable).where(eq(SessionTable.path, "packages\\api")).get())?.id).toBe( - sessionID, - ) + const moved = AbsolutePath.make("D:\\Moved\\Thing") + const updated = yield* db + .update(ProjectTable) + .set({ worktree: moved, sandboxes: [moved] }) + .where(eq(ProjectTable.id, projectID)) + .returning() + .get() + expect(updated?.worktree).toBe(moved) + expect(updated?.sandboxes).toEqual([moved]) + expect( + yield* db.get<{ worktree: string; sandboxes: string }>( + sql`SELECT worktree, sandboxes FROM project WHERE id = ${projectID}`, + ), + ).toEqual({ worktree: "D:/Moved/Thing", sandboxes: JSON.stringify(["D:/Moved/Thing"]) }) + expect( + (yield* db + .select() + .from(ProjectTable) + .where(inArray(ProjectTable.worktree, [moved])) + .get())?.id, + ).toBe(projectID) - const moved = AbsolutePath.make("D:\\Moved\\Thing") - const updated = yield* db - .update(ProjectTable) - .set({ worktree: moved, sandboxes: [moved] }) - .where(eq(ProjectTable.id, projectID)) - .returning() - .get() - expect(updated?.worktree).toBe(moved) - expect(updated?.sandboxes).toEqual([moved]) - expect( - yield* db.get<{ worktree: string; sandboxes: string }>( - sql`SELECT worktree, sandboxes FROM project WHERE id = ${projectID}`, - ), - ).toEqual({ worktree: "D:/Moved/Thing", sandboxes: JSON.stringify(["D:/Moved/Thing"]) }) - expect( - (yield* db - .select() - .from(ProjectTable) - .where(inArray(ProjectTable.worktree, [moved])) - .get())?.id, - ).toBe(projectID) - - yield* db.run(sql`UPDATE project SET worktree = ${"not-absolute"} WHERE id = ${projectID}`) - expect(() => Effect.runSync(db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get())).toThrow() + yield* db.run(sql`UPDATE project SET worktree = ${"not-absolute"} WHERE id = ${projectID}`) + expect(() => + Effect.runSync(db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get()), + ).toThrow() }) }) it.effect("imports existing drizzle migration state", () => Effect.gen(function* () { - const db = yield* makeDb - yield* db.run( - sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`, - ) - yield* db.run(sql` + const db = yield* makeDb + yield* db.run( + sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`, + ) + yield* db.run(sql` INSERT INTO __drizzle_migrations (hash, created_at, name, applied_at) VALUES ('hash', 1, '20260127222353_familiar_lady_ursula', ${new Date().toISOString()}) `) - 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" }) }), ) it.effect("does not replay a migrated session metadata column", () => Effect.gen(function* () { - const db = yield* makeDb - yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`) - yield* db.run( - sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`, - ) - yield* db.run(sql` + const db = yield* makeDb + yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`) + yield* db.run( + sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`, + ) + yield* db.run(sql` INSERT INTO __drizzle_migrations (hash, created_at, name, applied_at) VALUES ('hash', 1, '20260511173437_session-metadata', ${new Date().toISOString()}) `) - 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" }]) }), ) it.effect("accepts the temporary replacement session metadata migration id", () => Effect.gen(function* () { - const db = yield* makeDb - 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`INSERT INTO migration (id, time_completed) VALUES ('20260530232709_lovely_romulus', 1)`) + const db = yield* makeDb + 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`INSERT INTO migration (id, time_completed) VALUES ('20260530232709_lovely_romulus', 1)`) - yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration]) + yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration]) - expect(yield* db.all(sql`SELECT id FROM migration ORDER BY id`)).toEqual([ - { id: "20260511173437_session-metadata" }, - { id: "20260530232709_lovely_romulus" }, - ]) + expect(yield* db.all(sql`SELECT id FROM migration ORDER BY id`)).toEqual([ + { id: "20260511173437_session-metadata" }, + { id: "20260530232709_lovely_romulus" }, + ]) }), ) it.effect("skips drizzle import when migration table already has state", () => Effect.gen(function* () { - const db = yield* makeDb - 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`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`, - ) - yield* db.run(sql` + const db = yield* makeDb + 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`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`, + ) + yield* db.run(sql` INSERT INTO __drizzle_migrations (hash, created_at, name, applied_at) VALUES ('hash', 1, '20260127222353_familiar_lady_ursula', ${new Date().toISOString()}) `) - 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" }]) }), ) }) From d856d92506f8c4ab6a664ca2b56e562e86adbf1d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 4 Jun 2026 21:25:46 -0400 Subject: [PATCH 10/17] refactor(core): narrow v2 context epoch scope --- AGENTS.md | 1 - .../core/test/session-runner-message.test.ts | 670 +++++++++--------- .../llm/src/protocols/anthropic-messages.ts | 1 - .../llm/src/protocols/bedrock-converse.ts | 3 +- packages/llm/src/protocols/gemini.ts | 3 +- packages/llm/src/protocols/openai-chat.ts | 3 +- .../llm/src/protocols/openai-responses.ts | 3 +- packages/llm/src/protocols/shared.ts | 20 - .../test/provider/anthropic-messages.test.ts | 14 - packages/llm/test/schema.test.ts | 30 +- specs/v2/refreshable-context-sources.md | 420 ----------- 11 files changed, 340 insertions(+), 828 deletions(-) delete mode 100644 specs/v2/refreshable-context-sources.md diff --git a/AGENTS.md b/AGENTS.md index 8880407a78..6ed0761b89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,7 +133,6 @@ const table = sqliteTable("session", { - Avoid mocks as much as possible - Test actual implementation, do not duplicate logic into tests -- In `packages/core/test`, define tests with the shared `it.effect` or `testEffect(...)` helpers from `test/lib/effect.ts`; do not use raw `test(...)`. Wrap synchronous assertions in `Effect.sync(...)`. - Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`. ## Type Checking diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 66c90583ab..9e0398c92a 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -18,74 +18,74 @@ const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat. describe("toLLMMessages", () => { it.effect("maps every top-level V2 Session message type", () => Effect.sync(() => { - const file = new FileAttachment({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" }) - 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] }, + const file = new FileAttachment({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" }) + 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 }, }), - ) - 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" }], - ]) + 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" }], + ]) }), ) @@ -109,295 +109,295 @@ describe("toLLMMessages", () => { it.effect("expands assistant tool calls and settled outcomes into canonical tool messages", () => Effect.sync(() => { - const messages = toLLMMessages( - [ - new SessionMessage.Assistant({ - id: id("assistant"), - type: "assistant", - agent: "build", - model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, - content: [ - new SessionMessage.AssistantText({ type: "text", id: "text-1", text: "Checking" }), - new SessionMessage.AssistantReasoning({ - type: "reasoning", - id: "reasoning-1", - text: "Think", - providerMetadata: { anthropic: { signature: "sig_1" } }, + const messages = toLLMMessages( + [ + new SessionMessage.Assistant({ + id: id("assistant"), + type: "assistant", + agent: "build", + model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, + content: [ + new SessionMessage.AssistantText({ type: "text", id: "text-1", text: "Checking" }), + new SessionMessage.AssistantReasoning({ + type: "reasoning", + id: "reasoning-1", + text: "Think", + providerMetadata: { anthropic: { signature: "sig_1" } }, + }), + new SessionMessage.AssistantTool({ + type: "tool", + id: "pending", + name: "read", + state: new SessionMessage.ToolStatePending({ status: "pending", input: '{"path":"README.md"}' }), + time: { created }, + }), + new SessionMessage.AssistantTool({ + type: "tool", + id: "running", + name: "read", + state: new SessionMessage.ToolStateRunning({ + status: "running", + input: { path: "README.md" }, + content: [], + structured: {}, }), - new SessionMessage.AssistantTool({ - type: "tool", - id: "pending", - name: "read", - state: new SessionMessage.ToolStatePending({ status: "pending", input: '{"path":"README.md"}' }), - time: { created }, + 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: {}, }), - new SessionMessage.AssistantTool({ - type: "tool", - id: "running", - name: "read", - state: new SessionMessage.ToolStateRunning({ - status: "running", - input: { path: "README.md" }, - content: [], - structured: {}, - }), - time: { created }, + 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: {}, }), - 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 }, + 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" }, }), - 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, - ) + 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" }, + 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", + id: "hosted", + name: "web_search", + input: { query: "Effect" }, + providerExecuted: true, + providerMetadata: { fake: { continuation: "hosted-call" } }, + }, + { + 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: {} }, }, - { - type: "tool-call", - id: "hosted", - name: "web_search", - input: { query: "Effect" }, - providerExecuted: true, - providerMetadata: { fake: { continuation: "hosted-call" } }, + }, + ]) + 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" }, + ], }, - { - 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(() => { - const messages = toLLMMessages( - [ - new SessionMessage.Assistant({ - id: id("assistant-openai-reasoning"), - type: "assistant", - agent: "build", - model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, - content: [ - new SessionMessage.AssistantReasoning({ - type: "reasoning", - id: "reasoning-openai", - text: "Think", - providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, - }), - ], - time: { created, completed: created }, - }), - ], - model, - ) + const messages = toLLMMessages( + [ + new SessionMessage.Assistant({ + id: id("assistant-openai-reasoning"), + type: "assistant", + agent: "build", + model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, + content: [ + new SessionMessage.AssistantReasoning({ + type: "reasoning", + id: "reasoning-openai", + 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" } }, - }, - ]) + 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" } }, + 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" } }, }), - 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", + 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" }, }), - 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, - ) + 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, - }, - ]) + 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, + }, + ]) }), ) }) diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index 366e110792..38f1f19bab 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -386,7 +386,6 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* ( for (const [index, message] of request.messages.entries()) { if (message.role === "system") { - yield* ProviderShared.guardSystemUpdatePlacement("Anthropic Messages", request.messages, index) if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request.messages, index)) { messages.push(yield* lowerNativeSystemUpdate(message, breakpoints)) continue diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index 097fb195e4..2b3a2e9510 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -292,9 +292,8 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* ( ) { const messages: BedrockMessage[] = [] - for (const [index, message] of request.messages.entries()) { + for (const message of request.messages) { if (message.role === "system") { - yield* ProviderShared.guardSystemUpdatePlacement("Bedrock Converse", request.messages, index) const part = yield* ProviderShared.wrappedSystemUpdate("Bedrock Converse", message) const content = textWithCache(breakpoints, part.text, part.cache) const previous = messages.at(-1) diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts index b0348074e2..93159a1b64 100644 --- a/packages/llm/src/protocols/gemini.ts +++ b/packages/llm/src/protocols/gemini.ts @@ -200,9 +200,8 @@ const lowerToolCall = (part: ToolCallPart) => ({ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) { const contents: GeminiContent[] = [] - for (const [index, message] of request.messages.entries()) { + for (const message of request.messages) { if (message.role === "system") { - yield* ProviderShared.guardSystemUpdatePlacement("Gemini", request.messages, index) const part = yield* ProviderShared.wrappedSystemUpdate("Gemini", message) const previous = contents.at(-1) if (previous?.role === "user") diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index 9f07179c3d..c0769bf1f6 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -252,9 +252,8 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: const system: OpenAIChatMessage[] = request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }] const messages = [...system] - for (const [index, message] of request.messages.entries()) { + for (const message of request.messages) { if (message.role === "system") { - yield* ProviderShared.guardSystemUpdatePlacement("OpenAI Chat", request.messages, index) const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message) const previous = messages.at(-1) if (previous?.role === "user") diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index f20dc07e7a..06d07f8ccb 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -338,9 +338,8 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ const input: OpenAIResponsesInputItem[] = [...system] const store = OpenAIOptions.store(request) - for (const [index, message] of request.messages.entries()) { + for (const message of request.messages) { if (message.role === "system") { - yield* ProviderShared.guardSystemUpdatePlacement("OpenAI Responses", request.messages, index) const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Responses", message) const previous = input.at(-1) if (previous && "role" in previous && previous.role === "user") diff --git a/packages/llm/src/protocols/shared.ts b/packages/llm/src/protocols/shared.ts index 980870952b..1bcd8d4dcb 100644 --- a/packages/llm/src/protocols/shared.ts +++ b/packages/llm/src/protocols/shared.ts @@ -177,26 +177,6 @@ export const wrappedSystemUpdate = Effect.fn("ProviderShared.wrappedSystemUpdate return { type: "text" as const, text: wrapSystemUpdate(content), cache: content.at(-1)?.cache } }) -export const guardSystemUpdatePlacement = Effect.fn("ProviderShared.guardSystemUpdatePlacement")(function* ( - route: string, - messages: LLMRequest["messages"], - index: number, -) { - const pending = new Set() - for (const message of messages.slice(0, index)) { - for (const part of message.content) { - if (message.role === "assistant" && part.type === "tool-call" && part.providerExecuted !== true) - pending.add(part.id) - if (message.role === "tool" && part.type === "tool-result") pending.delete(part.id) - } - } - if (pending.size > 0) - return yield* invalidRequest( - `${route} chronological system updates cannot appear between a local tool call and its tool result`, - ) - return yield* Effect.void -}) - /** * Parse the streamed JSON input of a tool call. Treats an empty string as * `"{}"` — providers occasionally finish a tool call without ever emitting diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index f665ff049d..3fb3cdb3b9 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -127,9 +127,6 @@ describe("Anthropic Messages route", () => { it.effect("falls back for unsupported native chronological system update placement", () => Effect.gen(function* () { - const placementError = (messages: Parameters[0]["messages"]) => - LLMClient.prepare(LLM.request({ model: opus48, messages, cache: "none" })).pipe(Effect.flip) - expect( (yield* LLMClient.prepare( LLM.request({ @@ -168,17 +165,6 @@ describe("Anthropic Messages route", () => { ], }, ]) - expect( - (yield* placementError([ - Message.user("Use the tool."), - Message.assistant([ - ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }), - { type: "text", text: "Waiting." }, - ]), - Message.system("Too early."), - Message.tool({ id: "call_1", name: "lookup", result: "Done." }), - ])).message, - ).toContain("cannot appear between a local tool call and its tool result") }), ) diff --git a/packages/llm/test/schema.test.ts b/packages/llm/test/schema.test.ts index 0dbf90f282..949d0d0681 100644 --- a/packages/llm/test/schema.test.ts +++ b/packages/llm/test/schema.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { Effect, Schema } from "effect" +import { Schema } from "effect" import * as OpenAIChat from "../src/protocols/openai-chat" import * as OpenAIResponses from "../src/protocols/openai-responses" import { @@ -10,7 +10,6 @@ import { Model, ModelID, ProviderID, - ToolCallPart, Usage, } from "../src/schema" import { ProviderShared } from "../src/protocols/shared" @@ -64,33 +63,6 @@ describe("llm schema", () => { expect(decoded.messages[0]).toMatchObject({ role: "system", content: [{ type: "text", text: "Operator update." }] }) }) - test("rejects chronological system updates between a local tool call and its result", async () => { - const messages = [ - Message.assistant([ - ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }), - { type: "text", text: "Waiting." }, - ]), - ] - - await expect(Effect.runPromise(ProviderShared.guardSystemUpdatePlacement("Test", messages, 1))).rejects.toThrow( - "Test chronological system updates cannot appear between a local tool call and its tool result", - ) - }) - - test("rejects chronological system updates between results for multiple local tool calls", async () => { - const messages = [ - Message.assistant([ - ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }), - ToolCallPart.make({ id: "call_2", name: "lookup", input: {} }), - ]), - Message.tool({ id: "call_1", name: "lookup", result: "first" }), - ] - - await expect(Effect.runPromise(ProviderShared.guardSystemUpdatePlacement("Test", messages, 2))).rejects.toThrow( - "Test chronological system updates cannot appear between a local tool call and its tool result", - ) - }) - test("rejects invalid event type", () => { expect(() => decodeLLMEvent({ type: "bogus" })).toThrow() }) diff --git a/specs/v2/refreshable-context-sources.md b/specs/v2/refreshable-context-sources.md deleted file mode 100644 index 7e21a245f0..0000000000 --- a/specs/v2/refreshable-context-sources.md +++ /dev/null @@ -1,420 +0,0 @@ -# Refreshable Context Sources - -## Status - -Reviewed proposal for ambient `AGENTS.md`, configured instruction paths or URLs, and later local skill-source invalidation. - -## Decision Summary - -Context-source observation remains pull-based and lazy at a safe provider-turn boundary. - -```text -source signal --> mark an optional observation cache stale --> next naturally scheduled safe provider-turn boundary observes current state --> Context Epoch compares and admits exact changed bytes durably -``` - -The first ambient `AGENTS.md` slice will not depend on filesystem watching. Its scoped contributor will directly observe local instruction state whenever `SystemContextRegistry.load()` naturally runs before a provider turn. - -Watcher-backed caches are a later efficiency optimization for roots with proven subscription coverage. URLs remain separate observations with an independently chosen refresh policy. - -## Existing Pieces - -| Existing piece | Responsibility | -| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| `Watcher.locationLayer` | Publish advisory `file.watcher.updated` events for local filesystem changes. | -| `EventV2.subscribe(...)` | Expose advisory events as scoped Effect streams. | -| `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. | -| `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. | -| `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. - -## Primitive: `Refreshable` - -Place the optional coordination helper at: - -```text -packages/core/src/effect/refreshable.ts -``` - -`Refreshable` does not know about files, URLs, timers, watchers, Sessions, Context Epochs, or stale fallbacks. Domain services decide when to use it and how to recover expected observation failures. - -```ts -export interface Refreshable { - readonly get: Effect.Effect - readonly invalidate: Effect.Effect -} - -export const make = ( - load: Effect.Effect, -): Effect.Effect> -``` - -Internal state: - -```ts -type State = - | { readonly _tag: "Empty" } - | { readonly _tag: "Fresh"; readonly value: A } - | { readonly _tag: "Stale"; readonly value: A } -``` - -Implementation substrate: - -```text -SynchronizedRef.modifyEffect(...) -``` - -Semantics: - -| Operation | Behavior | -| ---------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| `get` on `Empty` | Run `load`, store `Fresh(value)` only after success, and return it. | -| `get` on `Fresh(value)` | Return the cached value without I/O. | -| `get` on `Stale(previous)` | Run `load`, store `Fresh(value)` only after success, and return it. | -| `invalidate` on `Fresh(value)` | Store `Stale(value)`. | -| `invalidate` on `Empty` or `Stale` | No-op. Repeated invalidations coalesce. | -| failed `load` | Preserve the prior `Empty` or `Stale(previous)` state, propagate the failure, and retry on the next `get`. | -| invalidation during `load` | Serialize after the reload and leave the refreshed cache stale for the next `get`. | - -```mermaid -stateDiagram-v2 - [*] --> Empty - Empty --> Fresh: get / load succeeds - Empty --> Empty: get / load fails - Fresh --> Fresh: get / return cached - Fresh --> Stale: invalidate - Stale --> Fresh: get / reload succeeds - Stale --> Stale: invalidate or reload fails -``` - -### Why Custom Instead Of Existing Effect Caches - -Effect `Cache`, `ScopedCache`, and `Effect.cachedInvalidateWithTTL(...)` cache failed exits. Effect `Resource` preserves its prior value after a failed refresh, but eagerly acquires and does not reload lazily after explicit invalidation. Context-source observation requires the narrower lazy `Empty` / `Fresh` / `Stale` rule: - -```text -failed refresh --> retain prior successful value internally --> remain stale --> retry at the next natural request -``` - -`SynchronizedRef.modifyEffect(...)` commits the next state only after the refresh effect succeeds. - -### Why No `peek` - -`Refreshable` should not expose stale fallback reads. A domain service that needs a previous discovery graph during failed rescans should own that graph explicitly in its observation model. - -### Why No Refresh Modes Or TTL - -An always-refreshed source does not need a cache: load it directly. TTL expiry, watcher events, and explicit source changes are external invalidation policies that may call `invalidate` later. - -```mermaid -sequenceDiagram - participant Signal as Optional Invalidation Source - participant Cache as Refreshable - participant Consumer - participant Loader - - Signal->>Cache: invalidate - Consumer->>Cache: get - Cache->>Loader: reload only when stale - Loader-->>Cache: successful coherent value - Cache-->>Consumer: refreshed value -``` - -## Observation Units - -Compose refreshables around coherent observations that share one invalidation policy. Do not create one uniformly per rendered Context Source or one aggregate cache for unrelated source kinds. - -```text -local built-in discovery --> one coherent observation while it shares one refresh policy - -configured local glob --> separate observation when its scan root or coverage differs - -configured URL --> independent observation - -local skill directory --> naturally one observation per registered source - -embedded skill --> direct value, no refreshable -``` - -## Ambient Instruction Contributor - -Add a Location-scoped contributor to `SystemContextRegistry`: - -```ts -yield * - registry.contribute({ - 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. - -The first slice closes one coherent ordered instruction set into an aggregate source: - -```text -core/instructions --> [{ path, content }, ...] -``` - -Rendered text retains the human-readable source identity: - -```text -Instructions from: /repo/packages/core/AGENTS.md - -``` - -or: - -```text -Instructions from: https://example.com/shared-agents.md - -``` - -The first implementation directly observes global and upward project `AGENTS.md` files on every safe provider-turn boundary. It does not use `Refreshable` yet unless a coherent source observation needs stale-on-failure retention. - -```mermaid -sequenceDiagram - participant Runner as Safe Provider Boundary - participant Registry as System Context Registry - participant Instructions as Instruction Context - participant Files - participant Epoch as Context Epoch - - Runner->>Registry: load - Registry->>Instructions: run contribution - Instructions->>Files: discover and read AGENTS.md files - Files-->>Instructions: coherent current observation - Instructions-->>Registry: instruction SystemContext - Registry-->>Runner: composed SystemContext - Runner->>Epoch: compare and durably admit changes -``` - -## Source Outcomes - -Discovery and file reads form one coherent aggregate observation in the first slice. - -```text -successful discovery and reads --> one ordered aggregate instruction value - -temporary discovery or read failure --> aggregate SystemContext.unavailable -``` - -| Observation | Source outcome | -| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -| 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 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. | -| URL returns `2xx` body | Available source with exact contents. | -| URL times out or returns transient failure | `SystemContext.unavailable`. | -| URL returns `404` or `410` | Decide the explicit removal contract before URL implementation. | - -Aggregate instruction removal text must be model-meaningful: - -```text -Previously loaded instructions no longer apply. -``` - -## First Ambient Slice - -Implement only: - -```text -global config AGENTS.md -+ upward project AGENTS.md ancestors -+ one aggregate core/instructions source -+ direct safe-turn observation -``` - -Preserve V1 ancestor stacking for `AGENTS.md`: nearest ancestor first, then outward through the project boundary. - -Do not include yet: - -```text -CLAUDE.md compatibility fallback -deprecated CONTEXT.md fallback -configured local paths or globs -configured URLs -watcher-backed caching -skills migration -nested read-triggered discovery -``` - -Tests: - -```text -initial baseline -edit -newly added ancestor AGENTS.md -confirmed unlink with meaningful removal text -empty file -transient scan failure -transient file-read failure -deterministic ordering -restart with durable structured snapshots -``` - -## Future Watcher Optimization - -Watchers remain advisory optimizations. They never wake idle Sessions and never publish durable Session context events. - -The current watcher subscribes only to `location.directory`, has ignore rules, starts asynchronously, and swallows subscription failures. Effective source roots may live elsewhere. Do not expose one broad `Watcher.Service.local: "watching" | "poll"` flag. - -Add root-specific watching only when needed: - -```ts -export interface Watcher.Interface { - readonly watch: (root: AbsolutePath) => Effect.Effect<"watching" | "poll"> -} -``` - -The exact watcher API remains a follow-up design. A truthful contract must account for: - -- successful subscription startup; -- subscription failure and callback error; -- source roots above or outside `location.directory`; -- ignore and protected-path coverage; -- scope cleanup; -- own-process mutations that should synchronously invalidate caches before the next continuation turn. - -When root coverage is proven: - -```mermaid -sequenceDiagram - participant Watcher - participant Cache as Refreshable - participant Runner - participant Epoch as Context Epoch - - Watcher->>Cache: relevant add / change / unlink - Cache->>Cache: invalidate only - Runner->>Cache: get at next safe boundary - Cache-->>Runner: refreshed coherent observation - Runner->>Epoch: durably admit exact changes -``` - -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 --> 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 - -URLs never share an observation cache with local discovery. - -Start with direct safe-turn loading: - -```text -safe provider-turn boundary --> fetch URL --> emit available or unavailable source -``` - -If measurements show excessive requests, add a URL-specific invalidation policy later: - -```text -TTL expires --> invalidate URL Refreshable --> next safe provider-turn boundary reloads URL -``` - -A TTL timer must not wake idle Sessions or publish durable Session events. - -## Skills Reuse - -`SkillV2` currently stores replayable source registrations through `State.create(...)` and materialized source results in a raw permanent `Map`. - -Use `Refreshable` later for local directory sources only after skill observation distinguishes confirmed absence from transient failure: - -```text -State.create --> current replayable Source registrations - -private Map --> reconcile against active non-embedded source keys --> create refreshable for added source --> drop refreshable for removed source -``` - -Do not add a generic `State -> cache invalidation` bridge. The skill service owns both current registrations and its observation-cache lifecycle. - -Remote skill refresh remains a separate design slice because the current puller skips files that already exist and therefore does not define overwrite or removal semantics. - -## Nested Instruction Discovery - -Nested instructions discovered after successful read-tool activity remain a Session-scoped follow-up. - -- The Location-scoped instruction service may resolve and reuse source observations. -- The set of nested source identities active for one Session must be durable and Session-scoped. -- Successful local file reads record newly observed nested source identities through synchronized Session events. -- The next safe provider boundary loads and admits them through Context Epoch history. -- Do not inject V1-style reminder text directly into read-tool output. -- Do not activate nested discovery for directory reads, managed tool-output resources, or external references until those semantics are explicitly designed. - -## Lifecycle - -- Location scope owns the System Context Registry, scoped context contributions, optional watcher-consumer fibers, and refreshable state. -- `Effect.forkScoped(...)` interrupts watcher-consumer fibers when the cached Location runtime is disposed. -- Stream finalization unsubscribes `EventV2` PubSub subscriptions. -- `Watcher.locationLayer` separately finalizes native Parcel watcher subscriptions. -- Repeated invalidations coalesce into one `Stale` state. -- 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. - -## 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`. -2. Add truthful root-specific watcher registration. -3. Move ambient instructions from one directly observed aggregate to one watcher-invalidated Refreshable and Context Source per candidate file. -4. Add configured local exact paths and globs. -5. Add configured URL observations with explicit `404` and `410` semantics. -6. Migrate local `SkillV2` directory observations to per-source refreshables after skill failure semantics are corrected. -7. Add durable Session-scoped nested read discovery. - -## Open Questions - -1. 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. Should own-process file mutations publish an advisory invalidation event synchronously after commit? - -## Compression Line - -```text -SystemContextRegistry remembers which context producers participate. -Refreshable remembers whether a successful observation needs loading again. -Context Epoch remembers what the model was told. -``` From 8cb02ba9cf76abbda9fbf50d913b78f0cb67b776 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 4 Jun 2026 21:32:37 -0400 Subject: [PATCH 11/17] refactor(core): trim v2 context epoch scope --- packages/core/src/event.ts | 8 +- packages/core/src/location-layer.ts | 2 +- packages/core/src/plugin/boot.ts | 4 - packages/core/src/session/runner/llm.ts | 12 +- packages/core/test/database-migration.test.ts | 153 ++++++++++-------- .../openai-chat-streams-text.json | 2 +- .../core/test/session-runner-message.test.ts | 60 +++---- .../core/test/session-runner-recorded.test.ts | 24 +-- packages/core/test/session-runner.test.ts | 34 ---- ...haiku-4-5-chronological-system-update.json | 33 ---- ...2-5-flash-chronological-system-update.json | 32 ---- ...t-4o-mini-chronological-system-update.json | 32 ---- ...s-gpt-5-5-chronological-system-update.json | 39 ----- .../llm/test/provider/golden.recorded.test.ts | 6 +- packages/llm/test/recorded-scenarios.ts | 27 ---- packages/llm/test/schema.test.ts | 12 -- .../opencode/test/cli/tui/sync-v2.test.tsx | 48 ------ 17 files changed, 117 insertions(+), 411 deletions(-) delete mode 100644 packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-haiku-4-5-chronological-system-update.json delete mode 100644 packages/llm/test/fixtures/recordings/gemini/gemini-2-5-flash-chronological-system-update.json delete mode 100644 packages/llm/test/fixtures/recordings/openai-chat/openai-chat-gpt-4o-mini-chronological-system-update.json delete mode 100644 packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-chronological-system-update.json diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 1362bf1e41..0ad0714e98 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -381,10 +381,10 @@ export const layerWith = (options?: LayerOptions) => }) } - function publishEvent(event: Payload, options?: PublishOptions) { + function publishEvent(event: Payload, commit?: PublishOptions["commit"]) { return Effect.gen(function* () { const durable = registry.get(event.type)?.sync !== undefined - if (!durable && options?.commit) + if (!durable && commit) return yield* Effect.die( new InvalidSyncEventError({ type: event.type, @@ -392,7 +392,7 @@ export const layerWith = (options?: LayerOptions) => }), ) if (durable) { - const committed = yield* commitSyncEvent(event as Payload, undefined, options?.commit) + const committed = yield* commitSyncEvent(event as Payload, undefined, commit) if (committed) { event = { ...event, seq: committed.seq } yield* Effect.forEach(syncHandlers, (sync) => observe(event as Payload, "sync", sync), { discard: true }) @@ -446,7 +446,7 @@ export const layerWith = (options?: LayerOptions) => ...(location ? { location } : {}), data, } as Payload, - options, + options?.commit, ) }) } diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index d6c01e1c86..c1621c404f 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -57,7 +57,7 @@ export class LocationServiceMap extends LayerMap.Service()(" Catalog.locationLayer, CommandV2.locationLayer, AgentV2.locationLayer, - PluginBoot.locationLayer.pipe(Layer.provide(systemContext)), + PluginBoot.locationLayer, FileSystem.locationLayer, Watcher.locationLayer, Pty.locationLayer, diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index 7cdc54fcf2..6a589a1ef0 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -25,7 +25,6 @@ import { EnvPlugin } from "./env" import { ModelsDevPlugin } from "./models-dev" import { ProviderPlugins } from "./provider" import { SkillV2 } from "../skill" -import { SystemContextRegistry } from "../system-context-registry" type Plugin = { id: PluginV2.ID @@ -43,7 +42,6 @@ type Plugin = { | Config.Service | ModelsDev.Service | SkillV2.Service - | SystemContextRegistry.Service > } @@ -69,7 +67,6 @@ export const layer = Layer.effect( const fs = yield* FSUtil.Service const global = yield* Global.Service const skill = yield* SkillV2.Service - const systemContext = yield* SystemContextRegistry.Service const done = yield* Deferred.make() const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) { @@ -89,7 +86,6 @@ export const layer = Layer.effect( Effect.provideService(Global.Service, global), Effect.provideService(SkillV2.Service, skill), Effect.provideService(PluginV2.Service, plugin), - Effect.provideService(SystemContextRegistry.Service, systemContext), ), }) }) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 3eda133e4c..b628861033 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -98,13 +98,6 @@ export const layer = Layer.effect( const getContext = Effect.fn("SessionRunner.getContext")(function* (sessionID: SessionSchema.ID) { return yield* store.context(sessionID) }) - const getRunnerContext = Effect.fn("SessionRunner.getRunnerContext")(function* ( - sessionID: SessionSchema.ID, - baselineSeq: number, - ) { - return yield* store.runnerContext(sessionID, baselineSeq) - }) - const failInterruptedTools = Effect.fn("SessionRunner.failInterruptedTools")(function* ( sessionID: SessionSchema.ID, ) { @@ -152,7 +145,7 @@ export const layer = Layer.effect( } } const system = initialized ?? (yield* SessionContextEpoch.prepare(db, events, systemContext, session.id)) - const context = yield* getRunnerContext(session.id, system.baselineSeq) + const context = yield* store.runnerContext(session.id, system.baselineSeq) const request = LLM.request({ model, system: system.baseline.length > 0 ? [SystemPart.make(system.baseline)] : [], @@ -251,7 +244,6 @@ export const layer = Layer.effect( readonly sessionID: SessionSchema.ID readonly force?: boolean }) { - const session = yield* getSession(input.sessionID) const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer") const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue") if (input.force !== true && !hasSteer && !hasQueue) return @@ -261,7 +253,7 @@ export const layer = Layer.effect( while (openActivity) { let needsContinuation = true for (let step = 0; step < MAX_STEPS; step++) { - needsContinuation = yield* runTurn(session.id, promotion) + needsContinuation = yield* runTurn(input.sessionID, promotion) promotion = "steer" if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer") if (!needsContinuation) break diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 2cd5510be7..52c1e920ad 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -1,4 +1,4 @@ -import { describe, expect } from "bun:test" +import { describe, expect, test } from "bun:test" import { $ } from "bun" import { fileURLToPath } from "url" import path from "path" @@ -18,45 +18,43 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionSchema } from "@opencode-ai/core/session/schema" import { SessionTable } from "@opencode-ai/core/session/sql" 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 { tmpdir } from "./fixture/tmpdir" -import { testEffect } from "./lib/effect" + +const run = (effect: Effect.Effect) => + Effect.runPromise( + effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped), + ) const makeDb = EffectDrizzleSqlite.makeWithDefaults() -const it = testEffect(SqliteClient.layer({ filename: ":memory:", disableWAL: true })) describe("DatabaseMigration", () => { - it.effect("serializes concurrent embedded initialization for one database path", () => - Effect.promise(async () => { - await using tmp = await tmpdir() - const filename = path.join(tmp.path, "embedded.sqlite") - const layers = [Database.layerFromPath(filename), Database.layerFromPath(filename)] + test("serializes concurrent embedded initialization for one database path", async () => { + await using tmp = await tmpdir() + const filename = path.join(tmp.path, "embedded.sqlite") + const layers = [Database.layerFromPath(filename), Database.layerFromPath(filename)] - await Effect.runPromise( - Effect.all( - layers.map((layer) => Effect.scoped(Layer.build(layer))), - { concurrency: "unbounded" }, - ), - ) - }), - ) - if (process.platform === "linux") { - it.effect( - "declared schema has no ungenerated migrations", - () => - Effect.promise(async () => { - const result = await $`bun ${fileURLToPath(new URL("../script/migration.ts", import.meta.url))} --check` - .quiet() - .nothrow() - expect(result.exitCode, result.stderr.toString()).toBe(0) - expect(result.stdout.toString()).toContain("No schema changes, nothing to migrate") - }), - 30_000, + await Effect.runPromise( + Effect.all( + layers.map((layer) => Effect.scoped(Layer.build(layer))), + { concurrency: "unbounded" }, + ), ) + }) + if (process.platform === "linux") { + test("declared schema has no ungenerated migrations", async () => { + const result = await $`bun ${fileURLToPath(new URL("../script/migration.ts", import.meta.url))} --check` + .quiet() + .nothrow() + expect(result.exitCode, result.stderr.toString()).toBe(0) + expect(result.stdout.toString()).toContain("No schema changes, nothing to migrate") + }, 30_000) } - it.effect("applies tracked migrations to an empty database", () => - Effect.gen(function* () { + test("applies tracked migrations to an empty database", async () => { + await run( + Effect.gen(function* () { const db = yield* makeDb yield* DatabaseMigration.apply(db) @@ -84,11 +82,13 @@ describe("DatabaseMigration", () => { { name: "session_message_session_time_created_id_idx" }, { name: "session_message_session_type_seq_idx" }, ]) - }), - ) + }), + ) + }) - it.effect("resets beta history and rebuilds event-sourced Session input storage", () => - Effect.gen(function* () { + test("resets beta history and rebuilds event-sourced Session input storage", async () => { + await run( + Effect.gen(function* () { const db = yield* makeDb 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)`) @@ -158,11 +158,13 @@ describe("DatabaseMigration", () => { expect.objectContaining({ name: "session_input_session_promoted_seq_idx", unique: 1 }), expect.objectContaining({ name: "session_input_session_admitted_seq_idx", unique: 1 }), ]) - }), - ) + }), + ) + }) - it.effect("resets incompatible projected Session messages before adding sequence order", () => - Effect.gen(function* () { + test("resets incompatible projected Session messages before adding sequence order", async () => { + await run( + Effect.gen(function* () { const db = yield* makeDb yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`) yield* db.run( @@ -211,11 +213,13 @@ 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, '{}')`, ) expect(yield* db.get(sql`SELECT id, seq FROM session_message`)).toEqual({ id: "fresh_projection", seq: 7 }) - }), - ) + }), + ) + }) - it.effect("runs session usage backfill in order with schema changes", () => - Effect.gen(function* () { + test("runs session usage backfill in order with schema changes", async () => { + await run( + Effect.gen(function* () { 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 message (id text PRIMARY KEY, session_id text NOT NULL, data text NOT NULL)`) @@ -238,11 +242,13 @@ describe("DatabaseMigration", () => { tokens_cache_read: 5, tokens_cache_write: 6, }) - }), - ) + }), + ) + }) - it.effect("normalizes Windows storage paths and leaves POSIX paths untouched", () => - Effect.gen(function* () { + test("normalizes Windows storage paths and leaves POSIX paths untouched", async () => { + await run( + Effect.gen(function* () { 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 session (id text PRIMARY KEY, directory text NOT NULL, path text)`) @@ -287,12 +293,14 @@ describe("DatabaseMigration", () => { directory: "/home/me/we\\ird", path: "src\\weird", }) - }), - ) + }), + ) + }) - it.effect("maps native Windows paths through database columns", () => { - if (process.platform !== "win32") return Effect.void - return Effect.gen(function* () { + test("maps native Windows paths through database columns", async () => { + if (process.platform !== "win32") return + await run( + Effect.gen(function* () { const db = yield* makeDb yield* DatabaseMigration.apply(db) const projectID = ProjectV2.ID.make("codec_project") @@ -395,11 +403,13 @@ describe("DatabaseMigration", () => { expect(() => Effect.runSync(db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get()), ).toThrow() - }) + }), + ) }) - it.effect("imports existing drizzle migration state", () => - Effect.gen(function* () { + test("imports existing drizzle migration state", async () => { + await run( + Effect.gen(function* () { const db = yield* makeDb yield* db.run( sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`, @@ -412,11 +422,13 @@ describe("DatabaseMigration", () => { yield* DatabaseMigration.applyOnly(db, []) expect(yield* db.get(sql`SELECT id FROM migration`)).toEqual({ id: "20260127222353_familiar_lady_ursula" }) - }), - ) + }), + ) + }) - it.effect("does not replay a migrated session metadata column", () => - Effect.gen(function* () { + test("does not replay a migrated session metadata column", async () => { + await run( + Effect.gen(function* () { const db = yield* makeDb yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`) yield* db.run( @@ -430,11 +442,13 @@ describe("DatabaseMigration", () => { yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration]) expect(yield* db.all(sql`SELECT id FROM migration`)).toEqual([{ id: "20260511173437_session-metadata" }]) - }), - ) + }), + ) + }) - it.effect("accepts the temporary replacement session metadata migration id", () => - Effect.gen(function* () { + test("accepts the temporary replacement session metadata migration id", async () => { + await run( + Effect.gen(function* () { const db = yield* makeDb 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)`) @@ -446,11 +460,13 @@ describe("DatabaseMigration", () => { { id: "20260511173437_session-metadata" }, { id: "20260530232709_lovely_romulus" }, ]) - }), - ) + }), + ) + }) - it.effect("skips drizzle import when migration table already has state", () => - Effect.gen(function* () { + test("skips drizzle import when migration table already has state", async () => { + await run( + Effect.gen(function* () { const db = yield* makeDb 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)`) @@ -465,6 +481,7 @@ describe("DatabaseMigration", () => { yield* DatabaseMigration.applyOnly(db, []) expect(yield* db.all(sql`SELECT id FROM migration ORDER BY id`)).toEqual([{ id: "existing" }]) - }), - ) + }), + ) + }) }) diff --git a/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json b/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json index 0e452bb618..bad659f93e 100644 --- a/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json +++ b/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json @@ -13,7 +13,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Recorded context\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}" + "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}" }, "response": { "status": 200, diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 9e0398c92a..47b018fa12 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -1,4 +1,4 @@ -import { describe, expect } from "bun:test" +import { describe, expect, test } from "bun:test" import { Message, Model } from "@opencode-ai/llm" import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat" import { ModelV2 } from "@opencode-ai/core/model" @@ -8,16 +8,14 @@ import { AgentAttachment, FileAttachment, ReferenceAttachment } from "@opencode- import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message" import { SessionV2 } from "@opencode-ai/core/session" import { ToolOutput } from "@opencode-ai/core/tool-output" -import { DateTime, Effect } from "effect" -import { it } from "./lib/effect" +import { DateTime } from "effect" const created = DateTime.makeUnsafe(0) const id = (value: string) => SessionMessage.ID.make(`msg_${value}`) const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route }) describe("toLLMMessages", () => { - it.effect("maps every top-level V2 Session message type", () => - Effect.sync(() => { + test("maps every top-level V2 Session message type", () => { const file = new FileAttachment({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" }) const reference = new ReferenceAttachment({ name: "docs", kind: "local", uri: "file:///docs" }) const messages = toLLMMessages( @@ -34,6 +32,12 @@ describe("toLLMMessages", () => { model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, time: { created }, }), + new SessionMessage.System({ + id: id("system"), + type: "system", + text: "Updated context\n\nOther context", + time: { created }, + }), new SessionMessage.User({ id: id("user"), type: "user", @@ -69,8 +73,9 @@ describe("toLLMMessages", () => { model, ) - expect(messages.map((message) => message.role)).toEqual(["user", "user", "user", "user"]) - expect(messages[0]).toEqual( + expect(messages.map((message) => message.role)).toEqual(["system", "user", "user", "user", "user"]) + expect(messages[0]).toEqual(Message.system("Updated context\n\nOther context")) + expect(messages[1]).toEqual( Message.make({ id: id("user"), role: "user", @@ -81,34 +86,14 @@ describe("toLLMMessages", () => { metadata: { agents: [{ name: "build" }], references: [reference] }, }), ) - expect(messages.slice(1).map((message) => message.content)).toEqual([ + expect(messages.slice(2).map((message) => message.content)).toEqual([ [{ type: "text", text: "Synthetic context" }], [{ type: "text", text: "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 }, - }), - ], - model, - ), - ).toEqual([Message.system("Updated context\n\nOther context")]) - }), - ) - - it.effect("expands assistant tool calls and settled outcomes into canonical tool messages", () => - Effect.sync(() => { + test("expands assistant tool calls and settled outcomes into canonical tool messages", () => { const messages = toLLMMessages( [ new SessionMessage.Assistant({ @@ -263,11 +248,9 @@ describe("toLLMMessages", () => { }, }, ]) - }), - ) + }) - it.effect("restores OpenAI encrypted reasoning metadata", () => - Effect.sync(() => { + test("restores OpenAI encrypted reasoning metadata", () => { const messages = toLLMMessages( [ new SessionMessage.Assistant({ @@ -296,11 +279,9 @@ describe("toLLMMessages", () => { providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, }, ]) - }), - ) + }) - it.effect("drops provider-native continuation metadata after a model switch", () => - Effect.sync(() => { + test("drops provider-native continuation metadata after a model switch", () => { const messages = toLLMMessages( [ new SessionMessage.Assistant({ @@ -398,6 +379,5 @@ describe("toLLMMessages", () => { providerMetadata: undefined, }, ]) - }), - ) + }) }) diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 99410e6efd..376b2e3452 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -19,11 +19,10 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" -import { SystemContext } from "@opencode-ai/core/system-context" import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry" import { describe, expect } from "bun:test" import { eq } from "drizzle-orm" -import { Effect, Layer, Schema } from "effect" +import { Effect, Layer } from "effect" import path from "node:path" import { testEffect } from "./lib/effect" @@ -57,26 +56,7 @@ const model = OpenAIChat.route }) .model({ id: "gpt-4o-mini" }) const models = SessionRunnerModel.layerWith(() => Effect.succeed(model)) -const systemContextKey = SystemContext.Key.make("test/context") -const systemContext = Layer.effectDiscard( - SystemContextRegistry.Service.pipe( - Effect.flatMap((registry) => - registry.contribute({ - key: systemContextKey, - load: Effect.succeed( - SystemContext.make({ - key: systemContextKey, - codec: Schema.toCodecJson(Schema.String), - load: Effect.succeed("Recorded context"), - baseline: String, - update: (_previous, current) => current, - removed: () => "Recorded context removed", - }), - ), - }), - ), - ), -).pipe(Layer.provideMerge(SystemContextRegistry.layer)) +const systemContext = SystemContextRegistry.layer const runner = SessionRunnerLLM.defaultLayer.pipe( Layer.provide(database), Layer.provide(store), diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 10a87130be..1457daecca 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -823,40 +823,6 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("replays retained context projections after multiple replacements", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) - - requests.length = 0 - response = [] - yield* session.resume(sessionID) - yield* events.publish(SessionEvent.ModelSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(1), - model: { id: ModelV2.ID.make("replacement-1"), providerID: ProviderV2.ID.make("fake") }, - }) - systemBaseline = "Replacement context 1" - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) - yield* session.resume(sessionID) - yield* events.publish(SessionEvent.ModelSwitched, { - sessionID, - messageID: SessionMessage.ID.create(), - timestamp: DateTime.makeUnsafe(2), - model: { id: ModelV2.ID.make("replacement-2"), providerID: ProviderV2.ID.make("fake") }, - }) - systemBaseline = "Replacement context 2" - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false }) - yield* session.resume(sessionID) - - yield* replaySessionProjection(sessionID) - expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Replacement context 2"]) - }), - ) - it.effect("replaces the baseline lazily after completed compaction without reopening replacement on replay", () => Effect.gen(function* () { yield* setup diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-haiku-4-5-chronological-system-update.json b/packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-haiku-4-5-chronological-system-update.json deleted file mode 100644 index 72e4d7b21b..0000000000 --- a/packages/llm/test/fixtures/recordings/anthropic-messages/anthropic-haiku-4-5-chronological-system-update.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "version": 1, - "metadata": { - "name": "anthropic-messages/anthropic-haiku-4-5-chronological-system-update", - "recordedAt": "2026-06-04T19:14:47.473Z", - "provider": "anthropic", - "route": "anthropic-messages", - "transport": "http", - "model": "claude-haiku-4-5-20251001", - "tags": ["prefix:anthropic-messages", "provider:anthropic", "system", "chronological-system-update", "golden"] - }, - "interactions": [ - { - "transport": "http", - "request": { - "method": "POST", - "url": "https://api.anthropic.com/v1/messages", - "headers": { - "anthropic-version": "2023-06-01", - "content-type": "application/json" - }, - "body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"Follow the latest instruction exactly. Reply only with the requested word.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use the latest instruction for your reply.\"},{\"type\":\"text\",\"text\":\"\\nFor this reply, respond exactly with: UPDATED\\n\"}]}],\"stream\":true,\"max_tokens\":80,\"temperature\":0}" - }, - "response": { - "status": 200, - "headers": { - "content-type": "text/event-stream; charset=utf-8" - }, - "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01QxsUPda7mfZ9zGSgw4JD54\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":49,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":5,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"UPDATED\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":49,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":5} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" - } - } - ] -} diff --git a/packages/llm/test/fixtures/recordings/gemini/gemini-2-5-flash-chronological-system-update.json b/packages/llm/test/fixtures/recordings/gemini/gemini-2-5-flash-chronological-system-update.json deleted file mode 100644 index c4fd142fc7..0000000000 --- a/packages/llm/test/fixtures/recordings/gemini/gemini-2-5-flash-chronological-system-update.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "version": 1, - "metadata": { - "name": "gemini/gemini-2-5-flash-chronological-system-update", - "recordedAt": "2026-06-04T19:14:48.065Z", - "provider": "google", - "route": "gemini", - "transport": "http", - "model": "gemini-2.5-flash", - "tags": ["prefix:gemini", "provider:google", "system", "chronological-system-update", "golden"] - }, - "interactions": [ - { - "transport": "http", - "request": { - "method": "POST", - "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", - "headers": { - "content-type": "application/json" - }, - "body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Use the latest instruction for your reply.\"},{\"text\":\"\\nFor this reply, respond exactly with: UPDATED\\n\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"Follow the latest instruction exactly. Reply only with the requested word.\"}]},\"generationConfig\":{\"maxOutputTokens\":80,\"temperature\":0,\"thinkingConfig\":{\"thinkingBudget\":0}}}" - }, - "response": { - "status": 200, - "headers": { - "content-type": "text/event-stream" - }, - "body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"UPDATED\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 44,\"candidatesTokenCount\": 2,\"totalTokenCount\": 46,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 44}],\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"p84hasmqJL2I-sAPtZnUkA0\"}\r\n\r\n" - } - } - ] -} diff --git a/packages/llm/test/fixtures/recordings/openai-chat/openai-chat-gpt-4o-mini-chronological-system-update.json b/packages/llm/test/fixtures/recordings/openai-chat/openai-chat-gpt-4o-mini-chronological-system-update.json deleted file mode 100644 index 4c01f328d7..0000000000 --- a/packages/llm/test/fixtures/recordings/openai-chat/openai-chat-gpt-4o-mini-chronological-system-update.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "version": 1, - "metadata": { - "name": "openai-chat/openai-chat-gpt-4o-mini-chronological-system-update", - "recordedAt": "2026-06-04T19:14:43.750Z", - "provider": "openai", - "route": "openai-chat", - "transport": "http", - "model": "gpt-4o-mini", - "tags": ["prefix:openai-chat", "provider:openai", "system", "chronological-system-update", "golden"] - }, - "interactions": [ - { - "transport": "http", - "request": { - "method": "POST", - "url": "https://api.openai.com/v1/chat/completions", - "headers": { - "content-type": "application/json" - }, - "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Follow the latest instruction exactly. Reply only with the requested word.\"},{\"role\":\"user\",\"content\":\"Use the latest instruction for your reply.\\n\\nFor this reply, respond exactly with: UPDATED\\n\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"max_tokens\":80,\"temperature\":0}" - }, - "response": { - "status": 200, - "headers": { - "content-type": "text/event-stream; charset=utf-8" - }, - "body": "data: {\"id\":\"chatcmpl-Dn7oZjOtsXXzajgbNQ17HQXOPABbd\",\"object\":\"chat.completion.chunk\",\"created\":1780600483,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_e0de90b008\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"uIKJFKJXo\"}\n\ndata: {\"id\":\"chatcmpl-Dn7oZjOtsXXzajgbNQ17HQXOPABbd\",\"object\":\"chat.completion.chunk\",\"created\":1780600483,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_e0de90b008\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"UPDATED\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"6HB3\"}\n\ndata: {\"id\":\"chatcmpl-Dn7oZjOtsXXzajgbNQ17HQXOPABbd\",\"object\":\"chat.completion.chunk\",\"created\":1780600483,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_e0de90b008\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"UlWDE\"}\n\ndata: {\"id\":\"chatcmpl-Dn7oZjOtsXXzajgbNQ17HQXOPABbd\",\"object\":\"chat.completion.chunk\",\"created\":1780600483,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_e0de90b008\",\"choices\":[],\"usage\":{\"prompt_tokens\":50,\"completion_tokens\":1,\"total_tokens\":51,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"w348ZVmuZE6\"}\n\ndata: [DONE]\n\n" - } - } - ] -} diff --git a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-chronological-system-update.json b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-chronological-system-update.json deleted file mode 100644 index f434c08f21..0000000000 --- a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-chronological-system-update.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "version": 1, - "metadata": { - "name": "openai-responses/openai-responses-gpt-5-5-chronological-system-update", - "recordedAt": "2026-06-04T19:14:46.794Z", - "provider": "openai", - "route": "openai-responses", - "transport": "http", - "model": "gpt-5.5", - "tags": [ - "prefix:openai-responses", - "provider:openai", - "flagship", - "system", - "chronological-system-update", - "golden" - ] - }, - "interactions": [ - { - "transport": "http", - "request": { - "method": "POST", - "url": "https://api.openai.com/v1/responses", - "headers": { - "content-type": "application/json" - }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the latest instruction exactly. Reply only with the requested word.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Use the latest instruction for your reply.\"},{\"type\":\"input_text\",\"text\":\"\\nFor this reply, respond exactly with: UPDATED\\n\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":80,\"stream\":true}" - }, - "response": { - "status": 200, - "headers": { - "content-type": "text/event-stream; charset=utf-8" - }, - "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_0b058cc7f4ec9168016a21cea3d244819da4789560e119a7ac\",\"object\":\"response\",\"created_at\":1780600483,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_0b058cc7f4ec9168016a21cea3d244819da4789560e119a7ac\",\"object\":\"response\",\"created_at\":1780600483,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"rs_0b058cc7f4ec9168016a21cea4a180819dab9efa31556dff80\",\"type\":\"reasoning\",\"content\":[],\"encrypted_content\":\"gAAAAABqIc6kqld4jlRSpxnmJIcQxeZVqwQ9w073E8j7To6pXvbKYQTT2P_KD86yGR20IrGeredD67SvjdCe32pPRqNcDgit4XCJuSKWzvQt8EZOrs5ZbY7EjnRWOWv7WGLRFyzIJG2oQVbEwrJZzV-msWBXOj45GobymmHAJT1yj76ks33WcKtOSyGXWfmzIx1tfeVFULriBFnnWJ45iYFJS2VdwvkHyLci6uitNfwHWOALkA00aavmw9oCac8LSDrKz7aIOYMHekFI4IosCjJ0GgTrPvGm_D0EIzeAcNn_ofL31u5i1r_v--RNC9dog0Xc1DxoXeXDoLUj1YZ3JxWjFhhyAlraZ-t5S4kHsAqIo_21usyZf2u59DSdIvA1JnzDYQO24-LhsnMsHjqVvh0tYh9EZ75U7-7arr8KOupXzrFDY4SrNFmdTdyT4AdDF8CamDfHszti2P5a0q0E_0lBCehiMPBHlg8yKvVN8oSkf3svUIyJfaQ80g_K0tOgwkOW54rMbBalKNRKOCQnP7_eN16XldJbhcPY1K00XUBjfWOxLR-mXv1-20CINZKsy6a_lp-yrR7fcGaYykY7v75-svaj-6nEpVTelFiepcnHySP3vBzEzlP9n7eQf6CEcb8IUNh8sYQSnnvpEba9kWrTz7laA-EseH1tIN_GHxd1aTPyg_hW1mjN3IajA38Lvsd7RT1rcE644zSbz2-mqh5NTkQ9SGkvW3yhTLJWageRvT_qSqPVnEv18qtO0KgSOLgmsij43kfPvrcXFmnSnwtuf6kebY_T88il919MIRSuLEzI4vocXiL3sJ34pp4HPb1nfdu6N8IP_m28ti47LOQi5FZd9oFm8aYJgUaijlu5O7loDcI5Mz1uNsSqAd0V6Vs-mPFPLj00WqF4wFaYsp4qjmr9uNVaug==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"rs_0b058cc7f4ec9168016a21cea4a180819dab9efa31556dff80\",\"type\":\"reasoning\",\"content\":[],\"encrypted_content\":\"gAAAAABqIc6lpWyK-ZtXIPgHUsad6JFwBf2WpDu7lyu-ISjz0dAs-NPdkHl21o6eo84i9i-kuwPidonWRG35JNnedZ-4k17N5iJC81DwW8Gpb83rQ6VN1YqNQeWEY0Y56euNz4A13EzP84g-_X-6NDxS9WvQWTBwNrdkDKsRGn62CGxaz6EiO8Gkeb9ZGUxLc0TdLtM4fBK3Kpy1mvFOyp1kv4CSw1VE3g3iN2p8CskF1BaduprfT9WDPRfO4CHJs8_YlEFDN_THnIy-2vXsCJlqoMZ7xg0i1syW0osiISNXWimhRRg5wFME50ZQV0F6WjETCFcMsFrkEHAdjxGhhrWroPT_79niuE1iA74iiwUpeF1Bp5dTYBbwijJrDnhFG5CpOYtgUb5-5lPdwmUK0nbYDBL6dQC-PntXU6vi0u3pVRcil0dOqkzhzy56BtBM4tJagYnhuAaAkW86nbq31qq1eH8XmUSKItZ3Um2eOlGHwRdV_0iZVYYvqquoByy4Wptznw0y30XvMSeSDp2deLqzU4dCLYgxcYczlREEd1pVCah-HjpSipHNcUUMshP8h0wKBcy3C5ciBzT-cmgTIgCEEWkAatSx3ADDM9MOnk6fjogmkj-FETMIebgI02zN4Qh1GXJZahseuhouM8O5ei-17XqHWwlRvEZfolUfHSiBnHGH1Hk2mdnqLm3WfAlULQ1d0SEYrZlztdMuQW-4Fw619fixbatUvp_p0VvlOPGEKXmgKMy_ZOkJSVIS0O0KphVRCebnVWRsg5oGGezVormxKaZj5yKYtgSaODXM_q0GTgo5OxaaIz7RHX_c3e8URJ5auUwZGXh3mzNgsB7VDo9wkawCeHG0BVJ1akwF7J1uNcwfMD1tFNrO5zQwF1NfiwFo-uVots8AHK5pZqrFpY64LULoiNVmqD1EZpB_sUCwpQdVSvgox5ZvW4xzcdNEDsuYklCB25xruiIo9qufGrrSjSK8DNxB2w==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":3}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_0b058cc7f4ec9168016a21cea52760819d927c7a1d6538a62a\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":4}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_0b058cc7f4ec9168016a21cea52760819d927c7a1d6538a62a\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"UPDATED\",\"item_id\":\"msg_0b058cc7f4ec9168016a21cea52760819d927c7a1d6538a62a\",\"logprobs\":[],\"obfuscation\":\"mgR1eK6bQ\",\"output_index\":1,\"sequence_number\":6}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_0b058cc7f4ec9168016a21cea52760819d927c7a1d6538a62a\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":7,\"text\":\"UPDATED\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_0b058cc7f4ec9168016a21cea52760819d927c7a1d6538a62a\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"UPDATED\"},\"sequence_number\":8}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_0b058cc7f4ec9168016a21cea52760819d927c7a1d6538a62a\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"UPDATED\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":9}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_0b058cc7f4ec9168016a21cea3d244819da4789560e119a7ac\",\"object\":\"response\",\"created_at\":1780600483,\"status\":\"completed\",\"background\":false,\"completed_at\":1780600486,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"rs_0b058cc7f4ec9168016a21cea4a180819dab9efa31556dff80\",\"type\":\"reasoning\",\"content\":[],\"encrypted_content\":\"gAAAAABqIc6m-feiwQGp0dCrntYnYC-tRtrFG_e34B0XI6-cuPjU9c7PhWAjN7pyE8sIXfe8t1Nxi8aqXwfKbMgFX-lycUFo6Fyv209ZOpWwUf4gL5KIvCnHvISI13qmgiIzti2HHBO3HYSHNdFvrj8B3AxU1KEMjn7Pnv1-c23i0H9mYWp6TTO9K1vDCWkGZYpGe9OP0aFBZmxkdB5fWWwi2X6naLOpvHn2ssTesecwQ2lRyrdY_EV1rtL8YL0zG78azgQdJPNRL8gv_BQ1HLHS1xUDgvYnVgQb8U8Tu-42scVWP6qBXJk3-01d6sjUj8RYVgrsjLIsHZq1QlnBTSNnynGLoALtt7cC4g3LhbTCjSTP5kG-KxDQ8RiBd34CP8voN8oaM_2Ycs0t7mCtBZdmWjw92iViVlW7wVV5PNU8fPz0BqGQqKWnb6xawvEqPxhxiiNuaby26mGdMOpilDNJVyQV06NVAMtNW7givFKxT-cRuvWxecfFgGdSfbK3V03k1NlN5Pard8ZqZgqZSpOEk_MpV59outeqvKozOl1jX6E8SXPtPW4nNfHz_vUb57hKGWx6meJj2kZlQ1xpb3Tb4MEMLltKj_OPbfyv-GfXyhm_enzRCu0iepQAhU1fFHfns8z0tT4xndX_5mEMgiDkXDBg0bfXpBA8uOuiJIlV5s1wXi85zJQ6hpnqG6fKMNI2iGgQihIm4OiiG73eyePSVto8nAiq_Q4WnDMX_k1w6V1D5IIeTIc8MLZjDL5kw3pm6GnnoxlNoYoBnmdupW_ckMKMzNDg0dGLQ17TsaEiGr1dfAxStBiP1hIx_fFYebksXtFr-OVxCN_Z09hkEQ6BOvrDtE02VPI_7vjz9EroixIKYdJDWwmvXwiXYGLSzL4uyN1Z4aLS5MrfWeG3AQNSMjOBBQ_SsPUFFqskhMiy1keO5-htbgV1gbOl9BlnNO1VaACCAKdbweOdosCUiRojQ3KKhhlVhg==\",\"summary\":[]},{\"id\":\"msg_0b058cc7f4ec9168016a21cea52760819d927c7a1d6538a62a\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"UPDATED\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":48,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":17,\"output_tokens_details\":{\"reasoning_tokens\":10},\"total_tokens\":65},\"user\":null,\"metadata\":{}},\"sequence_number\":10}\n\n" - } - } - ] -} diff --git a/packages/llm/test/provider/golden.recorded.test.ts b/packages/llm/test/provider/golden.recorded.test.ts index f82df3b5eb..9b95f678fd 100644 --- a/packages/llm/test/provider/golden.recorded.test.ts +++ b/packages/llm/test/provider/golden.recorded.test.ts @@ -73,7 +73,7 @@ describeRecordedGoldenScenarios([ prefix: "openai-chat", model: openAIChat, requires: ["OPENAI_API_KEY"], - scenarios: ["text", "chronological-system-update", "tool-call", "tool-loop"], + scenarios: ["text", "tool-call", "tool-loop"], }, { name: "OpenAI Responses gpt-5.5", @@ -83,7 +83,6 @@ describeRecordedGoldenScenarios([ tags: ["flagship"], scenarios: [ { id: "text", temperature: false }, - { id: "chronological-system-update", temperature: false }, { id: "reasoning", temperature: false }, { id: "reasoning-continuation", temperature: false }, { id: "tool-call", temperature: false }, @@ -105,7 +104,7 @@ describeRecordedGoldenScenarios([ model: anthropicHaiku, requires: ["ANTHROPIC_API_KEY"], options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) }, - scenarios: ["text", "chronological-system-update", "tool-call"], + scenarios: ["text", "tool-call"], }, { name: "Anthropic Opus 4.7", @@ -126,7 +125,6 @@ describeRecordedGoldenScenarios([ requires: ["GOOGLE_GENERATIVE_AI_API_KEY"], scenarios: [ { id: "text", maxTokens: 80 }, - "chronological-system-update", "tool-call", { id: "image", maxTokens: 160 }, ], diff --git a/packages/llm/test/recorded-scenarios.ts b/packages/llm/test/recorded-scenarios.ts index 1aa9998bfc..545a3b983a 100644 --- a/packages/llm/test/recorded-scenarios.ts +++ b/packages/llm/test/recorded-scenarios.ts @@ -332,28 +332,6 @@ const runTextScenario = (context: GoldenScenarioContext) => }), ]) -export const runChronologicalSystemUpdateScenario = (context: GoldenScenarioContext) => - Effect.gen(function* () { - const response = yield* generate( - LLM.request({ - id: `${context.id}_chronological_system_update`, - model: context.model, - system: "Follow the latest instruction exactly. Reply only with the requested word.", - cache: "none", - messages: [ - Message.user("Use the latest instruction for your reply."), - Message.system("For this reply, respond exactly with: UPDATED"), - ], - providerOptions: - context.model.route.id === "gemini" ? { gemini: { thinkingConfig: { thinkingBudget: 0 } } } : undefined, - generation: generation(context, context.maxTokens ?? 80), - }), - ) - - expectFinish(response.events, "stop") - expect(response.text.trim()).toMatch(/^UPDATED[.!]?$/i) - }) - const runToolCallScenario = (context: GoldenScenarioContext) => runGeneratedConversation(context, [ user("Call get_weather with city exactly Paris."), @@ -467,11 +445,6 @@ const runToolLoopScenario = (context: GoldenScenarioContext) => const goldenScenarios = { text: { title: "streams text", tags: ["text", "golden"], run: runTextScenario }, - "chronological-system-update": { - title: "uses chronological system update", - tags: ["system", "chronological-system-update", "golden"], - run: runChronologicalSystemUpdateScenario, - }, "tool-call": { title: "streams tool call", tags: ["tool", "tool-call", "golden"], run: runToolCallScenario }, "tool-loop": { title: "drives a tool loop", tags: ["tool", "tool-loop", "golden"], run: runToolLoopScenario }, image: { title: "reads image text", tags: ["media", "image", "vision", "golden"], run: runImageScenario }, diff --git a/packages/llm/test/schema.test.ts b/packages/llm/test/schema.test.ts index 949d0d0681..84ceb0e5d8 100644 --- a/packages/llm/test/schema.test.ts +++ b/packages/llm/test/schema.test.ts @@ -6,7 +6,6 @@ import { ContentPart, LLMEvent, LLMRequest, - Message, Model, ModelID, ProviderID, @@ -52,17 +51,6 @@ describe("llm schema", () => { expect(decoded.model.route.id).toBe("openai-responses") }) - test("decodes chronological system messages", () => { - const decoded = decodeLLMRequest({ - model, - system: [], - messages: [{ role: "system", content: [{ type: "text", text: "Operator update." }] }], - tools: [], - }) - - expect(decoded.messages[0]).toMatchObject({ role: "system", content: [{ type: "text", text: "Operator update." }] }) - }) - test("rejects invalid event type", () => { expect(() => decodeLLMEvent({ type: "bogus" })).toThrow() }) diff --git a/packages/opencode/test/cli/tui/sync-v2.test.tsx b/packages/opencode/test/cli/tui/sync-v2.test.tsx index a408e38e74..b37d86a703 100644 --- a/packages/opencode/test/cli/tui/sync-v2.test.tsx +++ b/packages/opencode/test/cli/tui/sync-v2.test.tsx @@ -358,54 +358,6 @@ 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() - const calls = createFetch((url) => { - if (url.pathname === "/api/session/session-1/message") return response.promise - return undefined - }) - let sync!: ReturnType - let ready!: () => void - const mounted = new Promise((resolve) => { - ready = resolve - }) - - function Probe() { - sync = useSyncV2() - onMount(ready) - return - } - - const app = await testRender(() => ( - - - - - - - - )) - - 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 () => { const events = createEventSource() const response = Promise.withResolvers() From 512b3e99673fc9ae57f84cc9ff3af9733543d86f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 4 Jun 2026 21:44:02 -0400 Subject: [PATCH 12/17] fix(core): harden v2 instruction boundaries --- CONTEXT.md | 2 + packages/core/src/instruction-context.ts | 21 ++- packages/core/src/session/context-epoch.ts | 4 + packages/core/src/session/projector.ts | 25 ++-- .../core/test/instruction-context.test.ts | 120 +++++++++++++++++- packages/core/test/session-runner.test.ts | 35 +++++ .../core/test/system-context-builtins.test.ts | 16 ++- specs/v2/schema-changelog.md | 2 + 8 files changed, 202 insertions(+), 23 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index d5ccd6de6d..2a0bc8df92 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -60,6 +60,7 @@ The point immediately before a provider call, after durable input promotion and - Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**. - Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**. - Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location. +- Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote. - Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values. - The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**. - Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam. @@ -74,6 +75,7 @@ The point immediately before a provider call, after durable input promotion and - A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history. - A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise. - When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply. +- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible. ## Example dialogue diff --git a/packages/core/src/instruction-context.ts b/packages/core/src/instruction-context.ts index be258e2a1c..db15ebd4a3 100644 --- a/packages/core/src/instruction-context.ts +++ b/packages/core/src/instruction-context.ts @@ -1,8 +1,9 @@ export * as InstructionContext from "./instruction-context" import { Array, Effect, Layer, Schema } from "effect" -import { join } from "path" +import { isAbsolute, join, relative, sep } from "path" import { FSUtil } from "./fs-util" +import { Flag } from "./flag/flag" import { Global } from "./global" import { Location } from "./location" import { AbsolutePath } from "./schema" @@ -30,15 +31,25 @@ export const layer = Layer.effectDiscard( codec: Schema.toCodecJson(Files), load: Effect.succeed(value), baseline: render, - update: (_previous, current) => render(current), + update: (_previous, current) => `These instructions replace all previously loaded ambient instructions.\n\n${render(current)}`, removed: () => "Previously loaded instructions no longer apply.", }) const observe = Effect.fn("InstructionContext.observe")(function* () { + const start = FSUtil.resolve(location.directory) + const stop = FSUtil.resolve(location.project.directory) + const fromProject = relative(stop, start) + const insideProject = + fromProject === "" || (fromProject !== ".." && !fromProject.startsWith(`..${sep}`) && !isAbsolute(fromProject)) const discovered = new Set( - (yield* fs.up({ targets: ["AGENTS.md"], start: location.directory, stop: location.project.directory })).map( - FSUtil.resolve, - ), + (Flag.OPENCODE_DISABLE_PROJECT_CONFIG || !insideProject + ? [] + : yield* fs.up({ + targets: ["AGENTS.md"], + start, + stop, + }) + ).map(FSUtil.resolve), ) const paths = Array.dedupe([FSUtil.resolve(join(global.config, "AGENTS.md")), ...discovered]) const files = yield* Effect.forEach( diff --git a/packages/core/src/session/context-epoch.ts b/packages/core/src/session/context-epoch.ts index 06465771ef..6781bd533d 100644 --- a/packages/core/src/session/context-epoch.ts +++ b/packages/core/src/session/context-epoch.ts @@ -135,6 +135,10 @@ export const requestReplacement = Effect.fn("SessionContextEpoch.requestReplacem .pipe(Effect.orDie) }) +export const reset = Effect.fn("SessionContextEpoch.reset")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { + yield* db.delete(SessionContextEpochTable).where(eq(SessionContextEpochTable.session_id, sessionID)).run().pipe(Effect.orDie) +}) + const insert = Effect.fnUntraced(function* ( db: DatabaseService, sessionID: SessionSchema.ID, diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 4d7e07c90b..42053ce733 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -260,17 +260,20 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie), ) yield* events.project(SessionEvent.Moved, (event) => - db - .update(SessionTable) - .set({ - directory: event.data.location.directory, - path: event.data.subdirectory, - workspace_id: event.data.location.workspaceID ? WorkspaceV2.ID.make(event.data.location.workspaceID) : null, - time_updated: DateTime.toEpochMillis(event.data.timestamp), - }) - .where(eq(SessionTable.id, event.data.sessionID)) - .run() - .pipe(Effect.orDie), + Effect.gen(function* () { + yield* db + .update(SessionTable) + .set({ + directory: event.data.location.directory, + path: event.data.subdirectory, + workspace_id: event.data.location.workspaceID ? WorkspaceV2.ID.make(event.data.location.workspaceID) : null, + time_updated: DateTime.toEpochMillis(event.data.timestamp), + }) + .where(eq(SessionTable.id, event.data.sessionID)) + .run() + .pipe(Effect.orDie) + yield* SessionContextEpoch.reset(db, event.data.sessionID) + }), ) yield* events.project(SessionV1.Event.Deleted, (event) => db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie), diff --git a/packages/core/test/instruction-context.test.ts b/packages/core/test/instruction-context.test.ts index 9b1657caac..88f97ed2bd 100644 --- a/packages/core/test/instruction-context.test.ts +++ b/packages/core/test/instruction-context.test.ts @@ -73,7 +73,19 @@ describe("InstructionContext", () => { text: expect.stringContaining(`Instructions from: ${packageFile}\nchanged`), }) - yield* Effect.promise(() => Promise.all([fs.rm(globalFile), fs.rm(packageFile), fs.rm(projectFile)])) + yield* Effect.promise(() => fs.rm(packageFile)) + const partial = yield* SystemContext.reconcile(yield* load, initialized.snapshot) + expect(partial).toEqual({ + _tag: "Updated", + text: [ + "These instructions replace all previously loaded ambient instructions.", + `Instructions from: ${globalFile}\nglobal`, + `Instructions from: ${projectFile}\nproject`, + ].join("\n\n"), + snapshot: expect.any(Object), + }) + + yield* Effect.promise(() => Promise.all([fs.rm(globalFile), fs.rm(projectFile)])) expect(yield* SystemContext.reconcile(yield* load, initialized.snapshot)).toEqual({ _tag: "Updated", text: "Previously loaded instructions no longer apply.", @@ -178,4 +190,110 @@ describe("InstructionContext", () => { ).toEqual({ _tag: "Unchanged" }) }), ) + + it.effect("canonicalizes upward discovery boundaries", () => + Effect.gen(function* () { + let observed: { targets: string[]; start: string; stop?: string } | undefined + const observingFS = Layer.effect( + FSUtil.Service, + FSUtil.Service.pipe( + Effect.map((fs) => + FSUtil.Service.of({ + ...fs, + up: (options) => + Effect.sync(() => { + observed = options + return [] + }), + }), + ), + ), + ).pipe(Layer.provide(FSUtil.defaultLayer)) + + yield* SystemContextRegistry.Service.pipe( + Effect.flatMap((service) => service.load()), + Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), + Effect.provide(observingFS), + Effect.provide(Global.layerWith({ config: "/global" })), + Effect.provide( + Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory: AbsolutePath.make("/repo/") }, + { projectDirectory: AbsolutePath.make("/repo") }, + ), + ), + ), + ), + ) + + expect(observed).toEqual({ targets: ["AGENTS.md"], start: FSUtil.resolve("/repo"), stop: FSUtil.resolve("/repo") }) + }), + ) + + it.effect("honors the project instruction opt-out", () => + Effect.gen(function* () { + const previous = process.env.OPENCODE_DISABLE_PROJECT_CONFIG + let scanned = false + process.env.OPENCODE_DISABLE_PROJECT_CONFIG = "1" + + yield* SystemContextRegistry.Service.pipe( + Effect.flatMap((service) => service.load()), + Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), + Effect.provide( + Layer.effect( + FSUtil.Service, + FSUtil.Service.pipe( + Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })), + ), + ).pipe(Layer.provide(FSUtil.defaultLayer)), + ), + Effect.provide(Global.layerWith({ config: "/global" })), + Effect.provide( + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))), + ), + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env.OPENCODE_DISABLE_PROJECT_CONFIG + else process.env.OPENCODE_DISABLE_PROJECT_CONFIG = previous + }), + ), + ) + + expect(scanned).toBe(false) + }), + ) + + it.effect("does not discover project instructions outside the canonical project root", () => + Effect.gen(function* () { + let scanned = false + yield* SystemContextRegistry.Service.pipe( + Effect.flatMap((service) => service.load()), + Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), + Effect.provide( + Layer.effect( + FSUtil.Service, + FSUtil.Service.pipe( + Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })), + ), + ).pipe(Layer.provide(FSUtil.defaultLayer)), + ), + Effect.provide(Global.layerWith({ config: "/global" })), + Effect.provide( + Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory: AbsolutePath.make("/outside") }, + { projectDirectory: AbsolutePath.make("/repo") }, + ), + ), + ), + ), + ) + + expect(scanned).toBe(false) + }), + ) }) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 1457daecca..fe60aece82 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -596,6 +596,41 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("requires a complete new baseline after a Session moves", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + const { db } = yield* Database.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + requests.length = 0 + response = [] + yield* session.resume(sessionID) + + yield* events.publish(SessionEvent.Moved, { + sessionID, + timestamp: DateTime.makeUnsafe(1), + location: { directory: AbsolutePath.make("/moved") }, + }) + expect( + yield* db + .select() + .from(SessionContextEpochTable) + .where(eq(SessionContextEpochTable.session_id, sessionID)) + .get(), + ).toBeUndefined() + + systemUnavailable = true + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + const exit = yield* session.resume(sessionID).pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(SystemContext.InitializationBlocked) + expect(requests).toHaveLength(1) + expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true) + }), + ) + it.effect("reuses one durable baseline after the context producer changes", () => Effect.gen(function* () { yield* setup diff --git a/packages/core/test/system-context-builtins.test.ts b/packages/core/test/system-context-builtins.test.ts index ed854abcd8..6ec9ca41ee 100644 --- a/packages/core/test/system-context-builtins.test.ts +++ b/packages/core/test/system-context-builtins.test.ts @@ -11,14 +11,18 @@ import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry import { location } from "./fixture/location" import { testEffect } from "./lib/effect" -const directory = AbsolutePath.make("/repo/packages/core") -const projectDirectory = AbsolutePath.make("/repo") +const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core")) +const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo")) +const instructionFile = FSUtil.resolve("/repo/AGENTS.md") const timestamp = Date.parse("2026-06-03T12:00:00.000Z") const localDate = (time: number) => new Date(time).toDateString() const locationLayer = Layer.succeed( Location.Service, Location.Service.of( - location({ directory }, { projectDirectory, vcs: { type: "git", store: AbsolutePath.make("/repo/.git") } }), + location( + { directory }, + { projectDirectory, vcs: { type: "git", store: AbsolutePath.make(FSUtil.resolve("/repo/.git")) } }, + ), ), ) const it = testEffect( @@ -34,8 +38,8 @@ const instructionFS = Layer.effect( Effect.map((fs) => FSUtil.Service.of({ ...fs, - up: () => Effect.succeed(["/repo/AGENTS.md"]), - readFileStringSafe: (path) => Effect.succeed(path === "/repo/AGENTS.md" ? "Be precise." : undefined), + up: () => Effect.succeed([instructionFile]), + readFileStringSafe: (path) => Effect.succeed(path === instructionFile ? "Be precise." : undefined), }), ), ), @@ -115,7 +119,7 @@ describe("SystemContextBuiltIns", () => { "", `Today's date: ${localDate(timestamp)}`, "", - "Instructions from: /repo/AGENTS.md\nBe precise.", + `Instructions from: ${instructionFile}\nBe precise.`, ].join("\n"), ) }), diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 39b00e5eb8..4f19607d49 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -761,6 +761,8 @@ Change: - Directly discover and read global plus upward project `AGENTS.md` files at each safe provider-turn boundary. - Preserve admitted instructions across transient scan/read failures and block first-epoch initialization while any context source is unavailable. - Retry Context Epoch preparation until stable after optimistic revision mismatches. +- Clear the active Context Epoch when a Session moves so the destination initializes a complete baseline before promoting more input. +- Canonicalize ambient instruction traversal boundaries, honor `OPENCODE_DISABLE_PROJECT_CONFIG`, and make non-empty aggregate updates explicitly supersede previously loaded instructions. Compatibility: From 91bd2f2c3743322ef3084bd7818eb777a923e3da Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 4 Jun 2026 22:57:20 -0400 Subject: [PATCH 13/17] fix(core): fence v2 context placement --- CONTEXT.md | 1 + packages/core/src/session/context-epoch.ts | 32 +++++++++++++++--- packages/core/src/session/runner/llm.ts | 4 +-- packages/core/test/session-runner.test.ts | 33 +++++++++++++++++++ .../llm/src/protocols/anthropic-messages.ts | 14 ++++++++ .../test/provider/anthropic-messages.test.ts | 19 +++++++++++ specs/v2/schema-changelog.md | 1 + 7 files changed, 97 insertions(+), 7 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 2a0bc8df92..ae25c055ad 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -61,6 +61,7 @@ The point immediately before a provider call, after durable input promotion and - 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**. - Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location. - Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote. +- Context Epoch initialization is fenced against the authoritative Session Location, so an old-Location runner cannot recreate source context after a concurrent move. - Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values. - The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**. - Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam. diff --git a/packages/core/src/session/context-epoch.ts b/packages/core/src/session/context-epoch.ts index 6781bd533d..22ce8d5d40 100644 --- a/packages/core/src/session/context-epoch.ts +++ b/packages/core/src/session/context-epoch.ts @@ -4,17 +4,19 @@ import { and, eq, isNull, lt, or, sql } from "drizzle-orm" import { DateTime, Effect, Schema } from "effect" import type { Database } from "../database/database" import { EventV2 } from "../event" +import { Location } from "../location" import { SystemContext } from "../system-context" import { SystemContextRegistry } from "../system-context-registry" import { SessionEvent } from "./event" import { SessionInput } from "./input" import { SessionMessageID } from "./message-id" import { SessionSchema } from "./schema" -import { SessionContextEpochTable } from "./sql" +import { SessionContextEpochTable, SessionTable } from "./sql" type DatabaseService = Database.Interface["db"] class RevisionMismatch extends Error {} +class LocationMismatch extends Error {} const retryRevisionMismatch = (attempt: () => Effect.Effect): Effect.Effect => attempt().pipe( @@ -34,8 +36,9 @@ export function initialize( db: DatabaseService, context: SystemContextRegistry.Interface, sessionID: SessionSchema.ID, + location: Location.Ref, ): Effect.Effect { - return retryRevisionMismatch(() => initializeOnce(db, context, sessionID)).pipe( + return retryRevisionMismatch(() => initializeOnce(db, context, sessionID, location)).pipe( Effect.withSpan("SessionContextEpoch.initialize"), ) } @@ -45,8 +48,9 @@ export function prepare( events: EventV2.Interface, context: SystemContextRegistry.Interface, sessionID: SessionSchema.ID, + location: Location.Ref, ): Effect.Effect { - return retryRevisionMismatch(() => prepareOnce(db, events, context, sessionID)).pipe( + return retryRevisionMismatch(() => prepareOnce(db, events, context, sessionID, location)).pipe( Effect.withSpan("SessionContextEpoch.prepare"), ) } @@ -56,11 +60,12 @@ const prepareOnce = Effect.fnUntraced(function* ( events: EventV2.Interface, context: SystemContextRegistry.Interface, sessionID: SessionSchema.ID, + location: Location.Ref, ) { const [value, stored] = yield* Effect.all([context.load(), find(db, sessionID)], { concurrency: "unbounded" }) if (!stored) { const generation = yield* SystemContext.initialize(value) - const baselineSeq = yield* insert(db, sessionID, generation) + const baselineSeq = yield* insert(db, sessionID, location, generation) return { baseline: generation.baseline, baselineSeq } } @@ -89,10 +94,11 @@ const initializeOnce = Effect.fnUntraced(function* ( db: DatabaseService, context: SystemContextRegistry.Interface, sessionID: SessionSchema.ID, + location: Location.Ref, ) { if (yield* exists(db, sessionID)) return const generation = yield* context.load().pipe(Effect.flatMap(SystemContext.initialize)) - const baselineSeq = yield* insert(db, sessionID, generation) + const baselineSeq = yield* insert(db, sessionID, location, generation) return { baseline: generation.baseline, baselineSeq } }) @@ -142,12 +148,28 @@ export const reset = Effect.fn("SessionContextEpoch.reset")(function* (db: Datab const insert = Effect.fnUntraced(function* ( db: DatabaseService, sessionID: SessionSchema.ID, + location: Location.Ref, generation: SystemContext.Generation, ) { return yield* db .transaction( () => Effect.gen(function* () { + const placed = yield* db + .select({ sessionID: SessionTable.id }) + .from(SessionTable) + .where( + and( + eq(SessionTable.id, sessionID), + eq(SessionTable.directory, location.directory), + location.workspaceID === undefined + ? isNull(SessionTable.workspace_id) + : eq(SessionTable.workspace_id, location.workspaceID), + ), + ) + .get() + .pipe(Effect.orDie) + if (!placed) return yield* Effect.die(new LocationMismatch()) const baselineSeq = yield* SessionInput.latestSeq(db, sessionID) yield* db .insert(SessionContextEpochTable) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index b628861033..0f1880205d 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -132,7 +132,7 @@ export const layer = Layer.effect( promotion: "steer" | "queue" | undefined, ) { const session = yield* getSession(sessionID) - const initialized = yield* SessionContextEpoch.initialize(db, systemContext, session.id) + const initialized = yield* SessionContextEpoch.initialize(db, systemContext, session.id, session.location) const model = yield* models.resolve(session) const toolFibers = yield* FiberSet.make() let needsContinuation = false @@ -144,7 +144,7 @@ export const layer = Layer.effect( yield* SessionInput.promoteSteers(db, events, session.id, cutoff) } } - const system = initialized ?? (yield* SessionContextEpoch.prepare(db, events, systemContext, session.id)) + const system = initialized ?? (yield* SessionContextEpoch.prepare(db, events, systemContext, session.id, session.location)) const context = yield* store.runnerContext(session.id, system.baselineSeq) const request = LLM.request({ model, diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index fe60aece82..160884402e 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -631,6 +631,39 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("does not create a source Location epoch after a concurrent Session move", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + const { db } = yield* Database.Service + let moved = false + systemLoadHook = Effect.suspend(() => { + if (moved) return Effect.void + moved = true + return events + .publish(SessionEvent.Moved, { + sessionID, + timestamp: DateTime.makeUnsafe(1), + location: { directory: AbsolutePath.make("/moved") }, + }) + .pipe(Effect.asVoid) + }) + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + + expect(Exit.isFailure(yield* session.resume(sessionID).pipe(Effect.exit))).toBe(true) + expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true) + expect( + yield* db + .select() + .from(SessionContextEpochTable) + .where(eq(SessionContextEpochTable.session_id, sessionID)) + .get(), + ).toBeUndefined() + expect((yield* session.get(sessionID)).location.directory).toBe(AbsolutePath.make("/moved")) + }), + ) + it.effect("reuses one durable baseline after the context producer changes", () => Effect.gen(function* () { yield* setup diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index 38f1f19bab..d543e659ca 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -363,6 +363,18 @@ const canUseNativeSystemUpdate = (messages: LLMRequest["messages"], index: numbe ) } +const splitsLocalToolResults = (messages: LLMRequest["messages"], index: number) => { + const pending = new Set() + for (const message of messages.slice(0, index)) { + for (const part of message.content) { + if (message.role === "assistant" && part.type === "tool-call" && part.providerExecuted !== true) + pending.add(part.id) + if (message.role === "tool" && part.type === "tool-result") pending.delete(part.id) + } + } + return pending.size > 0 +} + const lowerNativeSystemUpdate = Effect.fn("AnthropicMessages.lowerNativeSystemUpdate")(function* ( message: LLMRequest["messages"][number], breakpoints: Cache.Breakpoints, @@ -386,6 +398,8 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* ( for (const [index, message] of request.messages.entries()) { if (message.role === "system") { + if (splitsLocalToolResults(request.messages, index)) + return yield* invalid("Anthropic Messages system updates cannot split a local tool call from its tool result") if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request.messages, index)) { messages.push(yield* lowerNativeSystemUpdate(message, breakpoints)) continue diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index 3fb3cdb3b9..1cd8f4dd9e 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -168,6 +168,25 @@ describe("Anthropic Messages route", () => { }), ) + it.effect("rejects a system update between a local tool call and its result", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + model: opus48, + messages: [ + Message.user("Use the tool."), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]), + Message.system("Too early."), + Message.tool({ id: "call_1", name: "lookup", result: "Done." }), + ], + cache: "none", + }), + ).pipe(Effect.flip) + + expect(error.message).toContain("system updates cannot split a local tool call from its tool result") + }), + ) + it.effect("prepares tool call and tool result messages", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 4f19607d49..5c5980a2ed 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -762,6 +762,7 @@ Change: - Preserve admitted instructions across transient scan/read failures and block first-epoch initialization while any context source is unavailable. - Retry Context Epoch preparation until stable after optimistic revision mismatches. - Clear the active Context Epoch when a Session moves so the destination initializes a complete baseline before promoting more input. +- Fence Context Epoch initialization against the authoritative Session Location so a concurrent old-Location runner cannot recreate stale privileged context after a move. - Canonicalize ambient instruction traversal boundaries, honor `OPENCODE_DISABLE_PROJECT_CONFIG`, and make non-empty aggregate updates explicitly supersede previously loaded instructions. Compatibility: From 252a31b5ec7cdcf73932adbfa9626714733d963b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 4 Jun 2026 23:14:59 -0400 Subject: [PATCH 14/17] docs(v2): describe context epoch architecture --- CONTEXT.md | 1 - specs/v2/session.md | 52 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/CONTEXT.md b/CONTEXT.md index ae25c055ad..4a3bc6d225 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -86,4 +86,3 @@ The point immediately before a provider call, after durable input promotion and ## Flagged ambiguities - Legacy `experimental.chat.system.transform` can mutate the assembled baseline system prompt arbitrarily, but V2 plugins do not yet expose an equivalent hook. Decide separately whether to port it, replace dynamic uses with plugin-defined **Context Sources**, or narrow its semantics. -- A location change likely starts a new **Context Epoch** so location-dependent instructions and discovery can be rebuilt cleanly, but implementation should verify whether an append-only update is sufficient and meaningfully preserves cache. diff --git a/specs/v2/session.md b/specs/v2/session.md index 56384e78bd..41cfa87069 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -37,6 +37,58 @@ The local runner issues one explicit `llm.stream(request)` per provider turn, pr Projected hosted tools preserve call-side and settlement-side provider metadata separately so settlement and interruption recovery cannot erase continuation identifiers. Provider-native reasoning and provider metadata replay only while the historical assistant model matches the selected continuation model; after a model switch, visible reasoning text remains ordinary assistant text and provider-native metadata is omitted. +## Context Epochs + +V2 Sessions persist the exact privileged System Context shown to the model. A Context Epoch owns one immutable baseline plus a model-hidden structured snapshot used to compare independently observed Context Sources. Environment facts, the host-local date, and ambient global/upward-project `AGENTS.md` files are the initial registered sources. + +The first complete observation initializes the epoch before any pending prompt becomes model-visible. If initial context is temporarily unavailable, execution stops while the prompt remains pending and retryable. On later provider turns, the runner promotes eligible input first, then reconciles current sources at the safe boundary. Changed context becomes one durable chronological System message, and its event commit advances the epoch snapshot atomically. + +```mermaid +sequenceDiagram + participant Client + participant Runner + participant Registry as System Context Registry + participant Epoch as Context Epoch Store + participant History as Session History + participant LLM + + Client->>History: Admit prompt + Runner->>Registry: Observe initial context + Registry-->>Runner: Complete baseline or unavailable + Runner->>Epoch: Initialize missing epoch + Runner->>History: Promote eligible input + Runner->>Registry: Reconcile at safe boundary + Registry-->>Runner: Unchanged or chronological update + Runner->>Epoch: Advance snapshot atomically with update + Runner->>LLM: Baseline + chronological history +``` + +Model switches and completed compactions request lazy baseline replacement. A Session move clears the epoch so the destination Location must initialize a complete baseline before promoting more input. Epoch creation is fenced against the authoritative Session Location, preventing an old-Location runner from recreating stale privileged context after a concurrent move. + +```mermaid +stateDiagram-v2 + [*] --> Missing + Missing --> Active: complete initial observation + Missing --> Missing: initial context unavailable + Active --> Active: reconcile change + Active --> ReplacementPending: model switch or compaction + ReplacementPending --> Active: complete replacement observation + Active --> Missing: Session moves Location + ReplacementPending --> Missing: Session moves Location +``` + +Ambient project discovery canonicalizes and contains traversal within the project root and honors `OPENCODE_DISABLE_PROJECT_CONFIG`. An unavailable observation preserves the previously admitted value. A confirmed partial instruction removal emits the complete remaining aggregate with explicit supersession text; removing the final instruction emits a revocation message. + +Current Context Epoch follow-ups: + +- Add configured, remote, and nested instruction sources with explicit precedence and removal semantics. +- Add durable post-crash activity recovery for promoted or provider-dispatched work. +- Integrate actual automatic/context-pressure compaction with epoch replacement. +- Add operational metrics for observation latency, unavailable sources, contention, baseline size, and chronological-update growth. +- Consider watcher-backed per-file caching only if measurements show direct safe-boundary observation is too expensive. +- Expose plugin-defined Context Sources only after plugin reload and scoped cleanup semantics are designed. +- Add clustered Session execution ownership and stale-runtime fencing. + Provider timeout, retry, and watchdog policy is intentionally deferred. The runner does not impose a universal provider-stream inactivity or absolute timeout. A future slice should design configurable policy around provider behavior, durable failure reporting, and local drain-chain release rather than hardcoding one default for every provider. Inbox delivery is explicit: From 2187c2a4239fb8281f0eea7bb770a8d0d1204d4e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 4 Jun 2026 23:20:09 -0400 Subject: [PATCH 15/17] docs(v2): render context epoch diagrams --- specs/v2/session.md | 64 +++++++++++++++++++++++++-------------------- 1 file changed, 36 insertions(+), 28 deletions(-) diff --git a/specs/v2/session.md b/specs/v2/session.md index 41cfa87069..00f7c2ba72 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -43,38 +43,46 @@ V2 Sessions persist the exact privileged System Context shown to the model. A Co The first complete observation initializes the epoch before any pending prompt becomes model-visible. If initial context is temporarily unavailable, execution stops while the prompt remains pending and retryable. On later provider turns, the runner promotes eligible input first, then reconciles current sources at the safe boundary. Changed context becomes one durable chronological System message, and its event commit advances the epoch snapshot atomically. -```mermaid -sequenceDiagram - participant Client - participant Runner - participant Registry as System Context Registry - participant Epoch as Context Epoch Store - participant History as Session History - participant LLM - - Client->>History: Admit prompt - Runner->>Registry: Observe initial context - Registry-->>Runner: Complete baseline or unavailable - Runner->>Epoch: Initialize missing epoch - Runner->>History: Promote eligible input - Runner->>Registry: Reconcile at safe boundary - Registry-->>Runner: Unchanged or chronological update - Runner->>Epoch: Advance snapshot atomically with update - Runner->>LLM: Baseline + chronological history +```text +Client Runner System Context Registry Context Epoch Store Session History LLM + │ │ │ │ │ │ + ├─ Admit prompt ─────────────────────────────────────────────────────────────────────────────────────────────▶ │ + │ │ │ │ │ │ + │ ├─ Observe initial context ────────────▶ │ │ │ + │ │ │ │ │ │ + │ ◀─ Complete baseline or unavailable ───┤ │ │ │ + │ │ │ │ │ │ + │ ├─ Initialize missing epoch ───────────────────────────────────────▶ │ │ + │ │ │ │ │ │ + │ ├─ Promote eligible input ─────────────────────────────────────────────────────────────────▶ │ + │ │ │ │ │ │ + │ ├─ Reconcile at safe boundary ─────────▶ │ │ │ + │ │ │ │ │ │ + │ ◀─ Unchanged or chronological update ──┤ │ │ │ + │ │ │ │ │ │ + │ ├─ Advance snapshot atomically with update ────────────────────────▶ │ │ + │ │ │ │ │ │ + │ ├─ Baseline + chronological history ─────────────────────────────────────────────────────────────────────────▶ ``` Model switches and completed compactions request lazy baseline replacement. A Session move clears the epoch so the destination Location must initialize a complete baseline before promoting more input. Epoch creation is fenced against the authoritative Session Location, preventing an old-Location runner from recreating stale privileged context after a concurrent move. -```mermaid -stateDiagram-v2 - [*] --> Missing - Missing --> Active: complete initial observation - Missing --> Missing: initial context unavailable - Active --> Active: reconcile change - Active --> ReplacementPending: model switch or compaction - ReplacementPending --> Active: complete replacement observation - Active --> Missing: Session moves Location - ReplacementPending --> Missing: Session moves Location +```text +Session Epoch + │ │ + ├─ initialize complete baseline ──▶ + │ │ + │ ├─────────────────────────────────╮ + │ │ reconcile chronological update │ + │ ◀─────────────────────────────────╯ + │ │ + ├─ request replacement ───────────▶ + │ │ + │ ├─────────────────────────────────────╮ + │ │ replace after complete observation │ + │ ◀─────────────────────────────────────╯ + │ │ + ├─ clear after Location move ─────▶ ``` Ambient project discovery canonicalizes and contains traversal within the project root and honors `OPENCODE_DISABLE_PROJECT_CONFIG`. An unavailable observation preserves the previously admitted value. A confirmed partial instruction removal emits the complete remaining aggregate with explicit supersession text; removing the final instruction emits a revocation message. From 73d6b88024e743f43d4e81c191def36ac74d988e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 5 Jun 2026 10:01:01 -0400 Subject: [PATCH 16/17] fix(core): respect v2 default agents --- packages/core/src/agent.ts | 27 +++++++ packages/core/src/config.ts | 23 +++--- packages/core/src/config/plugin/agent.ts | 2 + packages/core/src/permission.ts | 3 +- packages/core/src/plugin/agent.ts | 3 + packages/core/src/session/runner/llm.ts | 7 +- packages/core/src/v1/config/migrate.ts | 2 +- packages/core/test/config/config.test.ts | 4 + packages/core/test/permission.test.ts | 22 ++++++ .../core/test/session-runner-recorded.test.ts | 4 + packages/core/test/session-runner.test.ts | 76 +++++++++++++++++++ 11 files changed, 155 insertions(+), 18 deletions(-) diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index cbe561e616..97f5156d0c 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -44,11 +44,13 @@ export class Info extends Schema.Class("AgentV2.Info")({ type Data = { agents: Map + default?: ID } export type Editor = { list: () => readonly Info[] get: (id: ID) => Info | undefined + default: (id: ID | undefined) => void update: (id: ID, fn: (agent: Draft) => void) => void remove: (id: ID) => void } @@ -57,6 +59,8 @@ export interface Interface { readonly transform: State.Interface["transform"] readonly update: (update: State.Transform) => Effect.Effect readonly get: (id: ID) => Effect.Effect + readonly default: () => Effect.Effect + readonly resolve: (id?: ID | string) => Effect.Effect readonly all: () => Effect.Effect } @@ -72,6 +76,9 @@ export const layer = Layer.effect( editor: (draft) => ({ list: () => Array.fromIterable(draft.agents.values()) as Info[], get: (id) => draft.agents.get(id), + default: (id) => { + draft.default = id + }, update: (id, fn) => { const current = draft.agents.get(id) ?? castDraft(Info.empty(id)) if (!draft.agents.has(id)) draft.agents.set(id, current) @@ -83,6 +90,19 @@ export const layer = Layer.effect( }, }), }) + const selectable = (agent: Info | undefined) => + agent && agent.mode !== "subagent" && !agent.hidden ? agent : undefined + const selectedDefault = () => { + const data = state.get() + const configured = data.default ? selectable(data.agents.get(data.default)) : undefined + if (configured) return configured + const build = selectable(data.agents.get(ID.make("build"))) + if (build) return build + for (const agent of data.agents.values()) { + const fallback = selectable(agent) + if (fallback) return fallback + } + } return Service.of({ transform: state.transform, @@ -93,6 +113,13 @@ export const layer = Layer.effect( get: Effect.fn("AgentV2.get")(function* (id) { return state.get().agents.get(id) }), + default: Effect.fn("AgentV2.default")(function* () { + return selectedDefault() + }), + resolve: Effect.fn("AgentV2.resolve")(function* (id) { + if (id !== undefined) return state.get().agents.get(ID.make(id)) + return selectedDefault() + }), all: Effect.fn("AgentV2.all")(function* () { return Array.fromIterable(state.get().agents.values()) }), diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 286fa2618a..8e1db95ba3 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -35,6 +35,9 @@ export class Info extends Schema.Class("Config.Info")({ model: Schema.String.pipe(Schema.optional).annotate({ description: "Default model to use when no session or agent model is selected", }), + default_agent: Schema.String.pipe(Schema.optional).annotate({ + description: "Default primary agent to use when no session agent is selected", + }), autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")]) .pipe(Schema.optional) .annotate({ @@ -130,6 +133,9 @@ export const layer = Layer.effect( const location = yield* Location.Service const policy = yield* Policy.Service const names = ["config.json", "opencode.json", "opencode.jsonc"] + const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const + const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions) + const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions) const loadFile = Effect.fnUntraced(function* (filepath: string) { const text = yield* fs.readFileStringSafe(filepath) @@ -139,21 +145,10 @@ export const layer = Layer.effect( const input: unknown = parse(text, errors, { allowTrailingComma: true }) if (errors.length) return - const decoded = ConfigMigrateV1.isV1(input) - ? Option.map( - Schema.decodeUnknownOption(ConfigV1.Info)(input, { - errors: "all", - onExcessProperty: "ignore", - propertyOrder: "original", - }), - ConfigMigrateV1.migrate, - ) - : Option.some(input) const info = Option.getOrUndefined( - Option.flatMap( - decoded, - Schema.decodeUnknownOption(Info, { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" }), - ), + ConfigMigrateV1.isV1(input) + ? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo)) + : decodeInfo(input), ) if (!info) return return new Document({ type: "document", path: filepath, info }) diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index 4f3d2b9764..f7fed14ef8 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -58,6 +58,8 @@ export const Plugin = PluginV2.define({ yield* agent.update((editor) => { const global = documents.flatMap((document) => document.info.permissions ?? []) + const configuredDefault = documents.findLast((document) => document.info.default_agent !== undefined)?.info.default_agent + if (configuredDefault !== undefined) editor.default(AgentV2.ID.make(configuredDefault)) for (const current of editor.list()) { editor.update(current.id, (agent) => agent.permissions.push(...global)) } diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index bd4a9527ff..bb75c7c9f5 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -16,6 +16,7 @@ export { Effect, Rule, Ruleset } from "./permission/schema" type Effect = PermissionSchema.Effect type Rule = PermissionSchema.Rule type Ruleset = PermissionSchema.Ruleset +const missingAgentPermissions: Ruleset = [{ action: "*", resource: "*", effect: "deny" }] export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe( Schema.brand("PermissionV2.ID"), @@ -161,7 +162,7 @@ export const layer = Layer.effect( const configured = EffectRuntime.fn("PermissionV2.configured")(function* (sessionID: SessionV2.ID) { const session = yield* sessions.get(sessionID) if (!session) return yield* new SessionV2.NotFoundError({ sessionID }) - return (yield* agents.get(AgentV2.ID.make(session.agent ?? "build")))?.permissions ?? [] + return (yield* agents.resolve(session.agent))?.permissions ?? missingAgentPermissions }) function denied(input: AssertInput, rules: Ruleset) { diff --git a/packages/core/src/plugin/agent.ts b/packages/core/src/plugin/agent.ts index 637ae40423..64e9302f93 100644 --- a/packages/core/src/plugin/agent.ts +++ b/packages/core/src/plugin/agent.ts @@ -9,6 +9,8 @@ import { PermissionV2 } from "../permission" import { PluginV2 } from "../plugin" const TRUNCATION_GLOB = path.join(Global.Path.data, "tool-output", "*") +const BUILD_SYSTEM = + "You are an AI coding agent. Help the user accomplish software engineering tasks by inspecting the workspace, making targeted changes, and using tools according to the configured permissions." const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases. @@ -123,6 +125,7 @@ export const Plugin = PluginV2.define({ yield* agent.update((editor) => { editor.update(AgentV2.ID.make("build"), (item) => { item.description = "The default agent. Executes tools based on configured permissions." + item.system ??= BUILD_SYSTEM item.mode = "primary" item.permissions.push( ...PermissionV2.merge(defaults, [ diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 0f1880205d..6246c4aaa8 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -16,6 +16,7 @@ import { SessionInput } from "../input" import { QuestionV2 } from "../../question" import { SystemContextRegistry } from "../../system-context-registry" import { SessionContextEpoch } from "../context-epoch" +import { AgentV2 } from "../../agent" /** * Runs one durable coding-agent Session until it settles. @@ -84,6 +85,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const events = yield* EventV2.Service const llm = yield* LLMClient.Service + const agents = yield* AgentV2.Service const tools = yield* ToolRegistry.Service const models = yield* SessionRunnerModel.Service const store = yield* SessionStore.Service @@ -134,6 +136,7 @@ export const layer = Layer.effect( const session = yield* getSession(sessionID) const initialized = yield* SessionContextEpoch.initialize(db, systemContext, session.id, session.location) const model = yield* models.resolve(session) + const agent = yield* agents.resolve(session.agent) const toolFibers = yield* FiberSet.make() let needsContinuation = false if (promotion) { @@ -148,13 +151,13 @@ export const layer = Layer.effect( const context = yield* store.runnerContext(session.id, system.baselineSeq) const request = LLM.request({ model, - system: system.baseline.length > 0 ? [SystemPart.make(system.baseline)] : [], + system: [agent?.system, system.baseline].filter((part): part is string => part !== undefined && part.length > 0).map(SystemPart.make), messages: toLLMMessages(context, model), tools: yield* tools.definitions(), }) const publisher = createLLMEventPublisher(events, { sessionID: session.id, - agent: session.agent ?? "build", + agent: agent?.id ?? "build", model: { id: ModelV2.ID.make(model.id), providerID: ProviderV2.ID.make(model.provider), diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 5dea17a4ab..617fe21d95 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -18,7 +18,6 @@ const keys = new Set([ "disabled_providers", "enabled_providers", "small_model", - "default_agent", "mode", "agent", "provider", @@ -38,6 +37,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) { $schema: info.$schema, shell: info.shell, model: info.model, + default_agent: info.default_agent, autoupdate: info.autoupdate, share: info.share ?? (info.autoshare ? "auto" : undefined), enterprise: info.enterprise, diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 465a415475..d8097a95d6 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -244,6 +244,7 @@ describe("Config", () => { JSON.stringify({ shell: "/bin/bash", model: "anthropic/claude", + default_agent: "reviewer", autoupdate: "notify", share: "disabled", enterprise: { url: "https://share.example.com" }, @@ -328,6 +329,7 @@ describe("Config", () => { expect(documents).toHaveLength(1) expect(documents[0]?.info.shell).toBe("/bin/bash") expect(documents[0]?.info.model).toBe("anthropic/claude") + expect(documents[0]?.info.default_agent).toBe("reviewer") expect(documents[0]?.info.autoupdate).toBe("notify") expect(documents[0]?.info.share).toBe("disabled") expect(documents[0]?.info.enterprise).toEqual({ url: "https://share.example.com" }) @@ -427,6 +429,7 @@ describe("Config", () => { path.join(tmp.path, "opencode.json"), JSON.stringify({ shell: "/bin/zsh", + default_agent: "reviewer", snapshot: false, autoshare: true, permission: { @@ -487,6 +490,7 @@ describe("Config", () => { expect(documents).toHaveLength(1) expect(documents[0]?.info).toBeInstanceOf(Config.Info) expect(documents[0]?.info.shell).toBe("/bin/zsh") + expect(documents[0]?.info.default_agent).toBe("reviewer") expect(documents[0]?.info.snapshots).toBe(false) expect(documents[0]?.info.share).toBe("auto") expect(documents[0]?.info.permissions).toEqual([ diff --git a/packages/core/test/permission.test.ts b/packages/core/test/permission.test.ts index 5bd13d844a..00b0cf9af6 100644 --- a/packages/core/test/permission.test.ts +++ b/packages/core/test/permission.test.ts @@ -165,6 +165,28 @@ describe("PermissionV2", () => { }), ) + it.effect("denies omitted-agent permissions when no primary default agent exists", () => + Effect.gen(function* () { + yield* setup() + const { db } = yield* Database.Service + yield* db + .update(SessionTable) + .set({ agent: null }) + .where(eq(SessionTable.id, SessionV2.ID.make("ses_test"))) + .run() + .pipe(Effect.orDie) + const agents = yield* AgentV2.Service + yield* agents.update((editor) => { + editor.remove(AgentV2.ID.make("test")) + editor.remove(AgentV2.ID.make("build")) + }) + + const service = yield* PermissionV2.Service + expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "deny" }) + expect(yield* service.list()).toEqual([]) + }), + ) + it.effect("evaluates bash with the normal configured-rule semantics", () => Effect.gen(function* () { yield* setup([{ action: "*", resource: "*", effect: "allow" }]) diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 376b2e3452..b05638f63f 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -6,6 +6,7 @@ import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" import { EventTable } from "@opencode-ai/core/event/sql" import { PermissionV2 } from "@opencode-ai/core/permission" +import { AgentV2 } from "@opencode-ai/core/agent" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -48,6 +49,7 @@ const permission = Layer.succeed( }), ) const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) +const agents = AgentV2.layer const model = OpenAIChat.route .with({ endpoint: { baseURL: "https://api.openai.com/v1" }, @@ -65,6 +67,7 @@ const runner = SessionRunnerLLM.defaultLayer.pipe( Layer.provide(registry), Layer.provide(models), Layer.provide(systemContext), + Layer.provide(agents), ) const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner)) const execution = Layer.effect( @@ -89,6 +92,7 @@ const it = testEffect( executor, client, permission, + agents, registry, models, systemContext, diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 160884402e..2a363bc2b1 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -31,6 +31,7 @@ import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" +import { AgentV2 } from "@opencode-ai/core/agent" import { NativeTool } from "@opencode-ai/core/tool/native" import { SessionContextEpochTable, @@ -106,6 +107,7 @@ const permission = Layer.succeed( ) const applications = ApplicationTools.layer const registry = ToolRegistry.layer.pipe(Layer.provide(permission), Layer.provide(applications)) +const agents = AgentV2.layer const echo = Layer.effectDiscard( ToolRegistry.Service.use((registry) => registry.contribute((editor) => { @@ -188,6 +190,7 @@ const runner = SessionRunnerLLM.layer.pipe( Layer.provide(registry), Layer.provide(models), Layer.provide(systemContext), + Layer.provide(agents), ) const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner)) const execution = Layer.effect( @@ -213,6 +216,7 @@ const it = testEffect( client, permission, applications, + agents, registry, echo, models, @@ -698,6 +702,78 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("includes the effective default agent system before durable context", () => + Effect.gen(function* () { + yield* setup + const agent = yield* AgentV2.Service + yield* agent.update((editor) => + editor.update(AgentV2.ID.make("build"), (agent) => { + agent.system = "Build agent instructions" + agent.mode = "primary" + }), + ) + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + + requests.length = 0 + response = fragmentFixture("text", "text-build", ["Done"]).completeEvents + yield* session.resume(sessionID) + + expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Build agent instructions", "Initial context"]) + }), + ) + + it.effect("uses the configured default agent system for omitted-agent sessions", () => + Effect.gen(function* () { + yield* setup + const agent = yield* AgentV2.Service + yield* agent.update((editor) => { + editor.update(AgentV2.ID.make("build"), (agent) => { + agent.system = "Build agent instructions" + agent.mode = "primary" + }) + editor.update(AgentV2.ID.make("reviewer"), (agent) => { + agent.system = "Reviewer instructions" + agent.mode = "primary" + }) + editor.default(AgentV2.ID.make("reviewer")) + }) + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + + requests.length = 0 + response = fragmentFixture("text", "text-reviewer", ["Done"]).completeEvents + yield* session.resume(sessionID) + + expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Reviewer instructions", "Initial context"]) + expect((yield* session.messages({ sessionID }))[0]).toMatchObject({ type: "assistant", agent: "reviewer" }) + }), + ) + + it.effect("uses an explicitly selected non-build agent system", () => + Effect.gen(function* () { + yield* setup + const { db } = yield* Database.Service + const agent = yield* AgentV2.Service + yield* agent.update((editor) => + editor.update(AgentV2.ID.make("reviewer"), (agent) => { + agent.system = "Reviewer instructions" + agent.mode = "primary" + }), + ) + yield* db.update(SessionTable).set({ agent: "reviewer" }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie) + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + + requests.length = 0 + response = fragmentFixture("text", "text-selected", ["Done"]).completeEvents + yield* session.resume(sessionID) + + expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Reviewer instructions", "Initial context"]) + expect((yield* session.messages({ sessionID }))[0]).toMatchObject({ type: "assistant", agent: "reviewer" }) + }), + ) + it.effect("admits removed context as a chronological System message", () => Effect.gen(function* () { yield* setup From 0212fd2ce74a0b061ffc0d5a9c2c3f526ae14b75 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 5 Jun 2026 10:11:43 -0400 Subject: [PATCH 17/17] refactor(core): simplify v2 runner turns (#30846) --- packages/core/src/session/runner/llm.ts | 51 +++++++++++-------------- 1 file changed, 23 insertions(+), 28 deletions(-) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 6246c4aaa8..17b27008cb 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -3,6 +3,7 @@ import { Cause, DateTime, Effect, FiberSet, Layer, Semaphore, Stream } from "eff import { EventV2 } from "../../event" import { ModelV2 } from "../../model" import { ProviderV2 } from "../../provider" +import { QuestionV2 } from "../../question" import { SessionSchema } from "../schema" import { SessionEvent } from "../event" import { SessionStore } from "../store" @@ -13,7 +14,6 @@ import { ToolRegistry } from "../../tool/registry" import { SessionRunnerModel } from "./model" import { Database } from "../../database/database" import { SessionInput } from "../input" -import { QuestionV2 } from "../../question" import { SystemContextRegistry } from "../../system-context-registry" import { SessionContextEpoch } from "../context-epoch" import { AgentV2 } from "../../agent" @@ -69,7 +69,7 @@ import { AgentV2 } from "../../agent" * - [ ] Coalesce streamed deltas and add covering projected-history indexes. * - [ ] Update title, summaries, compaction state, and cleanup in bounded background work. * - * Use `llm.stream(request)` for each provider turn. Keep tool execution and continuation here. + * Use `llm.stream(request)` for each provider turn. Keep tool execution and activity continuation here. * Durable activity recovery remains a separate future slice with an explicit retry policy. * * The current slice loads V2 history, translates it, resolves a model through a core service, and persists one @@ -79,6 +79,7 @@ import { AgentV2 } from "../../agent" // QUESTION: Did this exist previously, or did we add this limit? Does it make sense? const MAX_STEPS = 25 +type Promotion = Extract export const layer = Layer.effect( Service, @@ -97,13 +98,10 @@ export const layer = Layer.effect( return session }) - const getContext = Effect.fn("SessionRunner.getContext")(function* (sessionID: SessionSchema.ID) { - return yield* store.context(sessionID) - }) const failInterruptedTools = Effect.fn("SessionRunner.failInterruptedTools")(function* ( sessionID: SessionSchema.ID, ) { - for (const message of yield* getContext(sessionID)) { + for (const message of yield* store.context(sessionID)) { if (message.type !== "assistant") continue for (const tool of message.content) { if (tool.type !== "tool" || (tool.state.status !== "pending" && tool.state.status !== "running")) continue @@ -131,7 +129,7 @@ export const layer = Layer.effect( const runTurn = Effect.fn("SessionRunner.runTurn")(function* ( sessionID: SessionSchema.ID, - promotion: "steer" | "queue" | undefined, + promotion: Promotion | undefined, ) { const session = yield* getSession(sessionID) const initialized = yield* SessionContextEpoch.initialize(db, systemContext, session.id, session.location) @@ -218,18 +216,16 @@ export const layer = Layer.effect( }), ) } - if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers) + const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause) + if (streamInterrupted) yield* FiberSet.clear(toolFibers) const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit) if (settled._tag === "Failure" && isQuestionRejected(settled.cause)) { yield* FiberSet.clear(toolFibers) yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted")) return yield* Effect.interrupt } - if ( - (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) || - (settled._tag === "Failure" && Cause.hasInterrupts(settled.cause)) - ) { - yield* FiberSet.clear(toolFibers) + if (streamInterrupted || (settled._tag === "Failure" && Cause.hasInterrupts(settled.cause))) { + if (!streamInterrupted) yield* FiberSet.clear(toolFibers) yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted")) } if (publisher.hasProviderError()) @@ -243,6 +239,18 @@ export const layer = Layer.effect( ) }, Effect.scoped) + const runActivity = Effect.fn("SessionRunner.runActivity")(function* ( + sessionID: SessionSchema.ID, + initialPromotion: Promotion | undefined, + ) { + for (let step = 0; step < MAX_STEPS; step++) { + const needsContinuation = yield* runTurn(sessionID, step === 0 ? initialPromotion : "steer") + if (needsContinuation || (yield* SessionInput.hasPending(db, sessionID, "steer"))) continue + return + } + return yield* new StepLimitExceededError({ sessionID, limit: MAX_STEPS }) + }) + const run = Effect.fn("SessionRunner.run")(function* (input: { readonly sessionID: SessionSchema.ID readonly force?: boolean @@ -251,21 +259,8 @@ export const layer = Layer.effect( const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue") if (input.force !== true && !hasSteer && !hasQueue) return yield* failInterruptedTools(input.sessionID) - let promotion: "steer" | "queue" | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined - let openActivity = input.force === true || hasSteer || hasQueue - while (openActivity) { - let needsContinuation = true - for (let step = 0; step < MAX_STEPS; step++) { - needsContinuation = yield* runTurn(input.sessionID, promotion) - promotion = "steer" - if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer") - if (!needsContinuation) break - } - if (needsContinuation) - return yield* new StepLimitExceededError({ sessionID: input.sessionID, limit: MAX_STEPS }) - openActivity = yield* SessionInput.hasPending(db, input.sessionID, "queue") - promotion = openActivity ? "queue" : undefined - } + yield* runActivity(input.sessionID, hasSteer ? "steer" : hasQueue ? "queue" : undefined) + while (yield* SessionInput.hasPending(db, input.sessionID, "queue")) yield* runActivity(input.sessionID, "queue") }) return Service.of({