From 76ecf2e58c23a384244ae816092244e0fbd3ef33 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 4 Jun 2026 19:24:30 -0400 Subject: [PATCH] refactor(core): make v2 session inputs event sourced (#30785) --- .../migration.sql | 28 + .../snapshot.json | 1898 +++++++++++++++++ packages/core/src/database/migration.gen.ts | 1 + ...60604172448_event_sourced_session_input.ts | 47 + packages/core/src/event.ts | 121 +- packages/core/src/event/sql.ts | 4 +- packages/core/src/session.ts | 34 +- packages/core/src/session/event.ts | 48 +- packages/core/src/session/input.ts | 280 ++- packages/core/src/session/message-id.ts | 13 + packages/core/src/session/message-updater.ts | 114 +- packages/core/src/session/message.ts | 6 +- packages/core/src/session/projector.ts | 101 +- packages/core/src/session/runner/llm.ts | 19 +- .../src/session/runner/publish-llm-event.ts | 19 +- packages/core/src/session/sql.ts | 12 +- packages/core/test/database-migration.test.ts | 83 +- packages/core/test/event.test.ts | 180 +- packages/core/test/session-create.test.ts | 100 +- packages/core/test/session-projector.test.ts | 211 +- packages/core/test/session-prompt.test.ts | 188 +- .../core/test/session-runner-message.test.ts | 3 +- .../core/test/session-runner-recorded.test.ts | 6 +- packages/core/test/session-runner.test.ts | 66 +- .../core/test/session-tool-progress.test.ts | 6 +- .../src/cli/cmd/tui/context/sync-v2.tsx | 145 +- .../src/server/routes/instance/httpapi/api.ts | 2 +- .../routes/instance/httpapi/groups/global.ts | 6 +- .../routes/instance/httpapi/groups/sync.ts | 5 +- .../routes/instance/httpapi/handlers/sync.ts | 2 +- packages/opencode/src/session/compaction.ts | 4 +- packages/opencode/src/session/processor.ts | 81 +- packages/opencode/src/session/prompt.ts | 6 + .../opencode/test/cli/tui/sync-v2.test.tsx | 488 ++++- .../test/server/httpapi-session.test.ts | 19 +- .../opencode/test/server/httpapi-sync.test.ts | 7 + .../storage/workspace-time-migration.test.ts | 4 +- .../test/v2/session-message-updater.test.ts | 16 +- packages/sdk/js/src/v2/gen/types.gen.ts | 159 +- packages/sdk/openapi.json | 784 +++++-- packages/server/src/groups/v2/session.ts | 2 +- specs/v2/schema-changelog.md | 26 +- specs/v2/session.md | 6 +- 43 files changed, 4632 insertions(+), 718 deletions(-) create mode 100644 packages/core/migration/20260604172448_event_sourced_session_input/migration.sql create mode 100644 packages/core/migration/20260604172448_event_sourced_session_input/snapshot.json create mode 100644 packages/core/src/database/migration/20260604172448_event_sourced_session_input.ts create mode 100644 packages/core/src/session/message-id.ts diff --git a/packages/core/migration/20260604172448_event_sourced_session_input/migration.sql b/packages/core/migration/20260604172448_event_sourced_session_input/migration.sql new file mode 100644 index 0000000000..0c89cc8036 --- /dev/null +++ b/packages/core/migration/20260604172448_event_sourced_session_input/migration.sql @@ -0,0 +1,28 @@ +DELETE FROM `session_input`;--> statement-breakpoint +DELETE FROM `session_message`;--> statement-breakpoint +DELETE FROM `event`;--> statement-breakpoint +DELETE FROM `event_sequence`;--> statement-breakpoint +UPDATE `session` SET `workspace_id` = NULL;--> statement-breakpoint +DELETE FROM `workspace`;--> statement-breakpoint +DROP INDEX IF EXISTS `event_aggregate_seq_idx`;--> statement-breakpoint +CREATE UNIQUE INDEX `event_aggregate_seq_idx` ON `event` (`aggregate_id`,`seq`);--> statement-breakpoint +DROP INDEX IF EXISTS `session_message_session_seq_idx`;--> statement-breakpoint +CREATE UNIQUE INDEX `session_message_session_seq_idx` ON `session_message` (`session_id`,`seq`);--> statement-breakpoint +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_session_input` ( + `id` text PRIMARY KEY, + `session_id` text NOT NULL, + `prompt` text NOT NULL, + `delivery` text NOT NULL, + `admitted_seq` integer NOT NULL, + `promoted_seq` integer, + `time_created` integer NOT NULL, + CONSTRAINT `fk_session_input_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +DROP TABLE `session_input`;--> statement-breakpoint +ALTER TABLE `__new_session_input` RENAME TO `session_input`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE INDEX `session_input_session_pending_delivery_seq_idx` ON `session_input` (`session_id`,`promoted_seq`,`delivery`,`admitted_seq`);--> statement-breakpoint +CREATE UNIQUE INDEX `session_input_session_admitted_seq_idx` ON `session_input` (`session_id`,`admitted_seq`);--> statement-breakpoint +CREATE UNIQUE INDEX `session_input_session_promoted_seq_idx` ON `session_input` (`session_id`,`promoted_seq`); diff --git a/packages/core/migration/20260604172448_event_sourced_session_input/snapshot.json b/packages/core/migration/20260604172448_event_sourced_session_input/snapshot.json new file mode 100644 index 0000000000..4e916637ba --- /dev/null +++ b/packages/core/migration/20260604172448_event_sourced_session_input/snapshot.json @@ -0,0 +1,1898 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "84c6ad6c-6116-48e1-b973-6fee4593496b", + "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_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": "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": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "admitted_seq", + "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_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": ["id"], + "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": true, + "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": "admitted_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": "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": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": true, + "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" + } + ], + "renames": [] +} diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 9f2ba82eb3..59ed2fdbcf 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -31,5 +31,6 @@ export const migrations = ( import("./migration/20260603040000_session_message_projection_order"), import("./migration/20260603141458_session_input_inbox"), import("./migration/20260603160727_jittery_ezekiel_stane"), + import("./migration/20260604172448_event_sourced_session_input"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260604172448_event_sourced_session_input.ts b/packages/core/src/database/migration/20260604172448_event_sourced_session_input.ts new file mode 100644 index 0000000000..24a31bfae1 --- /dev/null +++ b/packages/core/src/database/migration/20260604172448_event_sourced_session_input.ts @@ -0,0 +1,47 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260604172448_event_sourced_session_input", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`DELETE FROM \`session_input\`;`) + yield* tx.run(`DELETE FROM \`session_message\`;`) + yield* tx.run(`DELETE FROM \`event\`;`) + yield* tx.run(`DELETE FROM \`event_sequence\`;`) + yield* tx.run(`UPDATE \`session\` SET \`workspace_id\` = NULL;`) + yield* tx.run(`DELETE FROM \`workspace\`;`) + yield* tx.run(`DROP INDEX IF EXISTS \`event_aggregate_seq_idx\`;`) + yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`) + yield* tx.run(`DROP INDEX IF EXISTS \`session_message_session_seq_idx\`;`) + yield* tx.run( + `CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`, + ) + yield* tx.run(`PRAGMA foreign_keys=OFF;`) + yield* tx.run(` + CREATE TABLE \`__new_session_input\` ( + \`id\` text PRIMARY KEY, + \`session_id\` text NOT NULL, + \`prompt\` text NOT NULL, + \`delivery\` text NOT NULL, + \`admitted_seq\` integer NOT NULL, + \`promoted_seq\` integer, + \`time_created\` integer NOT NULL, + CONSTRAINT \`fk_session_input_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(`DROP TABLE \`session_input\`;`) + yield* tx.run(`ALTER TABLE \`__new_session_input\` RENAME TO \`session_input\`;`) + yield* tx.run(`PRAGMA foreign_keys=ON;`) + yield* tx.run( + `CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`, + ) + yield* tx.run( + `CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`, + ) + yield* tx.run( + `CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`, + ) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 79708e4d3d..57dd587dec 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -1,14 +1,15 @@ export * as EventV2 from "./event" -import { Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect" +import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect" import { and, asc, eq, gt } from "drizzle-orm" import { Database } from "./database/database" import { EventSequenceTable, EventTable } from "./event/sql" import { Location } from "./location" import { externalID, type ExternalID, NonNegativeInt, withStatics } from "./schema" import { Identifier } from "./util/identifier" +import { isDeepStrictEqual } from "node:util" -export const ID = Schema.String.pipe( +export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe( Schema.brand("Event.ID"), withStatics((schema) => ({ create: () => schema.make("evt_" + Identifier.ascending()), @@ -258,16 +259,47 @@ export const layerWith = (options?: LayerOptions) => .get() .pipe(Effect.orDie) const latest = row?.seq ?? -1 - if (input && input.seq <= latest) return - if (input && row?.ownerID && row.ownerID !== input.ownerID) { - if (input.strictOwner) { - yield* Effect.die( - new InvalidSyncEventError({ - type: event.type, - message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`, - }), - ) + const encoded = syncRegistry + .get(versionedType(definition.type, sync.version))! + .encode(event.data) as Record + if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) { + yield* Effect.die( + new InvalidSyncEventError({ + type: event.type, + message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`, + }), + ) + } + if (input && input.seq <= latest) { + const stored = yield* db + .select() + .from(EventTable) + .where(and(eq(EventTable.aggregate_id, aggregateID), eq(EventTable.seq, input.seq))) + .get() + .pipe(Effect.orDie) + if ( + stored?.id === event.id && + stored.type === versionedType(definition.type, sync.version) && + isDeepStrictEqual(stored.data, encoded) + ) { + if (input.ownerID && row?.ownerID == null) { + yield* db + .update(EventSequenceTable) + .set({ owner_id: input.ownerID }) + .where(eq(EventSequenceTable.aggregate_id, aggregateID)) + .run() + .pipe(Effect.orDie) + } + return } + yield* Effect.die( + new InvalidSyncEventError({ + type: event.type, + message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`, + }), + ) + } + if (input && row?.ownerID && row.ownerID !== input.ownerID) { return } const seq = input?.seq ?? latest + 1 @@ -279,15 +311,25 @@ export const layerWith = (options?: LayerOptions) => }), ) } + const stored = yield* db + .select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq }) + .from(EventTable) + .where(eq(EventTable.id, event.id)) + .get() + .pipe(Effect.orDie) + if (stored) + yield* Effect.die( + new InvalidSyncEventError({ + type: event.type, + message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`, + }), + ) for (const guard of commitGuards) { yield* guard(event) } for (const projector of list) { yield* projector({ ...event, seq } as Payload) } - 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 }]) @@ -308,7 +350,7 @@ export const layerWith = (options?: LayerOptions) => aggregate_id: aggregateID, seq, type: versionedType(definition.type, sync.version), - data: encoded as Record, + data: encoded, }, ]) .run() @@ -337,22 +379,43 @@ export const layerWith = (options?: LayerOptions) => return Effect.gen(function* () { const durable = registry.get(event.type)?.sync !== undefined if (durable) { - for (const sync of syncHandlers) { - yield* sync(event as Payload) - } const committed = yield* commitSyncEvent(event as Payload) - if (committed) event = { ...event, seq: committed.seq } + if (committed) { + event = { ...event, seq: committed.seq } + yield* Effect.forEach(syncHandlers, (sync) => observe(event as Payload, "sync", sync), { discard: true }) + yield* notify(event as Payload, true) + return event + } } - for (const listener of listeners) { - yield* listener(event as Payload) - } - const pubsub = typed.get(event.type) - if (pubsub) yield* PubSub.publish(pubsub, event as Payload) - yield* PubSub.publish(all, event as Payload) + yield* notify(event as Payload, false) return event }) } + const observe = (event: Payload, kind: "sync" | "listener", observer: (event: Payload) => Effect.Effect) => + Effect.suspend(() => observer(event)).pipe( + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + (cause) => + Effect.logError("Event observer failed").pipe( + Effect.annotateLogs({ eventID: event.id, eventType: event.type, kind, cause }), + ), + ), + ) + + function notify(event: Payload, isolateListeners: boolean) { + return Effect.gen(function* () { + yield* Effect.forEach( + listeners, + (listener) => (isolateListeners ? observe(event, "listener", listener) : listener(event)), + { discard: true }, + ) + const pubsub = typed.get(event.type) + if (pubsub) yield* PubSub.publish(pubsub, event) + yield* PubSub.publish(all, event) + }) + } + function publish(definition: D, data: Data, options?: PublishOptions) { return Effect.gen(function* () { const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service)) @@ -396,13 +459,7 @@ export const layerWith = (options?: LayerOptions) => strictOwner: options?.strictOwner, }) if (committed && options?.publish) { - const published = { ...payload, seq: committed.seq } - for (const listener of listeners) { - yield* listener(published) - } - const pubsub = typed.get(payload.type) - if (pubsub) yield* PubSub.publish(pubsub, published) - yield* PubSub.publish(all, published) + yield* notify({ ...payload, seq: committed.seq }, true) } } }) diff --git a/packages/core/src/event/sql.ts b/packages/core/src/event/sql.ts index 74f0ab4834..38fe34f1e3 100644 --- a/packages/core/src/event/sql.ts +++ b/packages/core/src/event/sql.ts @@ -1,4 +1,4 @@ -import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core" +import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core" import type { EventV2 } from "../event" export const EventSequenceTable = sqliteTable("event_sequence", { @@ -19,7 +19,7 @@ export const EventTable = sqliteTable( data: text({ mode: "json" }).$type>().notNull(), }, (table) => [ - index("event_aggregate_seq_idx").on(table.aggregate_id, table.seq), + uniqueIndex("event_aggregate_seq_idx").on(table.aggregate_id, table.seq), index("event_aggregate_type_seq_idx").on(table.aggregate_id, table.type, table.seq), ], ) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 54203f1044..6ccc46da6c 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -1,7 +1,7 @@ export * as SessionV2 from "./session" export * from "./session/schema" -import { Cause, DateTime, Effect, Layer, Schema, Context, Stream } from "effect" +import { Cause, Effect, Layer, Schema, Context, Stream } from "effect" import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm" import { ProjectV2 } from "./project" import { WorkspaceV2 } from "./workspace" @@ -10,7 +10,6 @@ import { Location } from "./location" import { SessionMessage } from "./session/message" import { Prompt } from "./session/prompt" import { EventV2 } from "./event" -import { ProviderV2 } from "./provider" import { Database } from "./database/database" import { SessionProjector } from "./session/projector" import { SessionMessageTable, SessionTable } from "./session/sql" @@ -140,7 +139,7 @@ export interface Interface { prompt: Prompt delivery?: SessionInput.Delivery resume?: boolean - }) => Effect.Effect + }) => Effect.Effect readonly shell: (input: { id?: EventV2.ID sessionID: SessionSchema.ID @@ -198,20 +197,6 @@ export const layer = Layer.effect( ), ) - const findExistingPrompt = Effect.fnUntraced(function* (input: { - sessionID: SessionSchema.ID - messageID: SessionMessage.ID - prompt: Prompt - delivery: SessionInput.Delivery - }) { - const stored = yield* SessionInput.find(db, input.messageID) - if (!stored) return yield* SessionInput.reconcileProjected(db, { id: input.messageID, ...input }) - if (!SessionInput.equivalent(stored, input)) { - return yield* new PromptConflictError({ sessionID: input.sessionID, messageID: input.messageID }) - } - return stored - }) - const result = Service.of({ create: Effect.fn("V2Session.create")(function* (input) { const sessionID = input.id ?? SessionSchema.ID.create() @@ -367,20 +352,23 @@ export const layer = Layer.effect( yield* result.get(input.sessionID) const returnPrompt = Effect.fnUntraced(function* (admitted: SessionInput.Admitted) { if (input.resume !== false) yield* enqueueWake(input.sessionID) - return SessionInput.toMessage(admitted) + return admitted }, Effect.uninterruptible) const messageID = input.id ?? SessionMessage.ID.create() const delivery = input.delivery ?? "steer" const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery } - const existing = yield* findExistingPrompt(expected) - if (existing) return yield* returnPrompt(existing) - const admitted = yield* SessionInput.admit(db, { + const admitted = yield* SessionInput.admit(db, events, { id: messageID, sessionID: input.sessionID, prompt: input.prompt, delivery, - }) - if (!admitted) return yield* new PromptConflictError({ sessionID: input.sessionID, messageID }) + }).pipe( + Effect.catchDefect((defect) => + defect instanceof SessionInput.LifecycleConflict + ? new PromptConflictError({ sessionID: input.sessionID, messageID }) + : Effect.die(defect), + ), + ) if (!SessionInput.equivalent(admitted, expected)) return yield* new PromptConflictError({ sessionID: input.sessionID, messageID }) return yield* returnPrompt(admitted) diff --git a/packages/core/src/session/event.ts b/packages/core/src/session/event.ts index acfbb3fb97..a85e807d24 100644 --- a/packages/core/src/session/event.ts +++ b/packages/core/src/session/event.ts @@ -9,6 +9,7 @@ import { FileAttachment, Prompt } from "./prompt" import { SessionSchema } from "./schema" import { Location } from "../location" import { RelativePath } from "../schema" +import { SessionMessageID } from "./message-id" export { FileAttachment } @@ -52,6 +53,7 @@ export const AgentSwitched = EventV2.define({ ...options, schema: { ...Base, + messageID: SessionMessageID.ID, agent: Schema.String, }, }) @@ -62,6 +64,7 @@ export const ModelSwitched = EventV2.define({ ...options, schema: { ...Base, + messageID: SessionMessageID.ID, model: ModelV2.Ref, }, }) @@ -83,17 +86,45 @@ export const Prompted = EventV2.define({ ...options, schema: { ...Base, + messageID: SessionMessageID.ID, prompt: Prompt, delivery: Schema.Literals(["steer", "queue"]), }, }) export type Prompted = typeof Prompted.Type +export namespace PromptLifecycle { + export const Admitted = EventV2.define({ + type: "session.next.prompt.admitted", + ...options, + schema: { + ...Base, + messageID: SessionMessageID.ID, + prompt: Prompt, + delivery: Schema.Literals(["steer", "queue"]), + }, + }) + export type Admitted = typeof Admitted.Type + + export const Promoted = EventV2.define({ + type: "session.next.prompt.promoted", + ...options, + schema: { + ...Base, + messageID: SessionMessageID.ID, + prompt: Prompt, + timeCreated: V2Schema.DateTimeUtcFromMillis, + }, + }) + export type Promoted = typeof Promoted.Type +} + export const Synthetic = EventV2.define({ type: "session.next.synthetic", ...options, schema: { ...Base, + messageID: SessionMessageID.ID, text: Schema.String, }, }) @@ -105,6 +136,7 @@ export namespace Shell { ...options, schema: { ...Base, + messageID: SessionMessageID.ID, callID: Schema.String, command: Schema.String, }, @@ -129,6 +161,7 @@ export namespace Step { ...options, schema: { ...Base, + assistantMessageID: SessionMessageID.ID, agent: Schema.String, model: ModelV2.Ref, snapshot: Schema.String.pipe(Schema.optional), @@ -141,7 +174,7 @@ export namespace Step { ...stepSettlementOptions, schema: { ...Base, - assistantMessageID: EventV2.ID, + assistantMessageID: SessionMessageID.ID, finish: Schema.String, cost: Schema.Finite, tokens: Schema.Struct({ @@ -163,7 +196,7 @@ export namespace Step { ...stepSettlementOptions, schema: { ...Base, - assistantMessageID: EventV2.ID, + assistantMessageID: SessionMessageID.ID, error: UnknownError, }, }) @@ -176,6 +209,7 @@ export namespace Text { ...options, schema: { ...Base, + assistantMessageID: SessionMessageID.ID, textID: Schema.String, }, }) @@ -186,6 +220,7 @@ export namespace Text { type: "session.next.text.delta", schema: { ...Base, + assistantMessageID: SessionMessageID.ID, textID: Schema.String, delta: Schema.String, }, @@ -197,6 +232,7 @@ export namespace Text { ...options, schema: { ...Base, + assistantMessageID: SessionMessageID.ID, textID: Schema.String, text: Schema.String, }, @@ -210,6 +246,7 @@ export namespace Reasoning { ...options, schema: { ...Base, + assistantMessageID: SessionMessageID.ID, reasoningID: Schema.String, providerMetadata: ProviderMetadata.pipe(Schema.optional), }, @@ -221,6 +258,7 @@ export namespace Reasoning { type: "session.next.reasoning.delta", schema: { ...Base, + assistantMessageID: SessionMessageID.ID, reasoningID: Schema.String, delta: Schema.String, }, @@ -232,6 +270,7 @@ export namespace Reasoning { ...options, schema: { ...Base, + assistantMessageID: SessionMessageID.ID, reasoningID: Schema.String, text: Schema.String, providerMetadata: ProviderMetadata.pipe(Schema.optional), @@ -243,7 +282,7 @@ export namespace Reasoning { export namespace Tool { const ToolBase = { ...Base, - assistantMessageID: EventV2.ID, + assistantMessageID: SessionMessageID.ID, callID: Schema.String, } @@ -370,6 +409,7 @@ export namespace Compaction { ...options, schema: { ...Base, + messageID: SessionMessageID.ID, reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]), }, }) @@ -402,6 +442,8 @@ const DurableDefinitions = [ ModelSwitched, Moved, Prompted, + PromptLifecycle.Admitted, + PromptLifecycle.Promoted, Synthetic, Shell.Started, Shell.Ended, diff --git a/packages/core/src/session/input.ts b/packages/core/src/session/input.ts index e252c92d9b..0d8e9f2a66 100644 --- a/packages/core/src/session/input.ts +++ b/packages/core/src/session/input.ts @@ -1,11 +1,11 @@ export * as SessionInput from "./input" -import { and, asc, eq, inArray, isNull } from "drizzle-orm" +import { and, asc, eq, isNull, lte } from "drizzle-orm" import { DateTime, Effect, Schema } from "effect" import type { Database } from "../database/database" import type { EventV2 } from "../event" -import { EventTable } from "../event/sql" -import { NonNegativeInt, PositiveInt } from "../schema" +import { EventSequenceTable } from "../event/sql" +import { NonNegativeInt } from "../schema" import { V2Schema } from "../v2-schema" import { SessionEvent } from "./event" import { SessionMessage } from "./message" @@ -19,7 +19,7 @@ export const Delivery = Schema.Literals(["steer", "queue"]) export type Delivery = typeof Delivery.Type export class Admitted extends Schema.Class("SessionInput.Admitted")({ - seq: PositiveInt, + admittedSeq: NonNegativeInt, id: SessionMessage.ID, sessionID: SessionSchema.ID, prompt: Prompt, @@ -30,11 +30,10 @@ export class Admitted extends Schema.Class("SessionInput.Admitted")({ const decodePrompt = Schema.decodeUnknownSync(Prompt) const encodePrompt = Schema.encodeSync(Prompt) -const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) const fromRow = (row: typeof SessionInputTable.$inferSelect): Admitted => new Admitted({ - seq: row.seq, + admittedSeq: row.admitted_seq, id: SessionMessage.ID.make(row.id), sessionID: SessionSchema.ID.make(row.session_id), prompt: decodePrompt(row.prompt), @@ -48,8 +47,13 @@ export const find = Effect.fn("SessionInput.find")(function* (db: DatabaseServic return row === undefined ? undefined : fromRow(row) }) +export class LifecycleConflict extends Schema.TaggedErrorClass()("SessionInput.LifecycleConflict", { + id: SessionMessage.ID, +}) {} + export const admit = Effect.fn("SessionInput.admit")(function* ( db: DatabaseService, + events: EventV2.Interface, input: { readonly id: SessionMessage.ID readonly sessionID: SessionSchema.ID @@ -57,49 +61,124 @@ export const admit = Effect.fn("SessionInput.admit")(function* ( readonly delivery: Delivery }, ) { - return yield* db - .transaction( - () => - Effect.gen(function* () { - const existing = yield* find(db, input.id) - if (existing !== undefined) return existing - const event = yield* db - .select({ id: EventTable.id }) - .from(EventTable) - .where(eq(EventTable.id, input.id)) - .get() - .pipe(Effect.orDie) - const message = yield* db - .select({ id: SessionMessageTable.id }) - .from(SessionMessageTable) - .where(eq(SessionMessageTable.id, input.id)) - .get() - .pipe(Effect.orDie) - if (event !== undefined || message !== undefined) return undefined - const row = yield* db - .insert(SessionInputTable) - .values({ - id: input.id, - session_id: input.sessionID, - prompt: encodePrompt(input.prompt), - delivery: input.delivery, - }) - .returning() - .get() - .pipe(Effect.orDie) - return fromRow(row) - }), - { behavior: "immediate" }, + const existing = yield* find(db, input.id) + if (existing !== undefined) return existing + const timestamp = yield* DateTime.now + return yield* events + .publish(SessionEvent.PromptLifecycle.Admitted, { + messageID: input.id, + sessionID: input.sessionID, + timestamp, + prompt: input.prompt, + delivery: input.delivery, + }) + .pipe( + Effect.flatMap((event) => + event.seq === undefined + ? Effect.die("Prompt admission event is missing aggregate sequence") + : Effect.succeed( + new Admitted({ + admittedSeq: event.seq, + id: input.id, + sessionID: input.sessionID, + prompt: input.prompt, + delivery: input.delivery, + timeCreated: timestamp, + }), + ), + ), + Effect.catchDefect((defect) => + find(db, input.id).pipe(Effect.flatMap((stored) => (stored ? Effect.succeed(stored) : Effect.die(defect)))), + ), ) +}) + +export const latestSeq = Effect.fn("SessionInput.latestSeq")(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, +) { + const row = yield* db + .select({ seq: EventSequenceTable.seq }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, sessionID)) + .get() .pipe(Effect.orDie) + return row?.seq ?? -1 +}) + +export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(function* ( + db: DatabaseService, + input: { + readonly admittedSeq: number + readonly id: SessionMessage.ID + readonly sessionID: SessionSchema.ID + readonly prompt: Prompt + readonly delivery: Delivery + readonly timeCreated: DateTime.Utc + }, +) { + const message = yield* db + .select({ id: SessionMessageTable.id }) + .from(SessionMessageTable) + .where(eq(SessionMessageTable.id, input.id)) + .get() + .pipe(Effect.orDie) + if (message) return yield* Effect.die(new LifecycleConflict({ id: input.id })) + const stored = yield* db + .insert(SessionInputTable) + .values({ + id: input.id, + session_id: input.sessionID, + admitted_seq: input.admittedSeq, + prompt: encodePrompt(input.prompt), + delivery: input.delivery, + time_created: DateTime.toEpochMillis(input.timeCreated), + }) + .onConflictDoNothing() + .returning({ id: SessionInputTable.id }) + .get() + .pipe(Effect.orDie) + if (!stored) return yield* Effect.die(new LifecycleConflict({ id: input.id })) +}) + +export const projectPromoted = Effect.fn("SessionInput.projectPromoted")(function* ( + db: DatabaseService, + input: { + readonly id: SessionMessage.ID + readonly sessionID: SessionSchema.ID + readonly prompt: Prompt + readonly timeCreated: DateTime.Utc + readonly promotedSeq: number + }, +) { + const updated = yield* db + .update(SessionInputTable) + .set({ promoted_seq: input.promotedSeq }) + .where( + and( + eq(SessionInputTable.id, input.id), + eq(SessionInputTable.session_id, input.sessionID), + isNull(SessionInputTable.promoted_seq), + ), + ) + .returning() + .get() + .pipe(Effect.orDie) + if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id })) + const stored = fromRow(updated) + if ( + !matchesPrompt(stored, input) || + DateTime.toEpochMillis(stored.timeCreated) !== DateTime.toEpochMillis(input.timeCreated) + ) + return yield* Effect.die(new LifecycleConflict({ id: input.id })) + return toMessage(stored) }) export const hasPending = Effect.fn("SessionInput.hasPending")(function* ( db: DatabaseService, sessionID: SessionSchema.ID, - deliveries: ReadonlyArray = ["steer", "queue"], + delivery: Delivery, ) { - if (deliveries.length === 0) return false const row = yield* db .select({ id: SessionInputTable.id }) .from(SessionInputTable) @@ -107,7 +186,7 @@ export const hasPending = Effect.fn("SessionInput.hasPending")(function* ( and( eq(SessionInputTable.session_id, sessionID), isNull(SessionInputTable.promoted_seq), - inArray(SessionInputTable.delivery, deliveries), + eq(SessionInputTable.delivery, delivery), ), ) .limit(1) @@ -133,14 +212,34 @@ export const guardReservedID = Effect.fn("SessionInput.guardReservedID")(functio db: DatabaseService, event: EventV2.Payload, ) { - const admitted = yield* find(db, event.id) + if ( + Schema.is(SessionEvent.PromptLifecycle.Admitted)(event) || + Schema.is(SessionEvent.PromptLifecycle.Promoted)(event) + ) + return + const id = reservedID(event) + if (id === undefined) return + const admitted = yield* db + .select({ id: SessionInputTable.id }) + .from(SessionInputTable) + .where(eq(SessionInputTable.id, id)) + .get() + .pipe(Effect.orDie) if (admitted === undefined) return - if (!Schema.is(SessionEvent.Prompted)(event)) - return yield* Effect.die("Durable event conflicts with admitted prompt input") - if (!equivalent(admitted, event.data)) return yield* Effect.die("Prompt projection conflicts with admitted input") + return yield* Effect.die(new LifecycleConflict({ id })) }) -export const project = Effect.fn("SessionInput.project")(function* ( +const reservedID = (event: EventV2.Payload) => { + if (Schema.is(SessionEvent.Step.Started)(event)) return event.data.assistantMessageID + if (Schema.is(SessionEvent.AgentSwitched)(event)) return event.data.messageID + if (Schema.is(SessionEvent.ModelSwitched)(event)) return event.data.messageID + if (Schema.is(SessionEvent.Prompted)(event)) return event.data.messageID + if (Schema.is(SessionEvent.Synthetic)(event)) return event.data.messageID + if (Schema.is(SessionEvent.Shell.Started)(event)) return event.data.messageID + if (Schema.is(SessionEvent.Compaction.Started)(event)) return event.data.messageID +} + +export const projectLegacyPrompted = Effect.fn("SessionInput.projectLegacyPrompted")(function* ( db: DatabaseService, input: { readonly id: SessionMessage.ID @@ -151,82 +250,49 @@ export const project = Effect.fn("SessionInput.project")(function* ( readonly promotedSeq: number }, ) { - yield* db + const inserted = yield* db .insert(SessionInputTable) .values({ id: input.id, session_id: input.sessionID, + admitted_seq: input.promotedSeq, prompt: encodePrompt(input.prompt), delivery: input.delivery, promoted_seq: input.promotedSeq, time_created: DateTime.toEpochMillis(input.timeCreated), }) .onConflictDoNothing() - .run() - .pipe(Effect.orDie) - const admitted = yield* find(db, input.id) - if (admitted === undefined || admitted.delivery !== input.delivery || !matchesPrompt(admitted, input)) - return yield* Effect.die("Prompt projection conflicts with admitted input") - yield* db - .update(SessionInputTable) - .set({ promoted_seq: input.promotedSeq }) - .where( - and( - eq(SessionInputTable.id, input.id), - eq(SessionInputTable.session_id, input.sessionID), - isNull(SessionInputTable.promoted_seq), - ), - ) - .run() - .pipe(Effect.orDie) - return yield* find(db, input.id) -}) - -export const reconcileProjected = Effect.fn("SessionInput.reconcileProjected")(function* ( - db: DatabaseService, - expected: { - readonly id: SessionMessage.ID - readonly sessionID: SessionSchema.ID - readonly prompt: Prompt - readonly delivery: Delivery - }, -) { - if (expected.delivery !== "steer") return undefined - const row = yield* db - .select() - .from(SessionMessageTable) - .where(eq(SessionMessageTable.id, expected.id)) + .returning() .get() .pipe(Effect.orDie) - if (row === undefined || row.session_id !== expected.sessionID || row.type !== "user") return undefined - const message = decodeMessage({ ...row.data, id: row.id, type: row.type }) - if (message.type !== "user" || !Prompt.equivalence(Prompt.fromUserMessage(message), expected.prompt)) return undefined - return yield* project(db, { - id: expected.id, - sessionID: expected.sessionID, - prompt: expected.prompt, - delivery: expected.delivery, - timeCreated: message.time.created, - promotedSeq: row.seq, - }) + if (!inserted) return yield* Effect.die("Prompt projection conflicts with admitted input") + return fromRow(inserted) }) const publish = Effect.fn("SessionInput.publish")(function* ( + db: DatabaseService, events: EventV2.Interface, sessionID: SessionSchema.ID, rows: ReadonlyArray, ) { for (const row of rows) { - yield* events.publish( - SessionEvent.Prompted, - { + yield* events + .publish(SessionEvent.PromptLifecycle.Promoted, { sessionID, - timestamp: DateTime.makeUnsafe(row.time_created), + timestamp: yield* DateTime.now, + messageID: SessionMessage.ID.make(row.id), prompt: decodePrompt(row.prompt), - delivery: row.delivery, - }, - { id: SessionMessage.ID.make(row.id) }, - ) + timeCreated: DateTime.makeUnsafe(row.time_created), + }) + .pipe( + Effect.catchDefect((defect) => + defect instanceof LifecycleConflict + ? find(db, SessionMessage.ID.make(row.id)).pipe( + Effect.flatMap((stored) => (stored?.promotedSeq === undefined ? Effect.die(defect) : Effect.void)), + ) + : Effect.die(defect), + ), + ) } return rows.length }) @@ -235,6 +301,7 @@ export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* ( db: DatabaseService, events: EventV2.Interface, sessionID: SessionSchema.ID, + cutoff: number, ) { const rows = yield* db .select() @@ -244,12 +311,13 @@ export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* ( eq(SessionInputTable.session_id, sessionID), isNull(SessionInputTable.promoted_seq), eq(SessionInputTable.delivery, "steer"), + lte(SessionInputTable.admitted_seq, cutoff), ), ) - .orderBy(asc(SessionInputTable.seq)) + .orderBy(asc(SessionInputTable.admitted_seq)) .all() .pipe(Effect.orDie) - return yield* publish(events, sessionID, rows) + return yield* publish(db, events, sessionID, rows) }) export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(function* ( @@ -267,14 +335,14 @@ export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(fun eq(SessionInputTable.delivery, "queue"), ), ) - .orderBy(asc(SessionInputTable.seq)) + .orderBy(asc(SessionInputTable.admitted_seq)) .limit(1) .get() .pipe(Effect.orDie) - return row === undefined ? false : yield* publish(events, sessionID, [row]).pipe(Effect.as(true)) + return row === undefined ? false : yield* publish(db, events, sessionID, [row]).pipe(Effect.as(true)) }) -export const toMessage = (input: Admitted) => +const toMessage = (input: Admitted) => new SessionMessage.User({ id: input.id, type: "user", diff --git a/packages/core/src/session/message-id.ts b/packages/core/src/session/message-id.ts new file mode 100644 index 0000000000..f06fc0fcd5 --- /dev/null +++ b/packages/core/src/session/message-id.ts @@ -0,0 +1,13 @@ +export * as SessionMessageID from "./message-id" + +import { Schema } from "effect" +import { withStatics } from "../schema" +import { Identifier } from "../util/identifier" + +export const ID = Schema.String.check(Schema.isStartsWith("msg_")).pipe( + Schema.brand("Session.Message.ID"), + withStatics((schema) => ({ + create: () => schema.make("msg_" + Identifier.ascending()), + })), +) +export type ID = typeof ID.Type diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 65d53e1517..a7a3383205 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -123,7 +123,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.next.agent.switched": (event) => { return adapter.appendMessage( new SessionMessage.AgentSwitched({ - id: event.id, + id: event.data.messageID, type: "agent-switched", metadata: event.metadata, agent: event.data.agent, @@ -134,7 +134,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.next.model.switched": (event) => { return adapter.appendMessage( new SessionMessage.ModelSwitched({ - id: event.id, + id: event.data.messageID, type: "model-switched", metadata: event.metadata, model: event.data.model, @@ -146,7 +146,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.next.prompted": (event) => { return adapter.appendMessage( new SessionMessage.User({ - id: event.id, + id: event.data.messageID, type: "user", metadata: event.metadata, text: event.data.prompt.text, @@ -157,12 +157,14 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }), ) }, + "session.next.prompt.admitted": () => Effect.void, + "session.next.prompt.promoted": () => Effect.void, "session.next.synthetic": (event) => { return adapter.appendMessage( new SessionMessage.Synthetic({ sessionID: event.data.sessionID, text: event.data.text, - id: event.id, + id: event.data.messageID, type: "synthetic", time: { created: event.data.timestamp }, }), @@ -171,7 +173,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.next.shell.started": (event) => { return adapter.appendMessage( new SessionMessage.Shell({ - id: event.id, + id: event.data.messageID, type: "shell", metadata: event.metadata, callID: event.data.callID, @@ -206,7 +208,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { } yield* adapter.appendMessage( new SessionMessage.Assistant({ - id: event.id, + id: event.data.assistantMessageID, type: "assistant", agent: event.data.agent, model: event.data.model, @@ -234,43 +236,22 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }) }, "session.next.text.started": (event) => { - return Effect.gen(function* () { - const currentAssistant = yield* adapter.getCurrentAssistant() - if (currentAssistant) { - yield* adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.content.push( - castDraft(new SessionMessage.AssistantText({ type: "text", id: event.data.textID, text: "" })), - ) - }), - ) - } + return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { + draft.content.push( + castDraft(new SessionMessage.AssistantText({ type: "text", id: event.data.textID, text: "" })), + ) }) }, "session.next.text.delta": (event) => { - return Effect.gen(function* () { - const currentAssistant = yield* adapter.getCurrentAssistant() - if (currentAssistant) { - yield* adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestText(draft, event.data.textID) - if (match) match.text += event.data.delta - }), - ) - } + return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { + const match = latestText(draft, event.data.textID) + if (match) match.text += event.data.delta }) }, "session.next.text.ended": (event) => { - return Effect.gen(function* () { - const currentAssistant = yield* adapter.getCurrentAssistant() - if (currentAssistant) { - yield* adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestText(draft, event.data.textID) - if (match) match.text = event.data.text - }), - ) - } + return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { + const match = latestText(draft, event.data.textID) + if (match) match.text = event.data.text }) }, "session.next.tool.input.started": (event) => { @@ -367,52 +348,31 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }) }, "session.next.reasoning.started": (event) => { - return Effect.gen(function* () { - const currentAssistant = yield* adapter.getCurrentAssistant() - if (currentAssistant) { - yield* adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.content.push( - castDraft( - new SessionMessage.AssistantReasoning({ - type: "reasoning", - id: event.data.reasoningID, - text: "", - providerMetadata: event.data.providerMetadata, - }), - ), - ) + return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { + draft.content.push( + castDraft( + new SessionMessage.AssistantReasoning({ + type: "reasoning", + id: event.data.reasoningID, + text: "", + providerMetadata: event.data.providerMetadata, }), - ) - } + ), + ) }) }, "session.next.reasoning.delta": (event) => { - return Effect.gen(function* () { - const currentAssistant = yield* adapter.getCurrentAssistant() - if (currentAssistant) { - yield* adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestReasoning(draft, event.data.reasoningID) - if (match) match.text += event.data.delta - }), - ) - } + return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { + const match = latestReasoning(draft, event.data.reasoningID) + if (match) match.text += event.data.delta }) }, "session.next.reasoning.ended": (event) => { - return Effect.gen(function* () { - const currentAssistant = yield* adapter.getCurrentAssistant() - if (currentAssistant) { - yield* adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestReasoning(draft, event.data.reasoningID) - if (match) { - match.text = event.data.text - if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata - } - }), - ) + return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { + const match = latestReasoning(draft, event.data.reasoningID) + if (match) { + match.text = event.data.text + if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata } }) }, @@ -420,7 +380,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.next.compaction.started": (event) => { return adapter.appendMessage( new SessionMessage.Compaction({ - id: event.id, + id: event.data.messageID, type: "compaction", metadata: event.metadata, reason: event.data.reason, diff --git a/packages/core/src/session/message.ts b/packages/core/src/session/message.ts index f394df5a57..7f62236bef 100644 --- a/packages/core/src/session/message.ts +++ b/packages/core/src/session/message.ts @@ -2,15 +2,15 @@ export * as SessionMessage from "./message" import { Schema } from "effect" import { ProviderMetadata } from "@opencode-ai/llm" -import { EventV2 } from "../event" import { ModelV2 } from "../model" import { ToolOutput } from "../tool-output" import { V2Schema } from "../v2-schema" import { SessionEvent } from "./event" import { Prompt } from "./prompt" +import { SessionMessageID } from "./message-id" -export const ID = EventV2.ID -export type ID = Schema.Schema.Type +export const ID = SessionMessageID.ID +export type ID = typeof ID.Type const Base = { id: ID, diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index f7bb63e979..e780d7ceaa 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -19,7 +19,7 @@ type DatabaseService = Database.Interface["db"] const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) const encodeMessage = Schema.encodeSync(SessionMessage.Message) -export class PromptAlreadyProjected extends Error {} +class PromptAlreadyProjected extends Error {} export class SessionAlreadyProjected extends Error {} type Usage = { @@ -112,29 +112,23 @@ function run(db: DatabaseService, event: SessionEvent.Event) { return Effect.gen(function* () { const decodeRow = (row: typeof SessionMessageTable.$inferSelect) => decodeMessage({ ...row.data, id: row.id, type: row.type }) - const writeMessage = (message: SessionMessage.Message) => { + const updateMessage = (message: SessionMessage.Message) => { if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence") const encoded = encodeMessage(message) const { id, type, ...data } = encoded return db - .insert(SessionMessageTable) - .values([ - { - id: SessionMessage.ID.make(id), - session_id: event.data.sessionID, - type, - seq: event.seq, - time_created: DateTime.toEpochMillis(message.time.created), - data, - }, - ]) - .onConflictDoUpdate({ - target: SessionMessageTable.id, - set: { type, time_created: DateTime.toEpochMillis(message.time.created), data }, - }) + .update(SessionMessageTable) + .set({ type, time_created: DateTime.toEpochMillis(message.time.created), data }) + .where( + and( + eq(SessionMessageTable.id, SessionMessage.ID.make(id)), + eq(SessionMessageTable.session_id, event.data.sessionID), + ), + ) .run() .pipe(Effect.orDie) } + const appendMessage = (message: SessionMessage.Message) => insertMessage(db, event, message) const adapter: SessionMessageUpdater.Adapter = { getCurrentAssistant() { return Effect.gen(function* () { @@ -204,15 +198,33 @@ function run(db: DatabaseService, event: SessionEvent.Event) { .find((message): message is SessionMessage.Shell => message.type === "shell" && message.callID === callID) }) }, - updateAssistant: writeMessage, - updateCompaction: writeMessage, - updateShell: writeMessage, - appendMessage: writeMessage, + updateAssistant: updateMessage, + updateCompaction: updateMessage, + updateShell: updateMessage, + appendMessage, } yield* SessionMessageUpdater.update(adapter, event) }) } +function insertMessage(db: DatabaseService, event: SessionEvent.Event, message: SessionMessage.Message) { + if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence") + const encoded = encodeMessage(message) + const { id, type, ...data } = encoded + return db + .insert(SessionMessageTable) + .values({ + id: SessionMessage.ID.make(id), + session_id: event.data.sessionID, + type, + seq: event.seq, + time_created: DateTime.toEpochMillis(message.time.created), + data, + }) + .run() + .pipe(Effect.orDie) +} + export const layer = Layer.effectDiscard( Effect.gen(function* () { const events = yield* EventV2.Service @@ -349,27 +361,19 @@ export const layer = Layer.effectDiscard( ) yield* events.project(SessionEvent.Prompted, (event) => Effect.gen(function* () { + const messageID = event.data.messageID const existing = yield* db .select({ id: SessionMessageTable.id }) .from(SessionMessageTable) - .where(eq(SessionMessageTable.id, event.id)) + .where(eq(SessionMessageTable.id, messageID)) .get() .pipe(Effect.orDie) if (existing) return yield* Effect.die(new PromptAlreadyProjected()) yield* run(db, event) - const row = yield* db - .select() - .from(SessionMessageTable) - .where(eq(SessionMessageTable.id, event.id)) - .get() - .pipe(Effect.orDie) - if (!row) return yield* Effect.die("Prompt projection was not stored") - const message = decodeMessage({ ...row.data, id: row.id, type: row.type }) - if (message.type !== "user") return yield* Effect.die("Prompt projection did not produce a user message") if (event.seq === undefined) return yield* Effect.die("Synchronized Session event is missing aggregate sequence") - yield* SessionInput.project(db, { - id: SessionMessage.ID.make(event.id), + yield* SessionInput.projectLegacyPrompted(db, { + id: messageID, sessionID: event.data.sessionID, prompt: event.data.prompt, delivery: event.data.delivery, @@ -378,6 +382,37 @@ export const layer = Layer.effectDiscard( }) }), ) + yield* events.project(SessionEvent.PromptLifecycle.Admitted, (event) => + Effect.gen(function* () { + if (event.seq === undefined) + return yield* Effect.die("Synchronized Session event is missing aggregate sequence") + yield* SessionInput.projectAdmitted(db, { + admittedSeq: event.seq, + id: event.data.messageID, + sessionID: event.data.sessionID, + prompt: event.data.prompt, + delivery: event.data.delivery, + timeCreated: event.data.timestamp, + }) + }), + ) + yield* events.project(SessionEvent.PromptLifecycle.Promoted, (event) => + Effect.gen(function* () { + if (event.seq === undefined) + return yield* Effect.die("Synchronized Session event is missing aggregate sequence") + yield* insertMessage( + db, + event, + yield* SessionInput.projectPromoted(db, { + id: event.data.messageID, + sessionID: event.data.sessionID, + prompt: event.data.prompt, + timeCreated: event.data.timeCreated, + promotedSeq: 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)) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 6b51e3900c..a322b21098 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -132,10 +132,13 @@ export const layer = Layer.effect( const model = yield* models.resolve(session) const toolFibers = yield* FiberSet.make() let needsContinuation = false - if (promotion === "steer") yield* SessionInput.promoteSteers(db, events, session.id) - if (promotion === "queue") { - yield* SessionInput.promoteNextQueued(db, events, session.id) - yield* SessionInput.promoteSteers(db, events, session.id) + if (promotion) { + const cutoff = yield* SessionInput.latestSeq(db, session.id) + if (promotion === "steer") yield* SessionInput.promoteSteers(db, events, session.id, cutoff) + if (promotion === "queue") { + yield* SessionInput.promoteNextQueued(db, events, session.id) + yield* SessionInput.promoteSteers(db, events, session.id, cutoff) + } } yield* failInterruptedTools(session.id) const context = yield* getContext(session.id) @@ -233,8 +236,8 @@ export const layer = Layer.effect( readonly force?: boolean }) { const session = yield* getSession(input.sessionID) - const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, ["steer"]) - const hasQueue = yield* SessionInput.hasPending(db, input.sessionID, ["queue"]) + 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 let promotion: "steer" | "queue" | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined let openActivity = input.force === true || hasSteer || hasQueue @@ -243,12 +246,12 @@ export const layer = Layer.effect( for (let step = 0; step < MAX_STEPS; step++) { needsContinuation = yield* runTurn(session, promotion) promotion = "steer" - if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, ["steer"]) + if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer") if (!needsContinuation) break } if (needsContinuation) return yield* new StepLimitExceededError({ sessionID: input.sessionID, limit: MAX_STEPS }) - openActivity = yield* SessionInput.hasPending(db, input.sessionID, ["queue"]) + openActivity = yield* SessionInput.hasPending(db, input.sessionID, "queue") promotion = openActivity ? "queue" : undefined } }) diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 6c9bec6dd3..034018fd12 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -10,6 +10,7 @@ import { DateTime, Effect } from "effect" import { EventV2 } from "../../event" import { ModelV2 } from "../../model" import { SessionEvent } from "../event" +import { SessionMessage } from "../message" import { SessionSchema } from "../schema" type Input = { @@ -60,7 +61,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) const tools = new Map< string, { - readonly assistantMessageID: EventV2.ID + readonly assistantMessageID: SessionMessage.ID readonly name: string inputEnded: boolean called: boolean @@ -70,13 +71,17 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) } >() const timestamp = DateTime.now - let assistantMessageID: EventV2.ID | undefined + let assistantMessageID: SessionMessage.ID | undefined let providerFailed = false const startAssistant = Effect.fnUntraced(function* () { if (assistantMessageID !== undefined) return assistantMessageID - assistantMessageID = (yield* events.publish(SessionEvent.Step.Started, { ...input, timestamp: yield* timestamp })) - .id + assistantMessageID = SessionMessage.ID.create() + yield* events.publish(SessionEvent.Step.Started, { + ...input, + assistantMessageID, + timestamp: yield* timestamp, + }) return assistantMessageID }) const currentAssistantMessageID = () => @@ -118,6 +123,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) Effect.gen(function* () { yield* events.publish(SessionEvent.Text.Ended, { sessionID: input.sessionID, + assistantMessageID: yield* currentAssistantMessageID(), timestamp: yield* timestamp, textID, text: value, @@ -128,6 +134,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) Effect.gen(function* () { yield* events.publish(SessionEvent.Reasoning.Ended, { sessionID: input.sessionID, + assistantMessageID: yield* currentAssistantMessageID(), timestamp: yield* timestamp, reasoningID, text: value, @@ -220,6 +227,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* text.start(event.id) yield* events.publish(SessionEvent.Text.Started, { sessionID: input.sessionID, + assistantMessageID: yield* startAssistant(), timestamp: yield* timestamp, textID: event.id, }) @@ -228,6 +236,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* text.append(event.id, event.text) yield* events.publish(SessionEvent.Text.Delta, { sessionID: input.sessionID, + assistantMessageID: yield* currentAssistantMessageID(), timestamp: yield* timestamp, textID: event.id, delta: event.text, @@ -240,6 +249,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* reasoning.start(event.id) yield* events.publish(SessionEvent.Reasoning.Started, { sessionID: input.sessionID, + assistantMessageID: yield* startAssistant(), timestamp: yield* timestamp, reasoningID: event.id, providerMetadata: event.providerMetadata, @@ -249,6 +259,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* reasoning.append(event.id, event.text) yield* events.publish(SessionEvent.Reasoning.Delta, { sessionID: input.sessionID, + assistantMessageID: yield* currentAssistantMessageID(), timestamp: yield* timestamp, reasoningID: event.id, delta: event.text, diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index 8ecbbea240..27f22bd4bf 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -1,4 +1,4 @@ -import { sqliteTable, text, integer, index, primaryKey, real } from "drizzle-orm/sqlite-core" +import { sqliteTable, text, integer, index, primaryKey, real, uniqueIndex } from "drizzle-orm/sqlite-core" import * as DatabasePath from "../database/path" import { ProjectTable } from "../project/sql" import type { SessionMessage } from "./message" @@ -127,7 +127,7 @@ export const SessionMessageTable = sqliteTable( data: text({ mode: "json" }).notNull().$type(), }, (table) => [ - index("session_message_session_seq_idx").on(table.session_id, table.seq), + uniqueIndex("session_message_session_seq_idx").on(table.session_id, table.seq), index("session_message_session_type_seq_idx").on(table.session_id, table.type, table.seq), index("session_message_session_time_created_id_idx").on(table.session_id, table.time_created, table.id), index("session_message_time_created_idx").on(table.time_created), @@ -137,14 +137,14 @@ export const SessionMessageTable = sqliteTable( export const SessionInputTable = sqliteTable( "session_input", { - seq: integer().primaryKey({ autoIncrement: true }), - id: text().$type().notNull().unique(), + id: text().$type().primaryKey(), session_id: text() .$type() .notNull() .references(() => SessionTable.id, { onDelete: "cascade" }), prompt: text({ mode: "json" }).notNull().$type(), delivery: text().$type().notNull(), + admitted_seq: integer().notNull(), promoted_seq: integer(), time_created: integer() .notNull() @@ -155,7 +155,9 @@ export const SessionInputTable = sqliteTable( table.session_id, table.promoted_seq, table.delivery, - table.seq, + table.admitted_seq, ), + uniqueIndex("session_input_session_admitted_seq_idx").on(table.session_id, table.admitted_seq), + uniqueIndex("session_input_session_promoted_seq_idx").on(table.session_id, table.promoted_seq), ], ) diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 4c0ccb9832..6b0f1d2692 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -10,6 +10,7 @@ import { DatabaseMigration } from "@opencode-ai/core/database/migration" 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" +import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migration/20260604172448_event_sourced_session_input" import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -62,15 +63,17 @@ 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: 29 }) + expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 30 }) 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_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_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" }, @@ -79,6 +82,82 @@ describe("DatabaseMigration", () => { ) }) + 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)`) + 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]) + + 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 }), + ]) + }), + ) + }) + test("resets incompatible projected Session messages before adding sequence order", async () => { await run( Effect.gen(function* () { diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index a51c9f093f..c91d34fcfa 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { DateTime, Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect" +import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect" import { EventV2 } from "@opencode-ai/core/event" import { Database } from "@opencode-ai/core/database/database" import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql" @@ -236,6 +236,56 @@ describe("EventV2", () => { }), ) + it.effect("isolates observer defects after durable events commit", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const received = new Array() + yield* events.sync(() => Effect.die("sync defect")) + yield* events.listen(() => { + throw new Error("listener defect") + }) + yield* events.listen((event) => + Effect.sync(() => { + received.push(event.type) + }), + ) + + const event = yield* events.publish(SyncMessage, { id: "one", text: "hello" }) + + expect(received).toEqual([SyncMessage.type]) + expect(event.seq).toBeNumber() + }), + ) + + it.effect("preserves observer interruption", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + yield* events.listen(() => Effect.interrupt) + + const exit = yield* events.publish(SyncMessage, { id: "interrupted", text: "hello" }).pipe(Effect.exit) + const committed = yield* db + .select({ id: EventTable.id }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, "interrupted")) + .get() + .pipe(Effect.orDie) + + expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBeTrue() + expect(committed).toBeDefined() + }), + ) + + it.effect("keeps live-only listener defects fail-fast", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const defect = new Error("listener defect") + yield* events.listen(() => Effect.die(defect)) + + expect(yield* events.publish(Message, { text: "hello" }).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect) + }), + ) + it.effect("does not synchronize live-only events", () => Effect.gen(function* () { const events = yield* EventV2.Service @@ -254,6 +304,30 @@ describe("EventV2", () => { }), ) + it.effect("synchronizes only after the durable event commits", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const synchronized = new Array() + yield* events.sync((event) => + db + .select({ id: EventTable.id }) + .from(EventTable) + .where(eq(EventTable.id, event.id)) + .get() + .pipe( + Effect.orDie, + Effect.map((row) => synchronized.push(row !== undefined)), + Effect.asVoid, + ), + ) + + yield* events.publish(SyncMessage, { id: EventV2.ID.create(), text: "durable" }) + + expect(synchronized).toEqual([true]) + }), + ) + it.effect("inserts sync event rows on publish", () => Effect.gen(function* () { const events = yield* EventV2.Service @@ -690,6 +764,59 @@ describe("EventV2", () => { }), ) + it.effect("strict owner fences exact replay", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const aggregateID = EventV2.ID.create() + const id = EventV2.ID.create() + const replayed = { + id, + type: EventV2.versionedType(SyncMessage.type, 1), + seq: 0, + aggregateID, + data: { id: aggregateID, text: "owned" }, + } + yield* events.replay(replayed, { ownerID: "owner-a" }) + + const exit = yield* events.replay(replayed, { ownerID: "owner-b", strictOwner: true }).pipe(Effect.exit) + + expect(String(exit)).toContain("Replay owner mismatch") + }), + ) + + it.effect("exact replay claims an unowned aggregate", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const aggregateID = EventV2.ID.create() + const published = yield* events.publish(SyncMessage, { id: aggregateID, text: "owned" }) + const replayed = { + id: published.id, + type: EventV2.versionedType(SyncMessage.type, 1), + seq: published.seq!, + aggregateID, + data: published.data, + } + + yield* events.replay(replayed, { ownerID: "owner-a", strictOwner: true }) + const row = yield* db + .select({ ownerID: EventSequenceTable.owner_id }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, aggregateID)) + .get() + .pipe(Effect.orDie) + + expect(row?.ownerID).toBe("owner-a") + const exit = yield* events + .replay( + { ...replayed, id: EventV2.ID.create(), seq: 1, data: { id: aggregateID, text: "conflict" } }, + { ownerID: "owner-b", strictOwner: true }, + ) + .pipe(Effect.exit) + expect(String(exit)).toContain("Replay owner mismatch") + }), + ) + it.effect("replay with owner claims an unowned sequence", () => Effect.gen(function* () { const events = yield* EventV2.Service @@ -815,6 +942,57 @@ describe("EventV2", () => { }), ) + it.effect("rejects divergent stale replay without publishing it", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const received = new Array() + const aggregateID = EventV2.ID.create() + const replayed = { + id: EventV2.ID.create(), + type: EventV2.versionedType(SyncMessage.type, 1), + seq: 0, + aggregateID, + data: { id: aggregateID, text: "original" }, + } + yield* events.listen((event) => Effect.sync(() => received.push(event))) + yield* events.replay(replayed, { publish: true }) + + const exit = yield* events + .replay({ ...replayed, data: { id: aggregateID, text: "divergent" } }, { publish: true }) + .pipe(Effect.exit) + + expect(String(exit)).toContain("Replay diverged") + expect(received).toHaveLength(1) + }), + ) + + it.effect("rejects an event ID reused at another aggregate position", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const aggregateID = EventV2.ID.create() + const id = EventV2.ID.create() + yield* events.replay({ + id, + type: EventV2.versionedType(SyncMessage.type, 1), + seq: 0, + aggregateID, + data: { id: aggregateID, text: "first" }, + }) + + const exit = yield* events + .replay({ + id, + type: EventV2.versionedType(SyncMessage.type, 1), + seq: 1, + aggregateID, + data: { id: aggregateID, text: "second" }, + }) + .pipe(Effect.exit) + + expect(String(exit)).toContain(`Event ${id} already exists`) + }), + ) + it.effect("replay from a different owner leaves claimed sequence unchanged", () => Effect.gen(function* () { const events = yield* EventV2.Service diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 7c83ec5696..06eedd5e44 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -1,13 +1,15 @@ import { describe, expect } from "bun:test" +import path from "path" import { Effect, Layer, Stream } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" -import { eq } from "drizzle-orm" +import { asc, eq } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" import { EventTable } from "@opencode-ai/core/event/sql" import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { ProjectV2 } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" @@ -16,10 +18,12 @@ import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionInput } from "@opencode-ai/core/session/input" +import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { testEffect } from "./lib/effect" +import { tmpdir } from "./fixture/tmpdir" const database = Database.layerFromPath(":memory:") const events = EventV2.layer.pipe(Layer.provide(database)) @@ -211,11 +215,99 @@ describe("SessionV2.create", () => { const { db } = yield* Database.Service const created = yield* session.create({ location }) yield* session.prompt({ sessionID: created.id, prompt: new Prompt({ text: "Hello" }), resume: false }) - yield* SessionInput.promoteSteers(db, events, created.id) + yield* SessionInput.promoteSteers(db, events, created.id, Number.MAX_SAFE_INTEGER) expect( - Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)), - ).toMatchObject([{ cursor: 1, event: { type: "session.next.prompted", data: { prompt: { text: "Hello" } } } }]) + Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(2), Stream.runCollect)), + ).toMatchObject([ + { cursor: 1, event: { type: "session.next.prompt.admitted", data: { prompt: { text: "Hello" } } } }, + { cursor: 2, event: { type: "session.next.prompt.promoted" } }, + ]) + }), + ) + + it.effect("replays one prompt lifecycle into a fresh target database", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const sourceEvents = yield* EventV2.Service + const sourceDb = (yield* Database.Service).db + const created = yield* session.create({ id: SessionV2.ID.make("ses_fresh_target_replay"), location }) + const admitted = yield* session.prompt({ + sessionID: created.id, + prompt: new Prompt({ text: "Replay lifecycle" }), + resume: false, + }) + yield* SessionInput.promoteSteers(sourceDb, sourceEvents, created.id, Number.MAX_SAFE_INTEGER) + const serialized = (yield* sourceDb + .select() + .from(EventTable) + .where(eq(EventTable.aggregate_id, created.id)) + .orderBy(asc(EventTable.seq)) + .all() + .pipe(Effect.orDie)).map((event) => ({ + id: event.id, + aggregateID: event.aggregate_id, + seq: event.seq, + type: event.type, + data: event.data, + })) + + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const targetDatabase = Database.layerFromPath(path.join(tmp.path, "target.sqlite")) + const targetEvents = EventV2.layer.pipe(Layer.provide(targetDatabase)) + const targetProjector = SessionProjector.layer.pipe(Layer.provide(targetEvents), Layer.provide(targetDatabase)) + const targetStore = SessionStore.layer.pipe(Layer.provide(targetDatabase)) + + yield* Effect.gen(function* () { + const db = (yield* Database.Service).db + const events = yield* EventV2.Service + const store = yield* SessionStore.Service + yield* db + .insert(ProjectTable) + .values({ id: ProjectV2.ID.global, worktree: location.directory, sandboxes: [] }) + .run() + .pipe(Effect.orDie) + + expect(yield* store.get(created.id)).toBeUndefined() + expect(yield* events.replayAll(serialized.slice(0, 2))).toBe(created.id) + expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({ + id: admitted.id, + sessionID: created.id, + prompt: { text: "Replay lifecycle" }, + delivery: "steer", + admittedSeq: 1, + }) + expect(yield* store.context(created.id)).toEqual([]) + + expect(yield* events.replayAll(serialized.slice(2))).toBe(created.id) + expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({ + id: admitted.id, + sessionID: created.id, + prompt: { text: "Replay lifecycle" }, + delivery: "steer", + admittedSeq: 1, + promotedSeq: 2, + }) + expect(yield* store.context(created.id)).toMatchObject([ + { id: admitted.id, type: "user", text: "Replay lifecycle" }, + ]) + expect( + (yield* db + .select() + .from(EventTable) + .where(eq(EventTable.aggregate_id, created.id)) + .orderBy(asc(EventTable.seq)) + .all() + .pipe(Effect.orDie)).map((event) => [event.seq, event.type]), + ).toEqual([ + [0, EventV2.versionedType(SessionV1.Event.Created.type, 1)], + [1, EventV2.versionedType(SessionEvent.PromptLifecycle.Admitted.type, 1)], + [2, EventV2.versionedType(SessionEvent.PromptLifecycle.Promoted.type, 1)], + ]) + }).pipe(Effect.provide(Layer.fresh(Layer.mergeAll(targetDatabase, targetEvents, targetProjector, targetStore)))) }), ) diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 1493bad841..0cbadaf241 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -67,13 +67,25 @@ describe("SessionProjector", () => { yield* events.publish( SessionEvent.Prompted, - { sessionID, timestamp: created, prompt: new Prompt({ text: "first" }), delivery: "steer" }, - { id: SessionMessage.ID.make("evt_z") }, + { + sessionID, + messageID: SessionMessage.ID.make("msg_first"), + timestamp: created, + prompt: new Prompt({ text: "first" }), + delivery: "steer", + }, + { id: EventV2.ID.make("evt_z") }, ) yield* events.publish( SessionEvent.Prompted, - { sessionID, timestamp: created, prompt: new Prompt({ text: "second" }), delivery: "steer" }, - { id: SessionMessage.ID.make("evt_a") }, + { + sessionID, + messageID: SessionMessage.ID.make("msg_second"), + timestamp: created, + prompt: new Prompt({ text: "second" }), + delivery: "steer", + }, + { id: EventV2.ID.make("evt_a") }, ) const sessions = yield* SessionV2.Service @@ -110,7 +122,7 @@ describe("SessionProjector", () => { ), ) - it.effect("marks an admitted inbox row promoted with the Prompted event sequence", () => + it.effect("marks an admitted lifecycle row promoted with the PromptPromoted event sequence", () => Effect.gen(function* () { const { db } = yield* Database.Service yield* db @@ -131,14 +143,21 @@ describe("SessionProjector", () => { .run() .pipe(Effect.orDie) const events = yield* EventV2.Service - const id = SessionMessage.ID.make("evt_admitted") - yield* SessionInput.admit(db, { id, sessionID, prompt: new Prompt({ text: "promote me" }), delivery: "steer" }) + const id = SessionMessage.ID.make("msg_admitted") + yield* SessionInput.admit(db, events, { + id, + sessionID, + prompt: new Prompt({ text: "promote me" }), + delivery: "steer", + }) - const event = yield* events.publish( - SessionEvent.Prompted, - { sessionID, timestamp: created, prompt: new Prompt({ text: "promote me" }), delivery: "steer" }, - { id }, - ) + const event = yield* events.publish(SessionEvent.PromptLifecycle.Promoted, { + sessionID, + timestamp: created, + messageID: id, + prompt: new Prompt({ text: "promote me" }), + timeCreated: created, + }) expect( yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie), @@ -168,11 +187,27 @@ describe("SessionProjector", () => { .pipe(Effect.orDie) const events = yield* EventV2.Service - yield* events.publish(SessionEvent.AgentSwitched, { sessionID, timestamp: created, agent: "build" }) - yield* events.publish(SessionEvent.ModelSwitched, { sessionID, timestamp: created, model }) - yield* events.publish(SessionEvent.Synthetic, { sessionID, timestamp: created, text: "synthetic context" }) + yield* events.publish(SessionEvent.AgentSwitched, { + sessionID, + messageID: SessionMessage.ID.create(), + timestamp: created, + agent: "build", + }) + yield* events.publish(SessionEvent.ModelSwitched, { + sessionID, + messageID: SessionMessage.ID.create(), + timestamp: created, + model, + }) + yield* events.publish(SessionEvent.Synthetic, { + sessionID, + messageID: SessionMessage.ID.create(), + timestamp: created, + text: "synthetic context", + }) yield* events.publish(SessionEvent.Shell.Started, { sessionID, + messageID: SessionMessage.ID.create(), timestamp: created, callID: "shell-1", command: "pwd", @@ -183,7 +218,12 @@ describe("SessionProjector", () => { callID: "shell-1", output: "/project", }) - yield* events.publish(SessionEvent.Compaction.Started, { sessionID, timestamp: created, reason: "manual" }) + yield* events.publish(SessionEvent.Compaction.Started, { + sessionID, + messageID: SessionMessage.ID.create(), + timestamp: created, + reason: "manual", + }) yield* events.publish(SessionEvent.Compaction.Delta, { sessionID, timestamp: created, text: "partial" }) yield* events.publish(SessionEvent.Compaction.Ended, { sessionID, @@ -228,6 +268,47 @@ describe("SessionProjector", () => { }), ) + it.effect("rejects distinct creator events that reuse one projected message ID", () => + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "test", + directory: "/project", + title: "test", + version: "test", + }) + .run() + .pipe(Effect.orDie) + const events = yield* EventV2.Service + const id = SessionMessage.ID.make("msg_creator_collision") + + yield* events.publish(SessionEvent.Synthetic, { sessionID, messageID: id, timestamp: created, text: "keep me" }) + const exit = yield* events + .publish(SessionEvent.Step.Started, { + sessionID, + assistantMessageID: id, + timestamp: created, + agent: "build", + model, + }) + .pipe(Effect.exit) + + expect(exit._tag).toBe("Failure") + expect( + yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, id)).get().pipe(Effect.orDie), + ).toMatchObject({ type: "synthetic" }) + }), + ) + it.effect("rejects a Prompted event that conflicts with an admitted inbox row", () => Effect.gen(function* () { const { db } = yield* Database.Service @@ -249,24 +330,77 @@ describe("SessionProjector", () => { .run() .pipe(Effect.orDie) const events = yield* EventV2.Service - const id = SessionMessage.ID.make("evt_conflict") - yield* SessionInput.admit(db, { id, sessionID, prompt: new Prompt({ text: "admitted" }), delivery: "steer" }) + const id = SessionMessage.ID.make("msg_conflict") + yield* SessionInput.admit(db, events, { + id, + sessionID, + prompt: new Prompt({ text: "admitted" }), + delivery: "steer", + }) const exit = yield* events - .publish( - SessionEvent.Prompted, - { sessionID, timestamp: created, prompt: new Prompt({ text: "different" }), delivery: "steer" }, - { id }, - ) + .publish(SessionEvent.Prompted, { + sessionID, + messageID: id, + timestamp: created, + prompt: new Prompt({ text: "different" }), + delivery: "steer", + }) .pipe(Effect.exit) - expect(String(exit)).toContain("Prompt projection conflicts with admitted input") + expect(String(exit)).toContain("SessionInput.LifecycleConflict") expect( yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie), ).toMatchObject({ promoted_seq: null }) }), ) + it.effect("rejects an assistant message ID that conflicts with an admitted inbox row", () => + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "test", + directory: "/project", + title: "test", + version: "test", + }) + .run() + .pipe(Effect.orDie) + const events = yield* EventV2.Service + const id = SessionMessage.ID.make("msg_conflict") + yield* SessionInput.admit(db, events, { + id, + sessionID, + prompt: new Prompt({ text: "admitted" }), + delivery: "steer", + }) + + const exit = yield* events + .publish(SessionEvent.Step.Started, { + sessionID, + timestamp: created, + assistantMessageID: id, + agent: "build", + model, + }) + .pipe(Effect.exit) + + expect(String(exit)).toContain("SessionInput.LifecycleConflict") + expect( + yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, id)).get().pipe(Effect.orDie), + ).toBeUndefined() + }), + ) + it.effect("rejects a Prompted delivery mode that conflicts with an admitted inbox row", () => Effect.gen(function* () { const { db } = yield* Database.Service @@ -288,15 +422,15 @@ describe("SessionProjector", () => { .run() .pipe(Effect.orDie) const events = yield* EventV2.Service - const id = SessionMessage.ID.make("evt_delivery_conflict") + const id = SessionMessage.ID.make("msg_delivery_conflict") const prompt = new Prompt({ text: "admitted" }) - yield* SessionInput.admit(db, { id, sessionID, prompt, delivery: "queue" }) + yield* SessionInput.admit(db, events, { id, sessionID, prompt, delivery: "queue" }) const exit = yield* events - .publish(SessionEvent.Prompted, { sessionID, timestamp: created, prompt, delivery: "steer" }, { id }) + .publish(SessionEvent.Prompted, { sessionID, messageID: id, timestamp: created, prompt, delivery: "steer" }) .pipe(Effect.exit) - expect(String(exit)).toContain("Prompt projection conflicts with admitted input") + expect(String(exit)).toContain("SessionInput.LifecycleConflict") expect( yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie), ).toMatchObject({ delivery: "queue", promoted_seq: null }) @@ -306,7 +440,7 @@ describe("SessionProjector", () => { it.effect("does not revive a stale incomplete in-memory assistant projection", () => Effect.gen(function* () { const stale = new SessionMessage.Assistant({ - id: SessionMessage.ID.make("evt_assistant_stale"), + id: SessionMessage.ID.make("msg_assistant_stale"), type: "assistant", agent: "build", model, @@ -314,7 +448,7 @@ describe("SessionProjector", () => { time: { created }, }) const completed = new SessionMessage.Assistant({ - id: SessionMessage.ID.make("evt_assistant_completed"), + id: SessionMessage.ID.make("msg_assistant_completed"), type: "assistant", agent: "build", model, @@ -351,8 +485,8 @@ describe("SessionProjector", () => { yield* db .insert(SessionMessageTable) .values([ - assistantRow(SessionMessage.ID.make("evt_assistant_1"), 0), - assistantRow(SessionMessage.ID.make("evt_assistant_2"), 1), + assistantRow(SessionMessage.ID.make("msg_assistant_1"), 0), + assistantRow(SessionMessage.ID.make("msg_assistant_2"), 1), ]) .run() .pipe(Effect.orDie) @@ -361,7 +495,7 @@ describe("SessionProjector", () => { yield* service.publish(SessionEvent.Step.Ended, { sessionID, timestamp: DateTime.makeUnsafe(1), - assistantMessageID: SessionMessage.ID.make("evt_assistant_2"), + assistantMessageID: SessionMessage.ID.make("msg_assistant_2"), finish: "stop", cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, @@ -409,8 +543,8 @@ describe("SessionProjector", () => { yield* db .insert(SessionMessageTable) .values([ - assistantRow(SessionMessage.ID.make("evt_assistant_stale"), 0), - assistantRow(SessionMessage.ID.make("evt_assistant_completed"), 1, { + assistantRow(SessionMessage.ID.make("msg_assistant_stale"), 0), + assistantRow(SessionMessage.ID.make("msg_assistant_completed"), 1, { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2), }), @@ -421,6 +555,7 @@ describe("SessionProjector", () => { const service = yield* EventV2.Service yield* service.publish(SessionEvent.Text.Started, { sessionID, + assistantMessageID: SessionMessage.ID.make("msg_assistant_completed"), timestamp: DateTime.makeUnsafe(3), textID: "text-stale", }) @@ -437,15 +572,15 @@ describe("SessionProjector", () => { ) expect(messages).toEqual([ new SessionMessage.Assistant({ - id: SessionMessage.ID.make("evt_assistant_completed"), + id: SessionMessage.ID.make("msg_assistant_completed"), type: "assistant", agent: "build", model, - content: [], + content: [new SessionMessage.AssistantText({ type: "text", id: "text-stale", text: "" })], time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) }, }), new SessionMessage.Assistant({ - id: SessionMessage.ID.make("evt_assistant_stale"), + id: SessionMessage.ID.make("msg_assistant_stale"), type: "assistant", agent: "build", model, diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 431261749e..d73170ef6b 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -1,7 +1,9 @@ import { describe, expect } from "bun:test" import { DateTime, Effect, Fiber, Layer, Stream } from "effect" +import { eq } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" +import { EventTable } from "@opencode-ai/core/event/sql" import { SessionEvent } from "@opencode-ai/core/session/event" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" @@ -12,7 +14,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionInput } from "@opencode-ai/core/session/input" -import { SessionInputTable, SessionTable } from "@opencode-ai/core/session/sql" +import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" import { testEffect } from "./lib/effect" @@ -80,6 +82,18 @@ const admittedCount = Database.Service.use(({ db }) => Effect.map((rows) => rows.length), ), ) +const eventCount = (type: string) => + Database.Service.use(({ db }) => + db + .select() + .from(EventTable) + .where(eq(EventTable.type, type)) + .all() + .pipe( + Effect.orDie, + Effect.map((rows) => rows.length), + ), + ) describe("SessionV2.prompt", () => { it.effect("delegates execution continuation through SessionExecution", () => @@ -105,8 +119,7 @@ describe("SessionV2.prompt", () => { resume: false, }) - expect(message.type).toBe("user") - expect(message.text).toBe("Fix the failing tests") + expect(message.prompt.text).toBe("Fix the failing tests") expect(yield* session.messages({ sessionID })).toEqual([]) expect(yield* admitted(message.id)).toMatchObject({ id: message.id, @@ -123,25 +136,25 @@ describe("SessionV2.prompt", () => { const session = yield* SessionV2.Service const events = yield* EventV2.Service const { db } = yield* Database.Service - const fiber = yield* session.events({ sessionID }).pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped) + const fiber = yield* session.events({ sessionID }).pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) - yield* SessionInput.promoteSteers(db, events, sessionID) + yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER) const streamed = Array.from(yield* Fiber.join(fiber)) - expect( - streamed.map((event) => [event.cursor, event.event.type, (event.event.data as { prompt: Prompt }).prompt.text]), - ).toEqual([ - [EventV2.Cursor.make(0), "session.next.prompted", "First"], - [EventV2.Cursor.make(1), "session.next.prompted", "Second"], + expect(streamed.map((event) => [event.cursor, event.event.type])).toEqual([ + [EventV2.Cursor.make(0), "session.next.prompt.admitted"], + [EventV2.Cursor.make(1), "session.next.prompt.admitted"], + [EventV2.Cursor.make(2), "session.next.prompt.promoted"], + [EventV2.Cursor.make(3), "session.next.prompt.promoted"], ]) expect( Array.from( yield* session.events({ sessionID, after: streamed[0]!.cursor }).pipe(Stream.take(1), Stream.runCollect), - ).map((event) => [event.cursor, (event.event.data as { prompt: Prompt }).prompt.text]), - ).toEqual([[EventV2.Cursor.make(1), "Second"]]) + ).map((event) => [event.cursor, event.event.type]), + ).toEqual([[EventV2.Cursor.make(1), "session.next.prompt.admitted"]]) }), ) @@ -271,17 +284,6 @@ describe("SessionV2.prompt", () => { }), ) - it.effect("does not match pending inputs when no delivery modes are eligible", () => - Effect.gen(function* () { - yield* setup - const { db } = yield* Database.Service - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Wait" }), resume: false }) - - expect(yield* SessionInput.hasPending(db, sessionID, [])).toBe(false) - }), - ) - it.effect("returns one recorded message to concurrent exact retries", () => Effect.gen(function* () { yield* setup @@ -298,43 +300,127 @@ describe("SessionV2.prompt", () => { expect(messages[1]).toEqual(messages[0]) expect(yield* session.messages({ sessionID })).toEqual([]) expect(yield* admittedCount).toBe(1) + expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptLifecycle.Admitted.type, 1))).toBe(1) }), ) - it.effect("reconciles an existing projected prompt into a promoted inbox record", () => + it.effect("promotes one message once under concurrent promotion attempts", () => + Effect.gen(function* () { + yield* setup + const { db } = yield* Database.Service + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Promote once" }), resume: false }) + + yield* Effect.all( + [ + SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER), + SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER), + ], + { concurrency: "unbounded" }, + ) + + expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptLifecycle.Promoted.type, 1))).toBe(1) + expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 }) + expect(yield* session.messages({ sessionID })).toMatchObject([ + { id: messageID, type: "user", text: "Promote once" }, + ]) + }), + ) + + it.effect("promotes steers only through the captured aggregate cutoff", () => + Effect.gen(function* () { + yield* setup + const { db } = yield* Database.Service + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + const first = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Before cutoff" }), resume: false }) + const cutoff = yield* SessionInput.latestSeq(db, sessionID) + const second = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "After cutoff" }), resume: false }) + + yield* SessionInput.promoteSteers(db, events, sessionID, cutoff) + + expect(yield* admitted(first.id)).toHaveProperty("promotedSeq") + expect(yield* admitted(second.id)).not.toHaveProperty("promotedSeq") + }), + ) + + it.effect("reprojects one pending lifecycle without scheduling execution", () => + Effect.gen(function* () { + yield* setup + const { db } = yield* Database.Service + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + wakeCalls.length = 0 + yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Replay pending" }), resume: false }) + const recorded = yield* db + .select() + .from(EventTable) + .where(eq(EventTable.aggregate_id, sessionID)) + .all() + .pipe(Effect.orDie) + + yield* events.remove(sessionID) + yield* db.delete(SessionInputTable).where(eq(SessionInputTable.session_id, sessionID)).run().pipe(Effect.orDie) + yield* db + .delete(SessionMessageTable) + .where(eq(SessionMessageTable.session_id, sessionID)) + .run() + .pipe(Effect.orDie) + yield* events.replayAll( + recorded.map((event) => ({ + id: event.id, + aggregateID: event.aggregate_id, + seq: event.seq, + type: event.type, + data: event.data, + })), + ) + + expect(yield* admitted(messageID)).toMatchObject({ id: messageID, prompt: { text: "Replay pending" } }) + expect(yield* session.messages({ sessionID })).toEqual([]) + expect(wakeCalls).toEqual([]) + }), + ) + + it.effect("returns an exact retry of a legacy projected prompt", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service const prompt = new Prompt({ text: "Historical prompt" }) - yield* events.publish( - SessionEvent.Prompted, - { sessionID, timestamp: yield* DateTime.now, prompt, delivery: "steer" }, - { id: messageID }, - ) + yield* events.publish(SessionEvent.Prompted, { + sessionID, + messageID, + timestamp: yield* DateTime.now, + prompt, + delivery: "steer", + }) const retried = yield* session.prompt({ id: messageID, sessionID, prompt, resume: false }) - expect(retried).toMatchObject({ id: messageID, text: "Historical prompt" }) + expect(retried).toMatchObject({ id: messageID, prompt: { text: "Historical prompt" } }) expect(yield* admitted(messageID)).toHaveProperty("promotedSeq") }), ) - it.effect("reconciles an existing projected queued prompt with its delivery mode", () => + it.effect("returns an exact retry of a legacy projected queued prompt", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service const prompt = new Prompt({ text: "Historical queued prompt" }) - yield* events.publish( - SessionEvent.Prompted, - { sessionID, timestamp: yield* DateTime.now, prompt, delivery: "queue" }, - { id: messageID }, - ) + yield* events.publish(SessionEvent.Prompted, { + sessionID, + messageID, + timestamp: yield* DateTime.now, + prompt, + delivery: "queue", + }) const retried = yield* session.prompt({ id: messageID, sessionID, prompt, delivery: "queue", resume: false }) - expect(retried).toMatchObject({ id: messageID, text: "Historical queued prompt" }) + expect(retried).toMatchObject({ id: messageID, prompt: { text: "Historical queued prompt" } }) expect(yield* admitted(messageID)).toMatchObject({ delivery: "queue" }) }), ) @@ -344,11 +430,12 @@ describe("SessionV2.prompt", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* events.publish( - SessionEvent.Synthetic, - { sessionID, timestamp: yield* DateTime.now, text: "Collision" }, - { id: messageID }, - ) + yield* events.publish(SessionEvent.Synthetic, { + sessionID, + messageID, + timestamp: yield* DateTime.now, + text: "Collision", + }) const failure = yield* session .prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Collision" }), resume: false }) @@ -369,20 +456,21 @@ describe("SessionV2.prompt", () => { yield* session.prompt({ id: messageID, sessionID, prompt, resume: false }) const failure = yield* events - .publish( - SessionEvent.Synthetic, - { sessionID, timestamp: yield* DateTime.now, text: "Conflicting synthetic" }, - { id: messageID }, - ) + .publish(SessionEvent.Synthetic, { + sessionID, + messageID, + timestamp: yield* DateTime.now, + text: "Conflicting synthetic", + }) .pipe(Effect.catchDefect(Effect.succeed)) - expect(failure).toBe("Durable event conflicts with admitted prompt input") + expect(String(failure)).toContain("SessionInput.LifecycleConflict") expect(yield* admitted(messageID)).not.toHaveProperty("promotedSeq") expect(yield* session.messages({ sessionID })).toEqual([]) - yield* SessionInput.promoteSteers(db, events, sessionID) + yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER) - expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 0 }) + expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 }) expect(yield* session.messages({ sessionID })).toMatchObject([ { id: messageID, type: "user", text: "Reserved prompt" }, ]) diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index de6cc025dd..775e744d84 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -1,7 +1,6 @@ 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 { EventV2 } from "@opencode-ai/core/event" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { SessionMessage } from "@opencode-ai/core/session/message" @@ -12,7 +11,7 @@ import { ToolOutput } from "@opencode-ai/core/tool-output" import { DateTime } from "effect" const created = DateTime.makeUnsafe(0) -const id = (value: string) => EventV2.ID.make(`evt_${value}`) +const id = (value: string) => SessionMessage.ID.make(`msg_${value}`) const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route }) describe("toLLMMessages", () => { diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 10f8938fca..4268859bd1 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -14,7 +14,6 @@ import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" -import { SessionRunner } from "@opencode-ai/core/session/runner" 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" @@ -131,7 +130,7 @@ describe("SessionRunnerLLM recorded", () => { const messages = yield* session.context(sessionID) expect(messages).toHaveLength(2) - expect(messages[0]).toEqual(prompt) + expect(messages[0]).toMatchObject({ id: prompt.id, type: "user", text: "Say hello in one short sentence." }) expect(messages[1]).toMatchObject({ type: "assistant", agent: "build", finish: "stop" }) expect(messages[1]?.type === "assistant" ? messages[1].content : []).toMatchObject([ { type: "text", text: "Hello!" }, @@ -144,7 +143,8 @@ describe("SessionRunnerLLM recorded", () => { .orderBy(EventTable.seq) .all()).map((event) => event.type), ).toEqual([ - "session.next.prompted.1", + "session.next.prompt.admitted.1", + "session.next.prompt.promoted.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 640cf3cedc..bc476aee14 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -21,6 +21,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionInput } from "@opencode-ai/core/session/input" +import { SessionMessage } from "@opencode-ai/core/session/message" import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" @@ -29,7 +30,7 @@ import { SessionRunner } from "@opencode-ai/core/session/runner" 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 { SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" +import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -240,6 +241,7 @@ const replaySessionProjection = (id: SessionV2.ID) => .pipe(Effect.orDie) yield* events.remove(id) + yield* db.delete(SessionInputTable).where(eq(SessionInputTable.session_id, id)).run().pipe(Effect.orDie) yield* db.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, id)).run().pipe(Effect.orDie) yield* events.replayAll( recorded.map((event) => ({ @@ -425,7 +427,9 @@ describe("SessionRunnerLLM", () => { const message = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run automatically" }) }) expect(requests).toHaveLength(1) - expect(yield* session.messages({ sessionID })).toEqual([message]) + expect(yield* session.messages({ sessionID })).toMatchObject([ + { id: message.id, type: "user", text: "Run automatically" }, + ]) }), ) @@ -1217,9 +1221,11 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service const events = yield* EventV2.Service yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Recover interrupted tool" }), resume: false }) - yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID) - const assistant = yield* events.publish(SessionEvent.Step.Started, { + yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER) + const assistantMessageID = SessionMessage.ID.create() + yield* events.publish(SessionEvent.Step.Started, { sessionID, + assistantMessageID, timestamp: yield* DateTime.now, agent: "build", model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") }, @@ -1227,21 +1233,21 @@ describe("SessionRunnerLLM", () => { yield* events.publish(SessionEvent.Tool.Input.Started, { sessionID, timestamp: yield* DateTime.now, - assistantMessageID: assistant.id, + assistantMessageID, callID: "call-interrupted", name: "echo", }) yield* events.publish(SessionEvent.Tool.Input.Ended, { sessionID, timestamp: yield* DateTime.now, - assistantMessageID: assistant.id, + assistantMessageID, callID: "call-interrupted", text: '{"text":"stale"}', }) yield* events.publish(SessionEvent.Tool.Called, { sessionID, timestamp: yield* DateTime.now, - assistantMessageID: assistant.id, + assistantMessageID, callID: "call-interrupted", tool: "echo", input: { text: "stale" }, @@ -1279,9 +1285,11 @@ describe("SessionRunnerLLM", () => { prompt: new Prompt({ text: "Recover interrupted hosted tool" }), resume: false, }) - yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID) - const assistant = yield* events.publish(SessionEvent.Step.Started, { + yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER) + const assistantMessageID = SessionMessage.ID.create() + yield* events.publish(SessionEvent.Step.Started, { sessionID, + assistantMessageID, timestamp: yield* DateTime.now, agent: "build", model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") }, @@ -1289,21 +1297,21 @@ describe("SessionRunnerLLM", () => { yield* events.publish(SessionEvent.Tool.Input.Started, { sessionID, timestamp: yield* DateTime.now, - assistantMessageID: assistant.id, + assistantMessageID, callID: "call-hosted-interrupted", name: "web_search", }) yield* events.publish(SessionEvent.Tool.Input.Ended, { sessionID, timestamp: yield* DateTime.now, - assistantMessageID: assistant.id, + assistantMessageID, callID: "call-hosted-interrupted", text: '{"query":"stale"}', }) yield* events.publish(SessionEvent.Tool.Called, { sessionID, timestamp: yield* DateTime.now, - assistantMessageID: assistant.id, + assistantMessageID, callID: "call-hosted-interrupted", tool: "web_search", input: { query: "stale" }, @@ -1337,9 +1345,11 @@ describe("SessionRunnerLLM", () => { prompt: new Prompt({ text: "Recover interrupted tool input" }), resume: false, }) - yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID) - const assistant = yield* events.publish(SessionEvent.Step.Started, { + yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER) + const assistantMessageID = SessionMessage.ID.create() + yield* events.publish(SessionEvent.Step.Started, { sessionID, + assistantMessageID, timestamp: yield* DateTime.now, agent: "build", model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") }, @@ -1347,7 +1357,7 @@ describe("SessionRunnerLLM", () => { yield* events.publish(SessionEvent.Tool.Input.Started, { sessionID, timestamp: yield* DateTime.now, - assistantMessageID: assistant.id, + assistantMessageID, callID: "call-pending-interrupted", name: "echo", }) @@ -1414,7 +1424,7 @@ describe("SessionRunnerLLM", () => { const events = yield* EventV2.Service const defect = new Error("fail after prompt promotion") let fail = true - yield* events.project(SessionEvent.Prompted, () => (fail ? Effect.die(defect) : Effect.void)) + yield* events.project(SessionEvent.PromptLifecycle.Promoted, () => (fail ? Effect.die(defect) : Effect.void)) yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Recover promoted input" }), resume: false }) expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect) @@ -1433,6 +1443,30 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("does not strand a committed promotion when a post-commit listener defects", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + yield* events.listen((event) => + event.type === SessionEvent.PromptLifecycle.Promoted.type + ? Effect.die("fail after prompt promotion commits") + : Effect.void, + ) + yield* session.prompt({ + sessionID, + prompt: new Prompt({ text: "Run committed promotion" }), + resume: false, + }) + + requests.length = 0 + yield* session.resume(sessionID) + + expect(requests).toHaveLength(1) + expect(userTexts(requests[0]!)).toEqual(["Run committed promotion"]) + }), + ) + it.effect("runs different sessions concurrently", () => Effect.gen(function* () { yield* setup diff --git a/packages/core/test/session-tool-progress.test.ts b/packages/core/test/session-tool-progress.test.ts index f69743e701..9ed10b69e5 100644 --- a/packages/core/test/session-tool-progress.test.ts +++ b/packages/core/test/session-tool-progress.test.ts @@ -50,12 +50,14 @@ describe("Tool.Progress", () => { }) .run() .pipe(Effect.orDie) - const assistantMessageID = (yield* service.publish(SessionEvent.Step.Started, { + const assistantMessageID = SessionMessage.ID.create() + yield* service.publish(SessionEvent.Step.Started, { sessionID, + assistantMessageID, timestamp, agent: "build", model, - })).id + }) const readAssistant = Effect.gen(function* () { const row = yield* db .select() 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 0b0690d9d2..be13d2007d 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx @@ -1,5 +1,6 @@ import { useEvent } from "@tui/context/event" import type { + Event, SessionMessage, SessionMessageAssistant, SessionMessageAssistantReasoning, @@ -54,6 +55,11 @@ function latestReasoning(assistant: SessionMessageAssistant | undefined, reasoni ) } +function prepend(messages: SessionMessage[], message: SessionMessage) { + if (messages.some((item) => item.id === message.id)) return + messages.unshift(message) +} + export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext({ name: "SyncV2", init: () => { @@ -67,6 +73,18 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( const event = useEvent() const sdk = useSDK() + const applied = new Set() + const buffering = new Map() + const syncing = new Map>() + + function duplicate(id: string) { + if (applied.has(id)) return true + applied.add(id) + if (applied.size <= 1000) return false + const oldest = applied.values().next() + if (!oldest.done) applied.delete(oldest.value) + return false + } function update(sessionID: string, fn: (messages: SessionMessage[]) => void) { setStore( @@ -77,12 +95,41 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( ) } - event.subscribe((event) => { + async function hydrate(sessionID: string) { + const pending: Event[] = [] + const before = JSON.parse(JSON.stringify(store.messages[sessionID] ?? [])) as SessionMessage[] + buffering.set(sessionID, pending) + try { + const response = await sdk.client.v2.session.messages({ sessionID }) + const messages = response.data?.data ?? [] + const snapshotIDs = new Set(messages.map((message) => message.id)) + setStore( + "messages", + sessionID, + reconcile([...messages, ...before.filter((message) => !snapshotIDs.has(message.id))]), + ) + buffering.delete(sessionID) + for (const event of pending) apply(event) + } catch (error) { + buffering.delete(sessionID) + throw error + } + } + + function sync(sessionID: string) { + const existing = syncing.get(sessionID) + if (existing) return existing + const result = hydrate(sessionID).finally(() => syncing.delete(sessionID)) + syncing.set(sessionID, result) + return result + } + + function apply(event: Event) { switch (event.type) { case "session.next.agent.switched": update(event.properties.sessionID, (draft) => { - draft.unshift({ - id: event.id, + prepend(draft, { + id: event.properties.messageID, type: "agent-switched", agent: event.properties.agent, time: { created: event.properties.timestamp }, @@ -91,8 +138,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( break case "session.next.model.switched": update(event.properties.sessionID, (draft) => { - draft.unshift({ - id: event.id, + prepend(draft, { + id: event.properties.messageID, type: "model-switched", model: event.properties.model, time: { created: event.properties.timestamp }, @@ -101,8 +148,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( break case "session.next.prompted": { update(event.properties.sessionID, (draft) => { - draft.unshift({ - id: event.id, + prepend(draft, { + id: event.properties.messageID, type: "user", text: event.properties.prompt.text, files: event.properties.prompt.files, @@ -113,10 +160,25 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( }) break } + case "session.next.prompt.admitted": + break + case "session.next.prompt.promoted": + update(event.properties.sessionID, (draft) => { + prepend(draft, { + id: event.properties.messageID, + type: "user", + text: event.properties.prompt.text, + files: event.properties.prompt.files, + agents: event.properties.prompt.agents, + references: event.properties.prompt.references, + time: { created: event.properties.timeCreated }, + }) + }) + break case "session.next.synthetic": update(event.properties.sessionID, (draft) => { - draft.unshift({ - id: event.id, + prepend(draft, { + id: event.properties.messageID, type: "synthetic", sessionID: event.properties.sessionID, text: event.properties.text, @@ -126,8 +188,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( break case "session.next.shell.started": update(event.properties.sessionID, (draft) => { - draft.unshift({ - id: event.id, + prepend(draft, { + id: event.properties.messageID, type: "shell", callID: event.properties.callID, command: event.properties.command, @@ -146,10 +208,11 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( break case "session.next.step.started": update(event.properties.sessionID, (draft) => { + if (draft.some((message) => message.id === event.properties.assistantMessageID)) return const currentAssistant = activeAssistant(draft) if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp - draft.unshift({ - id: event.id, + prepend(draft, { + id: event.properties.assistantMessageID, type: "assistant", agent: event.properties.agent, model: event.properties.model, @@ -182,18 +245,28 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( break case "session.next.text.started": update(event.properties.sessionID, (draft) => { - activeAssistant(draft)?.content.push({ type: "text", id: event.properties.textID, text: "" }) + ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({ + type: "text", + id: event.properties.textID, + text: "", + }) }) break case "session.next.text.delta": update(event.properties.sessionID, (draft) => { - const match = latestText(activeAssistant(draft), event.properties.textID) + const match = latestText( + ownedAssistant(draft, event.properties.assistantMessageID), + event.properties.textID, + ) if (match) match.text += event.properties.delta }) break case "session.next.text.ended": update(event.properties.sessionID, (draft) => { - const match = latestText(activeAssistant(draft), event.properties.textID) + const match = latestText( + ownedAssistant(draft, event.properties.assistantMessageID), + event.properties.textID, + ) if (match) match.text = event.properties.text }) break @@ -263,7 +336,11 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( content: [...event.properties.content], result: event.properties.result, } - match.provider = event.properties.provider + match.provider = { + executed: event.properties.provider.executed || match.provider?.executed === true, + metadata: match.provider?.metadata, + resultMetadata: event.properties.provider.metadata, + } match.time.completed = event.properties.timestamp }) break @@ -282,13 +359,17 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( content: match.state.status === "running" ? match.state.content : [], result: event.properties.result, } - match.provider = event.properties.provider + match.provider = { + executed: event.properties.provider.executed || match.provider?.executed === true, + metadata: match.provider?.metadata, + resultMetadata: event.properties.provider.metadata, + } match.time.completed = event.properties.timestamp }) break case "session.next.reasoning.started": update(event.properties.sessionID, (draft) => { - activeAssistant(draft)?.content.push({ + ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({ type: "reasoning", id: event.properties.reasoningID, text: "", @@ -298,13 +379,19 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( break case "session.next.reasoning.delta": update(event.properties.sessionID, (draft) => { - const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID) + const match = latestReasoning( + ownedAssistant(draft, event.properties.assistantMessageID), + event.properties.reasoningID, + ) if (match) match.text += event.properties.delta }) break case "session.next.reasoning.ended": update(event.properties.sessionID, (draft) => { - const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID) + const match = latestReasoning( + ownedAssistant(draft, event.properties.assistantMessageID), + event.properties.reasoningID, + ) if (match) { match.text = event.properties.text if (event.properties.providerMetadata !== undefined) @@ -316,8 +403,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( break case "session.next.compaction.started": update(event.properties.sessionID, (draft) => { - draft.unshift({ - id: event.id, + prepend(draft, { + id: event.properties.messageID, type: "compaction", reason: event.properties.reason, summary: "", @@ -340,16 +427,20 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( }) break } + } + + event.subscribe((event) => { + if (duplicate(event.id)) return + if ("sessionID" in event.properties && typeof event.properties.sessionID === "string") + buffering.get(event.properties.sessionID)?.push(event) + apply(event) }) const result = { data: store, session: { message: { - async sync(sessionID: string) { - const response = await sdk.client.v2.session.messages({ sessionID }) - setStore("messages", sessionID, reconcile(response.data?.data ?? [])) - }, + sync, fromSession(sessionID: string) { const messages = store.messages[sessionID] if (!messages) return [] diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index b4ce2e011f..afc03db30d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -32,7 +32,7 @@ const EventSchema = Schema.Union([ .values() .map((definition) => Schema.Struct({ - id: Schema.String, + id: EventV2.ID, type: Schema.Literal(definition.type), properties: definition.data, }).annotate({ identifier: `Event.${definition.type}` }), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts index cd84990524..87556a1ad6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts @@ -20,10 +20,10 @@ const SyncEventSchemas = EventV2.registry return [ Schema.Struct({ type: Schema.Literal("sync"), - id: Schema.String, + id: EventV2.ID, syncEvent: Schema.Struct({ type: Schema.Literal(EventV2.versionedType(definition.type, definition.sync.version)), - id: Schema.String, + id: EventV2.ID, seq: Schema.Finite, aggregateID: Schema.String, data: definition.data, @@ -41,7 +41,7 @@ const GlobalEventSchema = Schema.Struct({ ...EventV2.registry .values() .map((definition) => - Schema.Struct({ id: Schema.String, type: Schema.Literal(definition.type), properties: definition.data }), + Schema.Struct({ id: EventV2.ID, type: Schema.Literal(definition.type), properties: definition.data }), ) .toArray(), InstanceDisposed, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts index 38a93240eb..ca9b1cdaf0 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts @@ -1,4 +1,5 @@ import { NonNegativeInt } from "@opencode-ai/core/schema" +import { EventV2 } from "@opencode-ai/core/event" import { SessionID } from "@/session/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" @@ -9,7 +10,7 @@ import { described } from "./metadata" const root = "/sync" export const ReplayEvent = Schema.Struct({ - id: Schema.String, + id: EventV2.ID, aggregateID: Schema.String, seq: NonNegativeInt, type: Schema.String, @@ -27,7 +28,7 @@ export const SessionPayload = Schema.Struct({ }) export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt) export const HistoryEvent = Schema.Struct({ - id: Schema.String, + id: EventV2.ID, aggregate_id: Schema.String, seq: NonNegativeInt, type: Schema.String, diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts index 32b02b4c74..5314e9d297 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts @@ -36,7 +36,7 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl const replay = Effect.fn("SyncHttpApi.replay")(function* (ctx: { payload: typeof ReplayPayload.Type }) { const payload: EventV2.SerializedEvent[] = ctx.payload.events.map((event) => ({ - id: EventV2.ID.make(event.id), + id: event.id, aggregateID: event.aggregateID, seq: event.seq, type: event.type, diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index f0d7e0dfec..b165aa813f 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -12,7 +12,7 @@ import { Plugin } from "@/plugin" import { Config } from "@/config/config" import { NotFoundError } from "@/storage/storage" -import { Effect, Layer, Context, Schema } from "effect" +import { Effect, Layer, Context } from "effect" import * as DateTime from "effect/DateTime" import { InstanceState } from "@/effect/instance-state" import { isOverflow as overflow, usable } from "./overflow" @@ -20,6 +20,7 @@ import { serviceUse } from "@opencode-ai/core/effect/service-use" import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2Bridge } from "@/event-v2-bridge" import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionMessage } from "@opencode-ai/core/session/message" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { EventV2 } from "@opencode-ai/core/event" @@ -609,6 +610,7 @@ export const layer = Layer.effect( if (flags.experimentalEventSystem) { yield* events.publish(SessionEvent.Compaction.Started, { sessionID: input.sessionID, + messageID: SessionMessage.ID.create(), timestamp: DateTime.makeUnsafe(Date.now()), reason: input.auto ? "auto" : "manual", }) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index e0027b989b..907134df01 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -25,13 +25,13 @@ import { isRecord } from "@/util/record" import { EventV2Bridge } from "@/event-v2-bridge" import { Database } from "@opencode-ai/core/database/database" import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionMessage } from "@opencode-ai/core/session/message" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import * as DateTime from "effect/DateTime" import { RuntimeFlags } from "@/effect/runtime-flags" import { toolFileSourceFromUri, Usage, type LLMEvent } from "@opencode-ai/llm" import { ToolOutput } from "@opencode-ai/core/tool-output" -import type { EventV2 } from "@opencode-ai/core/event" const DOOM_LOOP_THRESHOLD = 3 const log = Log.create({ service: "session.processor" }) @@ -67,7 +67,7 @@ export interface Interface { } type ToolCall = { - assistantMessageID?: EventV2.ID + assistantMessageID?: SessionMessage.ID partID: SessionV1.ToolPart["id"] messageID: SessionV1.ToolPart["messageID"] sessionID: SessionV1.ToolPart["sessionID"] @@ -85,7 +85,7 @@ interface ProcessorContext extends Input { currentText: SessionV1.TextPart | undefined currentTextID: string | undefined reasoningMap: Record - v2AssistantMessageID: EventV2.ID | undefined + v2AssistantMessageID: SessionMessage.ID | undefined } type StreamEvent = LLMEvent @@ -129,6 +129,7 @@ export const layer = Layer.effect( reasoningMap: {}, v2AssistantMessageID: undefined, } + const mirrorAssistant = flags.experimentalEventSystem && !input.assistantMessage.summary let aborted = false const slog = log.clone().tag("session.id", input.sessionID).tag("messageID", input.assistantMessage.id) @@ -146,8 +147,10 @@ export const layer = Layer.effect( const ensureV2AssistantMessage = Effect.fn("SessionProcessor.ensureV2AssistantMessage")(function* () { if (ctx.v2AssistantMessageID) return ctx.v2AssistantMessageID - ctx.v2AssistantMessageID = (yield* events.publish(SessionEvent.Step.Started, { + ctx.v2AssistantMessageID = SessionMessage.ID.create() + yield* events.publish(SessionEvent.Step.Started, { sessionID: ctx.sessionID, + assistantMessageID: ctx.v2AssistantMessageID, agent: input.assistantMessage.agent, model: { id: ModelV2.ID.make(ctx.model.id), @@ -156,7 +159,7 @@ export const layer = Layer.effect( }, snapshot: ctx.snapshot, timestamp: DateTime.makeUnsafe(Date.now()), - })).id + }) return ctx.v2AssistantMessageID }) @@ -249,9 +252,10 @@ export const layer = Layer.effect( const finishReasoning = Effect.fn("SessionProcessor.finishReasoning")(function* (reasoningID: string) { if (!(reasoningID in ctx.reasoningMap)) return // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { + if (mirrorAssistant) { yield* events.publish(SessionEvent.Reasoning.Ended, { sessionID: ctx.sessionID, + assistantMessageID: yield* currentV2AssistantMessage(), reasoningID, text: ctx.reasoningMap[reasoningID].text, providerMetadata: ctx.reasoningMap[reasoningID].metadata, @@ -266,23 +270,29 @@ export const layer = Layer.effect( }) const flushV2Fragments = Effect.fn("SessionProcessor.flushV2Fragments")(function* () { - if (!flags.experimentalEventSystem) return + if (!mirrorAssistant) return if (!ctx.assistantMessage.summary && ctx.currentText && ctx.currentTextID) { yield* events.publish(SessionEvent.Text.Ended, { sessionID: ctx.sessionID, + assistantMessageID: yield* currentV2AssistantMessage(), textID: ctx.currentTextID, text: ctx.currentText.text, timestamp: DateTime.makeUnsafe(Date.now()), }) } yield* Effect.forEach(Object.entries(ctx.reasoningMap), ([reasoningID, part]) => - events.publish(SessionEvent.Reasoning.Ended, { - sessionID: ctx.sessionID, - reasoningID, - text: part.text, - providerMetadata: part.metadata, - timestamp: DateTime.makeUnsafe(Date.now()), - }), + currentV2AssistantMessage().pipe( + Effect.flatMap((assistantMessageID) => + events.publish(SessionEvent.Reasoning.Ended, { + sessionID: ctx.sessionID, + assistantMessageID, + reasoningID, + text: part.text, + providerMetadata: part.metadata, + timestamp: DateTime.makeUnsafe(Date.now()), + }), + ), + ), ) }) @@ -307,7 +317,7 @@ export const layer = Layer.effect( return { call: ctx.toolcalls[input.id], part } } // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - const assistantMessageID = flags.experimentalEventSystem ? yield* ensureV2AssistantMessage() : undefined + const assistantMessageID = mirrorAssistant ? yield* ensureV2AssistantMessage() : undefined if (assistantMessageID) { yield* events.publish(SessionEvent.Tool.Input.Started, { sessionID: ctx.sessionID, @@ -367,9 +377,10 @@ export const layer = Layer.effect( case "reasoning-start": if (value.id in ctx.reasoningMap) return // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { + if (mirrorAssistant) { yield* events.publish(SessionEvent.Reasoning.Started, { sessionID: ctx.sessionID, + assistantMessageID: yield* ensureV2AssistantMessage(), reasoningID: value.id, providerMetadata: value.providerMetadata, timestamp: DateTime.makeUnsafe(Date.now()), @@ -392,9 +403,10 @@ export const layer = Layer.effect( if (!(value.id in ctx.reasoningMap)) return ctx.reasoningMap[value.id].text += value.text if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata - if (flags.experimentalEventSystem) { + if (mirrorAssistant) { yield* events.publish(SessionEvent.Reasoning.Delta, { sessionID: ctx.sessionID, + assistantMessageID: yield* currentV2AssistantMessage(), reasoningID: value.id, delta: value.text, timestamp: DateTime.makeUnsafe(Date.now()), @@ -426,9 +438,7 @@ export const layer = Layer.effect( case "tool-input-delta": { const toolCall = yield* ensureToolCall(value) - const assistantMessageID = flags.experimentalEventSystem - ? yield* requireV2AssistantMessage(toolCall.call) - : undefined + const assistantMessageID = mirrorAssistant ? yield* requireV2AssistantMessage(toolCall.call) : undefined if (assistantMessageID) { yield* events.publish(SessionEvent.Tool.Input.Delta, { sessionID: ctx.sessionID, @@ -445,7 +455,7 @@ export const layer = Layer.effect( case "tool-input-end": { const toolCall = yield* ensureToolCall(value) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { + if (mirrorAssistant) { const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call) yield* events.publish(SessionEvent.Tool.Input.Ended, { sessionID: ctx.sessionID, @@ -467,7 +477,7 @@ export const layer = Layer.effect( const input = isRecord(value.input) ? value.input : { value: value.input } if (!toolCall.call.inputEnded) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { + if (mirrorAssistant) { const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call) yield* events.publish(SessionEvent.Tool.Input.Ended, { sessionID: ctx.sessionID, @@ -479,7 +489,7 @@ export const layer = Layer.effect( } } // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { + if (mirrorAssistant) { const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call) yield* events.publish(SessionEvent.Tool.Called, { sessionID: ctx.sessionID, @@ -545,7 +555,7 @@ export const layer = Layer.effect( if (!toolCall && value.result.type === "error") return if (value.result.type === "error") { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { + if (mirrorAssistant) { const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call) yield* events.publish(SessionEvent.Tool.Failed, { sessionID: ctx.sessionID, @@ -586,7 +596,7 @@ export const layer = Layer.effect( attachments: attachments.length ? attachments : undefined, } // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { + if (mirrorAssistant) { const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call) const content = [ ToolOutput.text({ type: "text", text: output.output }), @@ -642,7 +652,7 @@ export const layer = Layer.effect( case "tool-error": { const toolCall = yield* readToolCall(value.id) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { + if (mirrorAssistant) { const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call) yield* events.publish(SessionEvent.Tool.Failed, { sessionID: ctx.sessionID, @@ -670,7 +680,7 @@ export const layer = Layer.effect( if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track() if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { + if (mirrorAssistant) { yield* ensureV2AssistantMessage() } } @@ -693,7 +703,7 @@ export const layer = Layer.effect( }) if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { + if (mirrorAssistant) { yield* events.publish(SessionEvent.Step.Ended, { sessionID: ctx.sessionID, assistantMessageID: yield* currentV2AssistantMessage(), @@ -752,9 +762,10 @@ export const layer = Layer.effect( case "text-start": if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { + if (mirrorAssistant) { yield* events.publish(SessionEvent.Text.Started, { sessionID: ctx.sessionID, + assistantMessageID: yield* ensureV2AssistantMessage(), timestamp: DateTime.makeUnsafe(Date.now()), textID: value.id, }) @@ -777,9 +788,10 @@ export const layer = Layer.effect( if (!ctx.currentText) return ctx.currentText.text += value.text if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata - if (flags.experimentalEventSystem) { + if (mirrorAssistant) { yield* events.publish(SessionEvent.Text.Delta, { sessionID: ctx.sessionID, + assistantMessageID: yield* currentV2AssistantMessage(), textID: value.id, delta: value.text, timestamp: DateTime.makeUnsafe(Date.now()), @@ -809,9 +821,10 @@ export const layer = Layer.effect( )).text if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { + if (mirrorAssistant) { yield* events.publish(SessionEvent.Text.Ended, { sessionID: ctx.sessionID, + assistantMessageID: yield* currentV2AssistantMessage(), text: ctx.currentText.text, timestamp: DateTime.makeUnsafe(Date.now()), textID: value.id, @@ -876,7 +889,7 @@ export const layer = Layer.effect( const match = yield* readToolCall(toolCallID) if (!match) continue const part = match.part - if (flags.experimentalEventSystem && match.call.assistantMessageID) { + if (mirrorAssistant && match.call.assistantMessageID) { yield* events.publish(SessionEvent.Tool.Failed, { sessionID: ctx.sessionID, assistantMessageID: match.call.assistantMessageID, @@ -922,7 +935,7 @@ export const layer = Layer.effect( } if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { + if (mirrorAssistant) { yield* events.publish(SessionEvent.Step.Failed, { sessionID: ctx.sessionID, assistantMessageID: yield* ensureV2AssistantMessage(), @@ -979,7 +992,7 @@ export const layer = Layer.effect( parse, set: (info) => { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - const event = flags.experimentalEventSystem + const event = mirrorAssistant ? events.publish(SessionEvent.Retried, { sessionID: ctx.sessionID, attempt: info.attempt, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 1ac1efb1f8..a75a3c993a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -51,6 +51,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2Bridge } from "@/event-v2-bridge" import { Database } from "@opencode-ai/core/database/database" import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionMessage } from "@opencode-ai/core/session/message" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { AgentAttachment, FileAttachment, Prompt, ReferenceAttachment, Source } from "@opencode-ai/core/session/prompt" @@ -563,6 +564,7 @@ export const layer = Layer.effect( if (flags.experimentalEventSystem) { yield* events.publish(SessionEvent.Shell.Started, { sessionID: input.sessionID, + messageID: SessionMessage.ID.create(), timestamp: DateTime.makeUnsafe(started), callID: part.callID, command: input.command, @@ -738,6 +740,7 @@ export const layer = Layer.effect( if (current?.agent !== info.agent) { yield* events.publish(SessionEvent.AgentSwitched, { sessionID: input.sessionID, + messageID: SessionMessage.ID.create(), timestamp: DateTime.makeUnsafe(info.time.created), agent: info.agent, }) @@ -749,6 +752,7 @@ export const layer = Layer.effect( ) { yield* events.publish(SessionEvent.ModelSwitched, { sessionID: input.sessionID, + messageID: SessionMessage.ID.create(), timestamp: DateTime.makeUnsafe(info.time.created), model: { id: ModelV2.ID.make(info.model.modelID), @@ -1190,6 +1194,7 @@ export const layer = Layer.effect( if (flags.experimentalEventSystem) { yield* events.publish(SessionEvent.Prompted, { sessionID: input.sessionID, + messageID: SessionMessage.ID.create(), timestamp: DateTime.makeUnsafe(info.time.created), delivery: "steer", prompt: new Prompt({ @@ -1205,6 +1210,7 @@ export const layer = Layer.effect( if (flags.experimentalEventSystem) { yield* events.publish(SessionEvent.Synthetic, { sessionID: input.sessionID, + messageID: SessionMessage.ID.create(), timestamp: DateTime.makeUnsafe(info.time.created), text, }) diff --git a/packages/opencode/test/cli/tui/sync-v2.test.tsx b/packages/opencode/test/cli/tui/sync-v2.test.tsx index 9cc495e466..1b995d2943 100644 --- a/packages/opencode/test/cli/tui/sync-v2.test.tsx +++ b/packages/opencode/test/cli/tui/sync-v2.test.tsx @@ -6,7 +6,7 @@ import { onMount } from "solid-js" import { ProjectProvider } from "../../../src/cli/cmd/tui/context/project" import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk" import { SyncProviderV2, useSyncV2 } from "../../../src/cli/cmd/tui/context/sync-v2" -import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk" +import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk" async function wait(fn: () => boolean, timeout = 2000) { const start = Date.now() @@ -20,6 +20,12 @@ function global(payload: Event): GlobalEvent { return { directory, project: "proj_test", payload } } +function emitTwice(events: ReturnType, payload: Event) { + const event = global(payload) + events.emit(event) + events.emit(event) +} + test("sync v2 settles pending tools when a live failure arrives", async () => { const events = createEventSource() const calls = createFetch() @@ -47,63 +53,68 @@ test("sync v2 settles pending tools when a live failure arrives", async () => { try { await mounted - events.emit( - global({ - id: "agent-1", - type: "session.next.agent.switched", - properties: { sessionID: "session-1", timestamp: 0, agent: "build" }, - }), - ) - events.emit( - global({ - id: "model-1", - type: "session.next.model.switched", - properties: { - sessionID: "session-1", - timestamp: 0, - model: { id: "model-1", providerID: "provider-1" }, - }, - }), - ) - events.emit( - global({ - id: "assistant-1", - type: "session.next.step.started", - properties: { - sessionID: "session-1", - timestamp: 1, - agent: "build", - model: { id: "model-1", providerID: "provider-1" }, - }, - }), - ) - events.emit( - global({ - id: "input-1", - type: "session.next.tool.input.started", - properties: { - sessionID: "session-1", - timestamp: 2, - assistantMessageID: "assistant-1", - callID: "call-1", - name: "bash", - }, - }), - ) - events.emit( - global({ - id: "failed-1", - type: "session.next.tool.failed", - properties: { - sessionID: "session-1", - timestamp: 3, - assistantMessageID: "assistant-1", - callID: "call-1", - error: { type: "unknown", message: "aborted" }, - provider: { executed: false }, - }, - }), - ) + emitTwice(events, { + id: "evt_agent_1", + type: "session.next.agent.switched", + properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" }, + }) + emitTwice(events, { + id: "evt_model_1", + type: "session.next.model.switched", + properties: { + sessionID: "session-1", + messageID: "msg_model_1", + timestamp: 0, + model: { id: "model-1", providerID: "provider-1" }, + }, + }) + emitTwice(events, { + id: "evt_step_started_1", + type: "session.next.step.started", + properties: { + sessionID: "session-1", + assistantMessageID: "msg_explicit_assistant_9", + timestamp: 1, + agent: "build", + model: { id: "model-1", providerID: "provider-1" }, + }, + }) + emitTwice(events, { + id: "evt_input_1", + type: "session.next.tool.input.started", + properties: { + sessionID: "session-1", + assistantMessageID: "msg_explicit_assistant_9", + timestamp: 2, + callID: "call-1", + name: "bash", + }, + }) + emitTwice(events, { + id: "evt_called_1", + type: "session.next.tool.called", + properties: { + sessionID: "session-1", + timestamp: 2, + assistantMessageID: "msg_explicit_assistant_9", + callID: "call-1", + tool: "bash", + input: {}, + provider: { executed: false, metadata: { fake: { call: true } } }, + }, + }) + emitTwice(events, { + id: "evt_failed_1", + type: "session.next.tool.failed", + properties: { + sessionID: "session-1", + timestamp: 3, + assistantMessageID: "msg_explicit_assistant_9", + callID: "call-1", + error: { type: "unknown", message: "aborted" }, + provider: { executed: false, metadata: { fake: { result: true } } }, + }, + }) await wait(() => { const assistant = sync.session.message.fromSession("session-1")[0] @@ -117,6 +128,7 @@ test("sync v2 settles pending tools when a live failure arrives", async () => { const assistant = sync.session.message.fromSession("session-1")[0] expect(assistant?.type).toBe("assistant") if (assistant?.type !== "assistant") return + expect(assistant.id).toBe("msg_explicit_assistant_9") const tool = assistant.content[0] expect(tool?.type).toBe("tool") if (tool?.type !== "tool") return @@ -126,6 +138,11 @@ test("sync v2 settles pending tools when a live failure arrives", async () => { expect(tool.state.input).toEqual({}) expect(tool.state.structured).toEqual({}) expect(tool.state.content).toEqual([]) + expect(tool.provider).toEqual({ + executed: false, + metadata: { fake: { call: true } }, + resultMetadata: { fake: { result: true } }, + }) expect(sync.session.message.fromSession("session-1").map((message) => message.type)).toEqual([ "assistant", "model-switched", @@ -135,3 +152,358 @@ test("sync v2 settles pending tools when a live failure arrives", async () => { app.renderer.destroy() } }) + +test("sync v2 renders admitted prompts only after promotion", 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_admitted_1", + type: "session.next.prompt.admitted", + properties: { + sessionID: "session-1", + messageID: "msg_user_1", + timestamp: 0, + prompt: { text: "hello" }, + delivery: "steer", + }, + }) + expect(sync.session.message.fromSession("session-1")).toEqual([]) + + emitTwice(events, { + id: "evt_promoted_1", + type: "session.next.prompt.promoted", + properties: { + sessionID: "session-1", + messageID: "msg_user_1", + timestamp: 1, + prompt: { text: "hello" }, + timeCreated: 0, + }, + }) + + await wait(() => sync.session.message.fromSession("session-1").length === 1) + const message = sync.session.message.fromSession("session-1")[0] + expect(message?.type).toBe("user") + if (message?.type !== "user") return + expect(message).toMatchObject({ id: "msg_user_1", text: "hello" }) + } finally { + app.renderer.destroy() + } +}) + +test("sync v2 renders a promoted prompt when admission was missed", 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_promoted_1", + type: "session.next.prompt.promoted", + properties: { + sessionID: "session-1", + messageID: "msg_user_1", + timestamp: 1, + prompt: { text: "hello" }, + timeCreated: 0, + }, + }) + + await wait(() => sync.session.message.fromSession("session-1").length === 1) + expect(sync.session.message.fromSession("session-1")[0]?.id).toBe("msg_user_1") + } finally { + app.renderer.destroy() + } +}) + +test("sync v2 preserves live events while snapshot hydration is in flight", 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_agent_1", + type: "session.next.agent.switched", + properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" }, + }) + response.resolve(json({ data: [] })) + await hydration + + expect(sync.session.message.fromSession("session-1").map((message) => [message.id, message.type])).toEqual([ + ["msg_agent_1", "agent-switched"], + ]) + } 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() + 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 + emitTwice(events, { + id: "evt_promoted_1", + type: "session.next.prompt.promoted", + properties: { + sessionID: "session-1", + messageID: "msg_user_1", + timestamp: 1, + prompt: { text: "stale" }, + timeCreated: 0, + }, + }) + await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_user_1") + const hydration = sync.session.message.sync("session-1") + emitTwice(events, { + id: "evt_agent_1", + type: "session.next.agent.switched", + properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 2, agent: "build" }, + }) + await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_agent_1") + response.resolve( + json({ + data: [{ id: "msg_user_1", type: "user", text: "fresh", time: { created: 0 } }], + }), + ) + await hydration + + expect(sync.session.message.fromSession("session-1").map((message) => [message.id, message.type])).toEqual([ + ["msg_agent_1", "agent-switched"], + ["msg_user_1", "user"], + ]) + expect(sync.session.message.fromSession("session-1")[1]).toMatchObject({ text: "fresh" }) + } finally { + app.renderer.destroy() + } +}) + +test("sync v2 preserves snapshot order and metadata for in-flight updates", 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 + emitTwice(events, { + id: "evt_step_older", + type: "session.next.step.started", + properties: { + sessionID: "session-1", + assistantMessageID: "msg_assistant_older", + timestamp: 0, + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + emitTwice(events, { + id: "evt_step_1", + type: "session.next.step.started", + properties: { + sessionID: "session-1", + assistantMessageID: "msg_assistant_old", + timestamp: 1, + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_assistant_old") + const hydration = sync.session.message.sync("session-1") + emitTwice(events, { + id: "evt_text_1", + type: "session.next.text.started", + properties: { + sessionID: "session-1", + assistantMessageID: "msg_assistant_old", + timestamp: 2, + textID: "text-1", + }, + }) + emitTwice(events, { + id: "evt_text_older", + type: "session.next.text.started", + properties: { + sessionID: "session-1", + assistantMessageID: "msg_assistant_older", + timestamp: 2, + textID: "text-older", + }, + }) + await wait(() => { + const messages = sync.session.message.fromSession("session-1") + return messages.every((message) => message.type !== "assistant" || message.content[0]?.type === "text") + }) + response.resolve( + json({ + data: [ + { + id: "msg_assistant_new", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [], + time: { created: 3 }, + }, + { + id: "msg_assistant_old", + type: "assistant", + metadata: { source: "snapshot" }, + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [], + time: { created: 1 }, + }, + ], + }), + ) + await hydration + emitTwice(events, { + id: "evt_step_late_duplicate", + type: "session.next.step.started", + properties: { + sessionID: "session-1", + assistantMessageID: "msg_assistant_old", + timestamp: 1, + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + + expect(sync.session.message.fromSession("session-1").map((message) => message.id)).toEqual([ + "msg_assistant_new", + "msg_assistant_old", + "msg_assistant_older", + ]) + expect(JSON.parse(JSON.stringify(sync.session.message.fromSession("session-1")[1]))).toMatchObject({ + metadata: { source: "snapshot" }, + content: [{ type: "text", id: "text-1", text: "" }], + }) + expect(JSON.parse(JSON.stringify(sync.session.message.fromSession("session-1")[2]))).toMatchObject({ + content: [{ type: "text", id: "text-older", text: "" }], + }) + } finally { + app.renderer.destroy() + } +}) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index d1b21b34fb..f4e2bff781 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -584,17 +584,19 @@ describe("session HttpApi", () => { request(`/api/session/${session.id}/prompt`, { method: "POST", headers: { ...headers, "content-type": "application/json" }, - body: JSON.stringify({ id: "evt_http_prompt", prompt: { text: "hello" } }), + body: JSON.stringify({ id: "msg_http_prompt", prompt: { text: "hello" } }), }) const first = yield* recordPrompt() const retried = yield* recordPrompt() - type PromptBody = { id: string; type: string; text: string } + type PromptBody = { id: string; prompt: { text: string }; delivery: string; promotedSeq?: number } const firstBody = yield* json<{ data: PromptBody }>(first) const retriedBody = yield* json<{ data: PromptBody }>(retried) expect(first.status).toBe(200) expect(retried.status).toBe(200) expect(retriedBody).toEqual(firstBody) - expect(firstBody).toMatchObject({ data: { type: "user", text: "hello" } }) + expect(firstBody).toMatchObject({ + data: { id: "msg_http_prompt", prompt: { text: "hello" }, delivery: "steer" }, + }) const messages = yield* requestJson<{ data: PromptBody[] }>(`/api/session/${session.id}/message`, { headers, @@ -604,27 +606,26 @@ describe("session HttpApi", () => { db .select() .from(SessionInputTable) - .where(eq(SessionInputTable.id, SessionMessage.ID.make("evt_http_prompt"))) + .where(eq(SessionInputTable.id, SessionMessage.ID.make("msg_http_prompt"))) .get() .pipe(Effect.orDie), ) expect(admitted).toMatchObject({ - id: "evt_http_prompt", + id: "msg_http_prompt", session_id: session.id, delivery: "steer", promoted_seq: null, }) - const conflict = yield* request(`/api/session/${session.id}/prompt`, { method: "POST", headers: { ...headers, "content-type": "application/json" }, - body: JSON.stringify({ id: "evt_http_prompt", prompt: { text: "goodbye" } }), + body: JSON.stringify({ id: "msg_http_prompt", prompt: { text: "goodbye" } }), }) expect(conflict.status).toBe(409) expect(yield* responseJson(conflict)).toEqual({ _tag: "ConflictError", - message: "Prompt message ID conflicts with an existing durable record: evt_http_prompt", - resource: "evt_http_prompt", + message: "Prompt message ID conflicts with an existing durable record: msg_http_prompt", + resource: "msg_http_prompt", }) }), { git: true, config: { formatter: false, lsp: false } }, diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index 0db044a86a..95b7004e8b 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -106,6 +106,13 @@ describe("sync HttpApi", () => { events: [{ id: "event", aggregateID: "session", seq: 1.5, type: "session.created", data: {} }], }, }, + { + path: SyncPaths.replay, + body: { + directory: tmp.directory, + events: [{ id: "event", aggregateID: "session", seq: 0, type: "session.created", data: {} }], + }, + }, ] for (const item of cases) { diff --git a/packages/opencode/test/storage/workspace-time-migration.test.ts b/packages/opencode/test/storage/workspace-time-migration.test.ts index a33d6d5e5a..063b26a4b1 100644 --- a/packages/opencode/test/storage/workspace-time-migration.test.ts +++ b/packages/opencode/test/storage/workspace-time-migration.test.ts @@ -26,7 +26,7 @@ function migrations() { } describe("workspace time migration", () => { - test("migrates existing workspace rows", () => { + test("discards existing workspace rows during the beta reset", () => { const sqlite = new Database(":memory:") const db = drizzle({ client: sqlite }) const entries = migrations() @@ -45,6 +45,6 @@ describe("workspace time migration", () => { ) expect(() => migrate(db, entries.slice(index))).not.toThrow() - expect(sqlite.query("SELECT time_used FROM workspace WHERE id = ?").get("workspace_1")).toEqual({ time_used: 0 }) + expect(sqlite.query("SELECT time_used FROM workspace WHERE id = ?").get("workspace_1")).toBeNull() }) }) diff --git a/packages/opencode/test/v2/session-message-updater.test.ts b/packages/opencode/test/v2/session-message-updater.test.ts index d240324c28..d937950a48 100644 --- a/packages/opencode/test/v2/session-message-updater.test.ts +++ b/packages/opencode/test/v2/session-message-updater.test.ts @@ -7,19 +7,21 @@ import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater" +import { SessionMessage } from "@opencode-ai/core/session/message" import { ToolOutput } from "@opencode-ai/core/tool-output" test.skip("step snapshots carry over to assistant messages", () => { const state: SessionMessageUpdater.MemoryState = { messages: [] } const sessionID = SessionID.make("session") - const assistantMessageID = EventV2.ID.create() + const assistantMessageID = SessionMessage.ID.create() Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { - id: assistantMessageID, + id: EventV2.ID.create(), type: "session.next.step.started", data: { sessionID, + assistantMessageID, timestamp: DateTime.makeUnsafe(1), agent: "build", model: { @@ -62,6 +64,7 @@ test.skip("step snapshots carry over to assistant messages", () => { test.skip("text ended populates assistant text content", () => { const state: SessionMessageUpdater.MemoryState = { messages: [] } const sessionID = SessionID.make("session") + const assistantMessageID = SessionMessage.ID.create() Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { @@ -69,6 +72,7 @@ test.skip("text ended populates assistant text content", () => { type: "session.next.step.started", data: { sessionID, + assistantMessageID, timestamp: DateTime.makeUnsafe(1), agent: "build", model: { @@ -86,6 +90,7 @@ test.skip("text ended populates assistant text content", () => { type: "session.next.text.started", data: { sessionID, + assistantMessageID, timestamp: DateTime.makeUnsafe(2), textID: "text-1", }, @@ -98,6 +103,7 @@ test.skip("text ended populates assistant text content", () => { type: "session.next.text.ended", data: { sessionID, + assistantMessageID, timestamp: DateTime.makeUnsafe(3), textID: "text-1", text: "hello assistant", @@ -114,14 +120,15 @@ test.skip("tool completion stores completed timestamp", () => { const state: SessionMessageUpdater.MemoryState = { messages: [] } const sessionID = SessionID.make("session") const callID = "call" - const assistantMessageID = EventV2.ID.create() + const assistantMessageID = SessionMessage.ID.create() Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { - id: assistantMessageID, + id: EventV2.ID.create(), type: "session.next.step.started", data: { sessionID, + assistantMessageID, timestamp: DateTime.makeUnsafe(1), agent: "build", model: { @@ -198,6 +205,7 @@ test.skip("compaction events reduce to compaction message", () => { type: "session.next.compaction.started", data: { sessionID, + messageID: SessionMessage.ID.create(), timestamp: DateTime.makeUnsafe(1), reason: "auto", }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 81cee54803..f3a9cca90a 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -19,6 +19,8 @@ export type Event = | EventSessionNextModelSwitched | EventSessionNextMoved | EventSessionNextPrompted + | EventSessionNextPromptAdmitted + | EventSessionNextPromptPromoted | EventSessionNextSynthetic | EventSessionNextShellStarted | EventSessionNextShellEnded @@ -804,6 +806,7 @@ export type GlobalEvent = { properties: { timestamp: number sessionID: string + messageID: string agent: string } } @@ -813,6 +816,7 @@ export type GlobalEvent = { properties: { timestamp: number sessionID: string + messageID: string model: { id: string providerID: string @@ -836,16 +840,40 @@ export type GlobalEvent = { properties: { timestamp: number sessionID: string + messageID: string prompt: Prompt delivery: "steer" | "queue" } } + | { + id: string + type: "session.next.prompt.admitted" + properties: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + delivery: "steer" | "queue" + } + } + | { + id: string + type: "session.next.prompt.promoted" + properties: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + timeCreated: number + } + } | { id: string type: "session.next.synthetic" properties: { timestamp: number sessionID: string + messageID: string text: string } } @@ -855,6 +883,7 @@ export type GlobalEvent = { properties: { timestamp: number sessionID: string + messageID: string callID: string command: string } @@ -875,6 +904,7 @@ export type GlobalEvent = { properties: { timestamp: number sessionID: string + assistantMessageID: string agent: string model: { id: string @@ -921,6 +951,7 @@ export type GlobalEvent = { properties: { timestamp: number sessionID: string + assistantMessageID: string textID: string } } @@ -930,6 +961,7 @@ export type GlobalEvent = { properties: { timestamp: number sessionID: string + assistantMessageID: string textID: string delta: string } @@ -940,6 +972,7 @@ export type GlobalEvent = { properties: { timestamp: number sessionID: string + assistantMessageID: string textID: string text: string } @@ -950,6 +983,7 @@ export type GlobalEvent = { properties: { timestamp: number sessionID: string + assistantMessageID: string reasoningID: string providerMetadata?: { [key: string]: { @@ -964,6 +998,7 @@ export type GlobalEvent = { properties: { timestamp: number sessionID: string + assistantMessageID: string reasoningID: string delta: string } @@ -974,6 +1009,7 @@ export type GlobalEvent = { properties: { timestamp: number sessionID: string + assistantMessageID: string reasoningID: string text: string providerMetadata?: { @@ -1111,6 +1147,7 @@ export type GlobalEvent = { properties: { timestamp: number sessionID: string + messageID: string reason: "auto" | "manual" } } @@ -1576,6 +1613,8 @@ export type GlobalEvent = { | SyncEventSessionNextModelSwitched | SyncEventSessionNextMoved | SyncEventSessionNextPrompted + | SyncEventSessionNextPromptAdmitted + | SyncEventSessionNextPromptPromoted | SyncEventSessionNextSynthetic | SyncEventSessionNextShellStarted | SyncEventSessionNextShellEnded @@ -3122,6 +3161,7 @@ export type SyncEventSessionNextAgentSwitched = { data: { timestamp: number sessionID: string + messageID: string agent: string } } @@ -3138,6 +3178,7 @@ export type SyncEventSessionNextModelSwitched = { data: { timestamp: number sessionID: string + messageID: string model: { id: string providerID: string @@ -3175,12 +3216,49 @@ export type SyncEventSessionNextPrompted = { data: { timestamp: number sessionID: string + messageID: string prompt: Prompt delivery: "steer" | "queue" } } } +export type SyncEventSessionNextPromptAdmitted = { + type: "sync" + id: string + syncEvent: { + type: "session.next.prompt.admitted.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + delivery: "steer" | "queue" + } + } +} + +export type SyncEventSessionNextPromptPromoted = { + type: "sync" + id: string + syncEvent: { + type: "session.next.prompt.promoted.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + timeCreated: number + } + } +} + export type SyncEventSessionNextSynthetic = { type: "sync" id: string @@ -3192,6 +3270,7 @@ export type SyncEventSessionNextSynthetic = { data: { timestamp: number sessionID: string + messageID: string text: string } } @@ -3208,6 +3287,7 @@ export type SyncEventSessionNextShellStarted = { data: { timestamp: number sessionID: string + messageID: string callID: string command: string } @@ -3242,6 +3322,7 @@ export type SyncEventSessionNextStepStarted = { data: { timestamp: number sessionID: string + assistantMessageID: string agent: string model: { id: string @@ -3309,6 +3390,7 @@ export type SyncEventSessionNextTextStarted = { data: { timestamp: number sessionID: string + assistantMessageID: string textID: string } } @@ -3325,6 +3407,7 @@ export type SyncEventSessionNextTextEnded = { data: { timestamp: number sessionID: string + assistantMessageID: string textID: string text: string } @@ -3342,6 +3425,7 @@ export type SyncEventSessionNextReasoningStarted = { data: { timestamp: number sessionID: string + assistantMessageID: string reasoningID: string providerMetadata?: { [key: string]: { @@ -3363,6 +3447,7 @@ export type SyncEventSessionNextReasoningEnded = { data: { timestamp: number sessionID: string + assistantMessageID: string reasoningID: string text: string providerMetadata?: { @@ -3545,6 +3630,7 @@ export type SyncEventSessionNextCompactionStarted = { data: { timestamp: number sessionID: string + messageID: string reason: "auto" | "manual" } } @@ -3670,19 +3756,14 @@ export type SessionV2Info = { subpath?: string } -export type SessionMessageUser = { +export type SessionInputAdmitted = { + admittedSeq: number id: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - } - text: string - files?: Array - agents?: Array - references?: Array - type: "user" + sessionID: string + prompt: Prompt + delivery: "steer" | "queue" + timeCreated: number + promotedSeq?: number } export type SessionMessageAgentSwitched = { @@ -3713,6 +3794,21 @@ export type SessionMessageModelSwitched = { } } +export type SessionMessageUser = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + text: string + files?: Array + agents?: Array + references?: Array + type: "user" +} + export type SessionMessageSynthetic = { id: string metadata?: { @@ -4176,6 +4272,7 @@ export type EventSessionNextAgentSwitched = { properties: { timestamp: number sessionID: string + messageID: string agent: string } } @@ -4186,6 +4283,7 @@ export type EventSessionNextModelSwitched = { properties: { timestamp: number sessionID: string + messageID: string model: { id: string providerID: string @@ -4211,17 +4309,43 @@ export type EventSessionNextPrompted = { properties: { timestamp: number sessionID: string + messageID: string prompt: Prompt delivery: "steer" | "queue" } } +export type EventSessionNextPromptAdmitted = { + id: string + type: "session.next.prompt.admitted" + properties: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + delivery: "steer" | "queue" + } +} + +export type EventSessionNextPromptPromoted = { + id: string + type: "session.next.prompt.promoted" + properties: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + timeCreated: number + } +} + export type EventSessionNextSynthetic = { id: string type: "session.next.synthetic" properties: { timestamp: number sessionID: string + messageID: string text: string } } @@ -4232,6 +4356,7 @@ export type EventSessionNextShellStarted = { properties: { timestamp: number sessionID: string + messageID: string callID: string command: string } @@ -4254,6 +4379,7 @@ export type EventSessionNextStepStarted = { properties: { timestamp: number sessionID: string + assistantMessageID: string agent: string model: { id: string @@ -4303,6 +4429,7 @@ export type EventSessionNextTextStarted = { properties: { timestamp: number sessionID: string + assistantMessageID: string textID: string } } @@ -4313,6 +4440,7 @@ export type EventSessionNextTextDelta = { properties: { timestamp: number sessionID: string + assistantMessageID: string textID: string delta: string } @@ -4324,6 +4452,7 @@ export type EventSessionNextTextEnded = { properties: { timestamp: number sessionID: string + assistantMessageID: string textID: string text: string } @@ -4335,6 +4464,7 @@ export type EventSessionNextReasoningStarted = { properties: { timestamp: number sessionID: string + assistantMessageID: string reasoningID: string providerMetadata?: { [key: string]: { @@ -4350,6 +4480,7 @@ export type EventSessionNextReasoningDelta = { properties: { timestamp: number sessionID: string + assistantMessageID: string reasoningID: string delta: string } @@ -4361,6 +4492,7 @@ export type EventSessionNextReasoningEnded = { properties: { timestamp: number sessionID: string + assistantMessageID: string reasoningID: string text: string providerMetadata?: { @@ -4507,6 +4639,7 @@ export type EventSessionNextCompactionStarted = { properties: { timestamp: number sessionID: string + messageID: string reason: "auto" | "manual" } } @@ -9263,7 +9396,7 @@ export type V2SessionPromptResponses = { * Success */ 200: { - data: SessionMessageUser + data: SessionInputAdmitted } } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 614a95fad3..6c9affdbb7 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -10122,7 +10122,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/SessionMessageUser" + "$ref": "#/components/schemas/SessionInputAdmitted" } }, "required": ["data"], @@ -10181,7 +10181,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "prompt": { "$ref": "#/components/schemas/Prompt" @@ -12017,6 +12018,12 @@ { "$ref": "#/components/schemas/EventSessionNextPrompted" }, + { + "$ref": "#/components/schemas/EventSessionNextPromptAdmitted" + }, + { + "$ref": "#/components/schemas/EventSessionNextPromptPromoted" + }, { "$ref": "#/components/schemas/EventSessionNextSynthetic" }, @@ -14383,11 +14390,15 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "agent": { "type": "string" } }, - "required": ["timestamp", "sessionID", "agent"], + "required": ["timestamp", "sessionID", "messageID", "agent"], "additionalProperties": false } }, @@ -14414,6 +14425,10 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "model": { "type": "object", "properties": { @@ -14431,7 +14446,7 @@ "additionalProperties": false } }, - "required": ["timestamp", "sessionID", "model"], + "required": ["timestamp", "sessionID", "messageID", "model"], "additionalProperties": false } }, @@ -14492,6 +14507,10 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "prompt": { "$ref": "#/components/schemas/Prompt" }, @@ -14500,7 +14519,84 @@ "enum": ["steer", "queue"] } }, - "required": ["timestamp", "sessionID", "prompt", "delivery"], + "required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.prompt.admitted"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "delivery": { + "type": "string", + "enum": ["steer", "queue"] + } + }, + "required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.prompt.promoted"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "timeCreated": { + "type": "number" + } + }, + "required": ["timestamp", "sessionID", "messageID", "prompt", "timeCreated"], "additionalProperties": false } }, @@ -14527,11 +14623,15 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "text": { "type": "string" } }, - "required": ["timestamp", "sessionID", "text"], + "required": ["timestamp", "sessionID", "messageID", "text"], "additionalProperties": false } }, @@ -14558,6 +14658,10 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "callID": { "type": "string" }, @@ -14565,7 +14669,7 @@ "type": "string" } }, - "required": ["timestamp", "sessionID", "callID", "command"], + "required": ["timestamp", "sessionID", "messageID", "callID", "command"], "additionalProperties": false } }, @@ -14626,6 +14730,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "agent": { "type": "string" }, @@ -14649,7 +14757,7 @@ "type": "string" } }, - "required": ["timestamp", "sessionID", "agent", "model"], + "required": ["timestamp", "sessionID", "assistantMessageID", "agent", "model"], "additionalProperties": false } }, @@ -14677,7 +14785,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "finish": { "type": "string" @@ -14746,7 +14855,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "error": { "$ref": "#/components/schemas/SessionErrorUnknown" @@ -14779,11 +14889,15 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "textID": { "type": "string" } }, - "required": ["timestamp", "sessionID", "textID"], + "required": ["timestamp", "sessionID", "assistantMessageID", "textID"], "additionalProperties": false } }, @@ -14810,6 +14924,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "textID": { "type": "string" }, @@ -14817,7 +14935,7 @@ "type": "string" } }, - "required": ["timestamp", "sessionID", "textID", "delta"], + "required": ["timestamp", "sessionID", "assistantMessageID", "textID", "delta"], "additionalProperties": false } }, @@ -14844,6 +14962,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "textID": { "type": "string" }, @@ -14851,7 +14973,7 @@ "type": "string" } }, - "required": ["timestamp", "sessionID", "textID", "text"], + "required": ["timestamp", "sessionID", "assistantMessageID", "textID", "text"], "additionalProperties": false } }, @@ -14878,6 +15000,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "reasoningID": { "type": "string" }, @@ -14888,7 +15014,7 @@ } } }, - "required": ["timestamp", "sessionID", "reasoningID"], + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID"], "additionalProperties": false } }, @@ -14915,6 +15041,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "reasoningID": { "type": "string" }, @@ -14922,7 +15052,7 @@ "type": "string" } }, - "required": ["timestamp", "sessionID", "reasoningID", "delta"], + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "delta"], "additionalProperties": false } }, @@ -14949,6 +15079,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "reasoningID": { "type": "string" }, @@ -14962,7 +15096,7 @@ } } }, - "required": ["timestamp", "sessionID", "reasoningID", "text"], + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "text"], "additionalProperties": false } }, @@ -14990,7 +15124,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -15027,7 +15162,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -15064,7 +15200,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -15101,7 +15238,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -15157,7 +15295,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -15207,7 +15346,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -15282,7 +15422,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -15369,12 +15510,16 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "reason": { "type": "string", "enum": ["auto", "manual"] } }, - "required": ["timestamp", "sessionID", "reason"], + "required": ["timestamp", "sessionID", "messageID", "reason"], "additionalProperties": false } }, @@ -16904,6 +17049,12 @@ { "$ref": "#/components/schemas/SyncEventSessionNextPrompted" }, + { + "$ref": "#/components/schemas/SyncEventSessionNextPromptAdmitted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextPromptPromoted" + }, { "$ref": "#/components/schemas/SyncEventSessionNextSynthetic" }, @@ -21285,11 +21436,15 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "agent": { "type": "string" } }, - "required": ["timestamp", "sessionID", "agent"], + "required": ["timestamp", "sessionID", "messageID", "agent"], "additionalProperties": false } }, @@ -21336,6 +21491,10 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "model": { "type": "object", "properties": { @@ -21353,7 +21512,7 @@ "additionalProperties": false } }, - "required": ["timestamp", "sessionID", "model"], + "required": ["timestamp", "sessionID", "messageID", "model"], "additionalProperties": false } }, @@ -21454,6 +21613,10 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "prompt": { "$ref": "#/components/schemas/Prompt" }, @@ -21462,7 +21625,124 @@ "enum": ["steer", "queue"] } }, - "required": ["timestamp", "sessionID", "prompt", "delivery"], + "required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"], + "additionalProperties": false + } + }, + "required": ["type", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + } + }, + "required": ["type", "id", "syncEvent"], + "additionalProperties": false + }, + "SyncEventSessionNextPromptAdmitted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "id": { + "type": "string" + }, + "syncEvent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["session.next.prompt.admitted.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "delivery": { + "type": "string", + "enum": ["steer", "queue"] + } + }, + "required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"], + "additionalProperties": false + } + }, + "required": ["type", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + } + }, + "required": ["type", "id", "syncEvent"], + "additionalProperties": false + }, + "SyncEventSessionNextPromptPromoted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "id": { + "type": "string" + }, + "syncEvent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["session.next.prompt.promoted.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "timeCreated": { + "type": "number" + } + }, + "required": ["timestamp", "sessionID", "messageID", "prompt", "timeCreated"], "additionalProperties": false } }, @@ -21509,11 +21789,15 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "text": { "type": "string" } }, - "required": ["timestamp", "sessionID", "text"], + "required": ["timestamp", "sessionID", "messageID", "text"], "additionalProperties": false } }, @@ -21560,6 +21844,10 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "callID": { "type": "string" }, @@ -21567,7 +21855,7 @@ "type": "string" } }, - "required": ["timestamp", "sessionID", "callID", "command"], + "required": ["timestamp", "sessionID", "messageID", "callID", "command"], "additionalProperties": false } }, @@ -21668,6 +21956,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "agent": { "type": "string" }, @@ -21691,7 +21983,7 @@ "type": "string" } }, - "required": ["timestamp", "sessionID", "agent", "model"], + "required": ["timestamp", "sessionID", "assistantMessageID", "agent", "model"], "additionalProperties": false } }, @@ -21739,7 +22031,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "finish": { "type": "string" @@ -21828,7 +22121,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "error": { "$ref": "#/components/schemas/SessionErrorUnknown" @@ -21881,11 +22175,15 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "textID": { "type": "string" } }, - "required": ["timestamp", "sessionID", "textID"], + "required": ["timestamp", "sessionID", "assistantMessageID", "textID"], "additionalProperties": false } }, @@ -21932,6 +22230,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "textID": { "type": "string" }, @@ -21939,7 +22241,7 @@ "type": "string" } }, - "required": ["timestamp", "sessionID", "textID", "text"], + "required": ["timestamp", "sessionID", "assistantMessageID", "textID", "text"], "additionalProperties": false } }, @@ -21986,6 +22288,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "reasoningID": { "type": "string" }, @@ -21996,7 +22302,7 @@ } } }, - "required": ["timestamp", "sessionID", "reasoningID"], + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID"], "additionalProperties": false } }, @@ -22043,6 +22349,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "reasoningID": { "type": "string" }, @@ -22056,7 +22366,7 @@ } } }, - "required": ["timestamp", "sessionID", "reasoningID", "text"], + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "text"], "additionalProperties": false } }, @@ -22104,7 +22414,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -22161,7 +22472,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -22218,7 +22530,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -22294,7 +22607,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -22364,7 +22678,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -22459,7 +22774,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -22586,12 +22902,16 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "reason": { "type": "string", "enum": ["auto", "manual"] } }, - "required": ["timestamp", "sessionID", "reason"], + "required": ["timestamp", "sessionID", "messageID", "reason"], "additionalProperties": false } }, @@ -22965,11 +23285,120 @@ "required": ["id", "projectID", "cost", "tokens", "time", "title", "location"], "additionalProperties": false }, + "SessionInputAdmitted": { + "type": "object", + "properties": { + "admittedSeq": { + "type": "integer", + "minimum": 0 + }, + "id": { + "type": "string", + "pattern": "^msg_" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "delivery": { + "type": "string", + "enum": ["steer", "queue"] + }, + "timeCreated": { + "type": "number" + }, + "promotedSeq": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["admittedSeq", "id", "sessionID", "prompt", "delivery", "timeCreated"], + "additionalProperties": false + }, + "SessionMessageAgentSwitched": { + "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": ["agent-switched"] + }, + "agent": { + "type": "string" + } + }, + "required": ["id", "time", "type", "agent"], + "additionalProperties": false + }, + "SessionMessageModelSwitched": { + "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": ["model-switched"] + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + } + }, + "required": ["id", "time", "type", "model"], + "additionalProperties": false + }, "SessionMessageUser": { "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "metadata": { "type": "object" @@ -23013,84 +23442,12 @@ "required": ["id", "time", "text", "type"], "additionalProperties": false }, - "SessionMessageAgentSwitched": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "metadata": { - "type": "object" - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "number" - } - }, - "required": ["created"], - "additionalProperties": false - }, - "type": { - "type": "string", - "enum": ["agent-switched"] - }, - "agent": { - "type": "string" - } - }, - "required": ["id", "time", "type", "agent"], - "additionalProperties": false - }, - "SessionMessageModelSwitched": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "metadata": { - "type": "object" - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "number" - } - }, - "required": ["created"], - "additionalProperties": false - }, - "type": { - "type": "string", - "enum": ["model-switched"] - }, - "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"], - "additionalProperties": false - } - }, - "required": ["id", "time", "type", "model"], - "additionalProperties": false - }, "SessionMessageSynthetic": { "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "metadata": { "type": "object" @@ -23124,7 +23481,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "metadata": { "type": "object" @@ -23392,7 +23750,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "metadata": { "type": "object" @@ -23521,7 +23880,8 @@ "type": "string" }, "id": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "metadata": { "type": "object" @@ -24428,11 +24788,15 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "agent": { "type": "string" } }, - "required": ["timestamp", "sessionID", "agent"], + "required": ["timestamp", "sessionID", "messageID", "agent"], "additionalProperties": false } }, @@ -24459,6 +24823,10 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "model": { "type": "object", "properties": { @@ -24476,7 +24844,7 @@ "additionalProperties": false } }, - "required": ["timestamp", "sessionID", "model"], + "required": ["timestamp", "sessionID", "messageID", "model"], "additionalProperties": false } }, @@ -24537,6 +24905,10 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "prompt": { "$ref": "#/components/schemas/Prompt" }, @@ -24545,7 +24917,84 @@ "enum": ["steer", "queue"] } }, - "required": ["timestamp", "sessionID", "prompt", "delivery"], + "required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextPromptAdmitted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.prompt.admitted"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "delivery": { + "type": "string", + "enum": ["steer", "queue"] + } + }, + "required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextPromptPromoted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.prompt.promoted"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "timeCreated": { + "type": "number" + } + }, + "required": ["timestamp", "sessionID", "messageID", "prompt", "timeCreated"], "additionalProperties": false } }, @@ -24572,11 +25021,15 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "text": { "type": "string" } }, - "required": ["timestamp", "sessionID", "text"], + "required": ["timestamp", "sessionID", "messageID", "text"], "additionalProperties": false } }, @@ -24603,6 +25056,10 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "callID": { "type": "string" }, @@ -24610,7 +25067,7 @@ "type": "string" } }, - "required": ["timestamp", "sessionID", "callID", "command"], + "required": ["timestamp", "sessionID", "messageID", "callID", "command"], "additionalProperties": false } }, @@ -24671,6 +25128,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "agent": { "type": "string" }, @@ -24694,7 +25155,7 @@ "type": "string" } }, - "required": ["timestamp", "sessionID", "agent", "model"], + "required": ["timestamp", "sessionID", "assistantMessageID", "agent", "model"], "additionalProperties": false } }, @@ -24722,7 +25183,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "finish": { "type": "string" @@ -24791,7 +25253,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "error": { "$ref": "#/components/schemas/SessionErrorUnknown" @@ -24824,11 +25287,15 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "textID": { "type": "string" } }, - "required": ["timestamp", "sessionID", "textID"], + "required": ["timestamp", "sessionID", "assistantMessageID", "textID"], "additionalProperties": false } }, @@ -24855,6 +25322,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "textID": { "type": "string" }, @@ -24862,7 +25333,7 @@ "type": "string" } }, - "required": ["timestamp", "sessionID", "textID", "delta"], + "required": ["timestamp", "sessionID", "assistantMessageID", "textID", "delta"], "additionalProperties": false } }, @@ -24889,6 +25360,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "textID": { "type": "string" }, @@ -24896,7 +25371,7 @@ "type": "string" } }, - "required": ["timestamp", "sessionID", "textID", "text"], + "required": ["timestamp", "sessionID", "assistantMessageID", "textID", "text"], "additionalProperties": false } }, @@ -24923,6 +25398,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "reasoningID": { "type": "string" }, @@ -24933,7 +25412,7 @@ } } }, - "required": ["timestamp", "sessionID", "reasoningID"], + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID"], "additionalProperties": false } }, @@ -24960,6 +25439,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "reasoningID": { "type": "string" }, @@ -24967,7 +25450,7 @@ "type": "string" } }, - "required": ["timestamp", "sessionID", "reasoningID", "delta"], + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "delta"], "additionalProperties": false } }, @@ -24994,6 +25477,10 @@ "type": "string", "pattern": "^ses" }, + "assistantMessageID": { + "type": "string", + "pattern": "^msg_" + }, "reasoningID": { "type": "string" }, @@ -25007,7 +25494,7 @@ } } }, - "required": ["timestamp", "sessionID", "reasoningID", "text"], + "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "text"], "additionalProperties": false } }, @@ -25035,7 +25522,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -25072,7 +25560,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -25109,7 +25598,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -25146,7 +25636,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -25202,7 +25693,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -25252,7 +25744,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -25319,7 +25812,8 @@ "pattern": "^ses" }, "assistantMessageID": { - "type": "string" + "type": "string", + "pattern": "^msg_" }, "callID": { "type": "string" @@ -25406,12 +25900,16 @@ "type": "string", "pattern": "^ses" }, + "messageID": { + "type": "string", + "pattern": "^msg_" + }, "reason": { "type": "string", "enum": ["auto", "manual"] } }, - "required": ["timestamp", "sessionID", "reason"], + "required": ["timestamp", "sessionID", "messageID", "reason"], "additionalProperties": false } }, diff --git a/packages/server/src/groups/v2/session.ts b/packages/server/src/groups/v2/session.ts index a830e4bf3c..83ae83750a 100644 --- a/packages/server/src/groups/v2/session.ts +++ b/packages/server/src/groups/v2/session.ts @@ -114,7 +114,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") delivery: SessionInput.Delivery.pipe(Schema.optional), resume: Schema.Boolean.pipe(Schema.optional), }), - success: Schema.Struct({ data: SessionMessage.User }), + success: Schema.Struct({ data: SessionInput.Admitted }), error: [ConflictError, SessionNotFoundError], }).annotateMerge( OpenApi.annotations({ diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 0daa32a290..33a619b222 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -4,6 +4,28 @@ Record V2 database, durable-event, projected-message, HTTP, and generated SDK sc This document covers meaningful contract changes introduced on the `feat/opencode-embedded-api` branch since its divergence from `origin/dev`. Mechanical file moves and internal refactors are omitted unless they changed stored data, replay behavior, public HTTP or SDK shapes, or model-facing tool contracts. +## 2026-06-04 Event-Sourced Session Input Cutover + +Affected schema: + +- `session_input`, `session_message`, `event`, `event_sequence`, and disposable workspace beta storage. +- New synchronized `session.next.prompt.admitted.1` and `session.next.prompt.promoted.1` events. +- Experimental `SessionV2.prompt(...)`, HTTP, and generated SDK admission receipt. + +Change: + +- Replace inbox-local admission sequence with event-sourced prompt admission and promotion sequences. +- Give projected Session messages stable `msg_*` resource IDs distinct from `evt_*` creator event IDs. +- Give every event that creates a projected transcript resource an explicit `msg_*` resource ID. Assistant steps propagate one `assistantMessageID` through assistant-owned events. +- Reset incompatible unreleased beta event history, derived Session projections, workspace rows, and Session workspace links. + +Compatibility: + +- The reset preserves canonical V1 `session`, `message`, and `part` rows. +- Existing synchronized workspaces are disposable beta state and are removed by the reset. +- Before starting the new build, discard adapter-managed external workspace resources created by unreleased builds. The SQL migration cannot remove external resources through runtime adapters, and rediscovering retained resources after startup can replay incompatible beta history. +- Exact prompt retries reconcile one stable `msg_*` identity when Session, prompt, and delivery mode match. + ## Earlier Branch History ### Replayable Session Event Refinement And Cursor Stream @@ -60,7 +82,7 @@ Affected schema: Change: -- Bind step settlement to an explicit `assistantMessageID`. +- Bind step settlement to an explicit assistant message ID. Reason: @@ -409,7 +431,7 @@ Affected schema: Change: - Preserve stable IDs on projected assistant text parts. -- Route durable tool projection updates through explicit owning `assistantMessageID` values rather than provider-local call IDs alone. +- Route durable tool projection updates through explicit owning assistant message IDs rather than provider-local call IDs alone. - Replay full-value text and tool-input end checkpoints while keeping fragment deltas ephemeral. Reason: diff --git a/specs/v2/session.md b/specs/v2/session.md index 20cd4393ab..56384e78bd 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -13,14 +13,14 @@ sessions.create({ id?, location, ... }) sessions.prompt({ id?, sessionID, prompt, delivery?, resume? }) -> omitted ID generates one internal message ID -> supplied ID admits one durable Session input when absent - -> exact reuse returns the same admitted user-shaped message + -> exact reuse returns the same admitted lifecycle receipt -> reusing one message ID for another Session, prompt, or delivery mode fails -> exact retry schedules another wake unless resume is false -> resume omitted or true schedules execution after admission -> resume false admits only ``` -`session_input` is the durable admission inbox. Admitted inputs remain outside model-visible Session history until the serialized runner promotes them by publishing ordinary `Prompted` events. The existing projector atomically writes the visible user message and marks its inbox row promoted in the same event transaction. +`session_input` is the durable admission inbox. Admitted inputs remain outside model-visible Session history until the serialized runner publishes `PromptLifecycle.Promoted`. The projector atomically writes the visible user message and marks its inbox row promoted in the same event transaction. The legacy V1-to-V2 shadow bridge continues publishing ordinary `Prompted` events for already-visible V1 prompts. Execution routing starts from only the Session ID: @@ -33,7 +33,7 @@ SessionExecution.resume(sessionID) `SessionExecution` and the read-side `SessionStore` are process-global. `SessionRunner`, catalog, model resolver, tool registry, permission state, and filesystem are cached per Location. No layer takes a Session ID. An omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics. -The local runner issues one explicit `llm.stream(request)` per provider turn, projects each complete local tool call durably before eagerly starting its structured child execution, awaits every started tool fiber after provider-stream closure, reloads projected history once before continuation, and fails after 25 provider turns within one local drain activity only when work remains. Tool settlement events carry the owning assistant-message ID because provider-local call IDs may repeat across turns. Before assembling a provider request, the runner durably fails any local tool still projected as `running` from a previous process with `Tool execution interrupted`; abandoned side effects are never silently replayed. +The local runner issues one explicit `llm.stream(request)` per provider turn, projects each complete local tool call durably before eagerly starting its structured child execution, awaits every started tool fiber after provider-stream closure, reloads projected history once before continuation, and fails after 25 provider turns within one local drain activity only when work remains. Tool settlement events carry the owning assistant message ID because provider-local call IDs may repeat across turns. Before assembling a provider request, the runner durably fails any local tool still projected as `running` from a previous process with `Tool execution interrupted`; abandoned side effects are never silently replayed. 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.