From d1ac31bd8efa57a7fc15f0a8f521e567d511ad2c Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Fri, 17 Apr 2026 11:09:48 +0100 Subject: [PATCH 1/7] feat(core): add session metadata support Persist session metadata in the session store and expose it across the session APIs and SDK so clients can round-trip metadata during create, get, update, list, and fork operations. Add a JSON metadata column to the session table, thread metadata through the session model/projectors, and normalize empty metadata responses to {}. Support full metadata replacement on session update, add copyMetadata to session forks with default copy behavior, regenerate the session SDK surface, and cover the behavior with server/session tests plus a migration for existing databases. --- .../migration.sql | 1 + .../snapshot.json | 1574 +++++++++++++++++ .../routes/instance/httpapi/groups/session.ts | 1 + .../instance/httpapi/handlers/session.ts | 9 +- packages/opencode/src/session/projectors.ts | 1 + packages/opencode/src/session/session.sql.ts | 1 + packages/opencode/src/session/session.ts | 32 +- .../test/server/session-actions.test.ts | 68 + .../opencode/test/server/session-list.test.ts | 15 + .../opencode/test/session/session.test.ts | 59 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 10 + packages/sdk/js/src/v2/gen/types.gen.ts | 16 + 12 files changed, 1784 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/migration/20260511173437_session-metadata/migration.sql create mode 100644 packages/opencode/migration/20260511173437_session-metadata/snapshot.json diff --git a/packages/opencode/migration/20260511173437_session-metadata/migration.sql b/packages/opencode/migration/20260511173437_session-metadata/migration.sql new file mode 100644 index 000000000000..0ce73631f0d7 --- /dev/null +++ b/packages/opencode/migration/20260511173437_session-metadata/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `session` ADD `metadata` text; \ No newline at end of file diff --git a/packages/opencode/migration/20260511173437_session-metadata/snapshot.json b/packages/opencode/migration/20260511173437_session-metadata/snapshot.json new file mode 100644 index 000000000000..3678ed0ac05d --- /dev/null +++ b/packages/opencode/migration/20260511173437_session-metadata/snapshot.json @@ -0,0 +1,1574 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "85d2086c-3c95-4706-90b0-f7480b73db5c", + "prevIds": [ + "fdfcccee-fb3a-481f-b801-b9835fa30d5d" + ], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "data_migration", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "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": "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": "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": "project_id", + "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": "data", + "entityType": "columns", + "table": "permission" + }, + { + "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": "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": "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" + }, + { + "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" + }, + { + "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": [ + "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": [ + "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": [ + "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": [ + "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": [ + "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": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "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": [ + "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": "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": [ + "project_id" + ], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "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": [ + "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": [ + { + "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 + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_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": [] +} \ No newline at end of file diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 2053aba3b4bd..46385802547f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -45,6 +45,7 @@ export const MessagesQuery = Schema.Struct({ export const StatusMap = Schema.Record(Schema.String, SessionStatus.Info) export const UpdatePayload = Schema.Struct({ title: Schema.optional(Schema.String), + metadata: Schema.optional(Schema.NullOr(Session.Info.fields.metadata)), permission: Schema.optional(Permission.Ruleset), time: Schema.optional( Schema.Struct({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 9230a6fe5713..7594dae42cc4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -173,6 +173,9 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", if (ctx.payload.title !== undefined) { yield* session.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title }) } + if ("metadata" in ctx.payload) { + yield* session.setMetadata({ sessionID: ctx.params.sessionID, metadata: ctx.payload.metadata ?? {} }) + } if (ctx.payload.permission !== undefined) { yield* session.setPermission({ sessionID: ctx.params.sessionID, @@ -190,7 +193,11 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", payload?: typeof ForkPayload.Type }) { return yield* SessionError.mapStorageNotFound( - session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload?.messageID }), + session.fork({ + sessionID: ctx.params.sessionID, + messageID: ctx.payload?.messageID, + copyMetadata: ctx.payload?.copyMetadata, + }), ) }) diff --git a/packages/opencode/src/session/projectors.ts b/packages/opencode/src/session/projectors.ts index 93acd4546db2..12ed04f76e26 100644 --- a/packages/opencode/src/session/projectors.ts +++ b/packages/opencode/src/session/projectors.ts @@ -54,6 +54,7 @@ export function toPartialRow(info: DeepPartial) { summary_deletions: grab(info, "summary", (v) => grab(v, "deletions")), summary_files: grab(info, "summary", (v) => grab(v, "files")), summary_diffs: grab(info, "summary", (v) => grab(v, "diffs")), + metadata: grab(info, "metadata"), revert: grab(info, "revert"), permission: grab(info, "permission"), time_created: grab(info, "time", (v) => grab(v, "created")), diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 421fa68694d2..490604bc1ab1 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -33,6 +33,7 @@ export const SessionTable = sqliteTable( summary_deletions: integer(), summary_files: integer(), summary_diffs: text({ mode: "json" }).$type(), + metadata: text({ mode: "json" }).$type>(), revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(), permission: text({ mode: "json" }).$type(), agent: text(), diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index f50f8750b32b..42aa69ecc9b1 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -89,6 +89,7 @@ export function fromRow(row: SessionRow): Info { version: row.version, summary, share, + metadata: row.metadata ?? {}, revert, permission: row.permission ?? undefined, time: { @@ -118,6 +119,7 @@ export function toRow(info: Info) { summary_deletions: info.summary?.deletions, summary_files: info.summary?.files, summary_diffs: info.summary?.diffs, + metadata: info.metadata, revert: info.revert ?? null, permission: info.permission, time_created: info.time.created, @@ -176,6 +178,8 @@ const Model = Schema.Struct({ variant: optionalOmitUndefined(Schema.String), }) +const Metadata = Schema.Record(Schema.String, Schema.Any) + export const Info = Schema.Struct({ id: SessionID, slug: Schema.String, @@ -190,6 +194,7 @@ export const Info = Schema.Struct({ agent: optionalOmitUndefined(Schema.String), model: optionalOmitUndefined(Model), version: Schema.String, + metadata: Metadata, time: Time, permission: optionalOmitUndefined(Permission.Ruleset), revert: optionalOmitUndefined(Revert), @@ -221,6 +226,7 @@ export const CreateInput = Schema.optional( title: Schema.optional(Schema.String), agent: Schema.optional(Schema.String), model: Schema.optional(Model), + metadata: Schema.optional(Metadata), permission: Schema.optional(Permission.Ruleset), workspaceID: Schema.optional(WorkspaceID), }), @@ -230,6 +236,7 @@ export type CreateInput = Types.DeepMutable ({ zod: zod(s) }))) export const GetInput = SessionID export const ChildrenInput = SessionID @@ -241,6 +248,10 @@ export const SetArchivedInput = Schema.Struct({ sessionID: SessionID, time: Schema.optional(ArchivedTimestamp), }).pipe(withStatics((s) => ({ zod: zod(s) }))) +export const SetMetadataInput = Schema.Struct({ + sessionID: SessionID, + metadata: Schema.NullOr(Metadata), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) export const SetPermissionInput = Schema.Struct({ sessionID: SessionID, permission: Permission.Ruleset, @@ -295,6 +306,7 @@ const UpdatedInfo = Schema.Struct({ agent: Schema.optional(Schema.NullOr(Schema.String)), model: Schema.optional(Schema.NullOr(Model)), version: Schema.optional(Schema.NullOr(Schema.String)), + metadata: Schema.optional(Schema.NullOr(Metadata)), time: Schema.optional(UpdatedTime), permission: Schema.optional(Schema.NullOr(Permission.Ruleset)), revert: Schema.optional(Schema.NullOr(Revert)), @@ -430,14 +442,16 @@ export interface Interface { title?: string agent?: string model?: Schema.Schema.Type + metadata?: Record permission?: Permission.Ruleset workspaceID?: WorkspaceID }) => Effect.Effect - readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect + readonly fork: (input: { sessionID: SessionID; messageID?: MessageID; copyMetadata?: boolean }) => Effect.Effect readonly touch: (sessionID: SessionID) => Effect.Effect readonly get: (id: SessionID) => Effect.Effect readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect + readonly setMetadata: (input: typeof SetMetadataInput.Type) => Effect.Effect readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect readonly setRevert: (input: { sessionID: SessionID @@ -496,6 +510,7 @@ export const layer: Layer.Layer permission?: Permission.Ruleset }) { const ctx = yield* InstanceState.context @@ -511,6 +526,7 @@ export const layer: Layer.Layer + metadata?: Record permission?: Permission.Ruleset workspaceID?: WorkspaceID }) { @@ -635,12 +652,17 @@ export const layer: Layer.Layer() @@ -696,6 +719,10 @@ export const layer: Layer.Layer { }) describe("session action routes", () => { + test("session routes expose metadata on create, update, get, and fork", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const app = Server.Default().app + + const created = await app.request("/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: "meta-session", + metadata: { source: "sdk", trace: { id: "abc" } }, + }), + }) + expect(created.status).toBe(200) + + const session = (await created.json()) as SessionNs.Info + expect(session.metadata).toEqual({ source: "sdk", trace: { id: "abc" } }) + + const updated = await app.request(`/session/${session.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ metadata: { source: "sdk", trace: { id: "def" }, tags: ["one"] } }), + }) + expect(updated.status).toBe(200) + + const next = (await updated.json()) as SessionNs.Info + expect(next.metadata).toEqual({ source: "sdk", trace: { id: "def" }, tags: ["one"] }) + + const fetched = await app.request(`/session/${session.id}`) + expect(fetched.status).toBe(200) + expect(((await fetched.json()) as SessionNs.Info).metadata).toEqual(next.metadata) + + const forked = await app.request(`/session/${session.id}/fork`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }) + expect(forked.status).toBe(200) + + const fork = (await forked.json()) as SessionNs.Info + expect(fork.metadata).toEqual(next.metadata) + + const blanked = await app.request(`/session/${session.id}/fork`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ copyMetadata: false }), + }) + expect(blanked.status).toBe(200) + const empty = (await blanked.json()) as SessionNs.Info + expect(empty.metadata).toEqual({}) + + const cleared = await app.request(`/session/${session.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ metadata: null }), + }) + expect(cleared.status).toBe(200) + expect(((await cleared.json()) as SessionNs.Info).metadata).toEqual({}) + + await svc.remove(fork.id) + await svc.remove(empty.id) + await svc.remove(session.id) + }, + }) + }) + test("abort route returns success", async () => { await using tmp = await tmpdir({ git: true }) await WithInstance.provide({ diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 20478dde844e..64396752ee8f 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -236,4 +236,19 @@ describe("session.list", () => { }, }) }) + + test("includes metadata in listed sessions", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const meta = { source: "sdk", trace: { id: "abc" } } + const session = await svc.create({ title: "meta-session", metadata: meta }) + + const listed = [...svc.list({ search: "meta-session" })].find((item) => item.id === session.id) + + expect(listed?.metadata).toEqual(meta) + }, + }) + }) }) diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index bb69e459bc05..288daec7241f 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -183,4 +183,63 @@ describe("Session", () => { expect(missing).toBe(true) }) + + test("persists metadata and copies it on fork by default", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const meta = { source: "sdk", trace: { id: "abc" } } + const session = await create({ title: "with-meta", metadata: meta }) + const saved = await get(session.id) + const fork = await AppRuntime.runPromise( + SessionNs.Service.use((svc) => svc.fork({ sessionID: session.id, copyMetadata: true })), + ) + + expect(saved.metadata).toEqual(meta) + expect(fork.metadata).toEqual(meta) + expect(fork.metadata).not.toBe(meta) + + await remove(fork.id) + await remove(session.id) + }, + }) + }) + + test("can fork without copying metadata", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await create({ metadata: { source: "sdk" } }) + const fork = await AppRuntime.runPromise( + SessionNs.Service.use((svc) => svc.fork({ sessionID: session.id, copyMetadata: false })), + ) + + expect(fork.metadata).toEqual({}) + + await remove(fork.id) + await remove(session.id) + }, + }) + }) + + test("defaults metadata to an empty object", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await create({ title: "empty-meta" }) + const saved = await get(session.id) + + expect(session.metadata).toEqual({}) + expect(saved.metadata).toEqual({}) + + await remove(session.id) + }, + }) + }) }) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index bf3201a5c081..7820cea3a673 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -3006,6 +3006,9 @@ export class Session2 extends HeyApiClient { providerID: string variant?: string } + metadata?: { + [key: string]: unknown + } permission?: PermissionRuleset workspaceID?: string }, @@ -3022,6 +3025,7 @@ export class Session2 extends HeyApiClient { { in: "body", key: "title" }, { in: "body", key: "agent" }, { in: "body", key: "model" }, + { in: "body", key: "metadata" }, { in: "body", key: "permission" }, { in: "body", key: "workspaceID" }, ], @@ -3145,6 +3149,9 @@ export class Session2 extends HeyApiClient { directory?: string workspace?: string title?: string + metadata?: { + [key: string]: unknown + } permission?: PermissionRuleset time?: { archived?: number @@ -3161,6 +3168,7 @@ export class Session2 extends HeyApiClient { { in: "query", key: "directory" }, { in: "query", key: "workspace" }, { in: "body", key: "title" }, + { in: "body", key: "metadata" }, { in: "body", key: "permission" }, { in: "body", key: "time" }, ], @@ -3456,6 +3464,7 @@ export class Session2 extends HeyApiClient { directory?: string workspace?: string messageID?: string + copyMetadata?: boolean }, options?: Options, ) { @@ -3468,6 +3477,7 @@ export class Session2 extends HeyApiClient { { in: "query", key: "directory" }, { in: "query", key: "workspace" }, { in: "body", key: "messageID" }, + { in: "body", key: "copyMetadata" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index da80645ad717..d70395f9d868 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -752,6 +752,9 @@ export type Session = { variant?: string } version: string + metadata: { + [key: string]: unknown + } time: { created: number updated: number @@ -1441,6 +1444,9 @@ export type GlobalSession = { variant?: string } version: string + metadata: { + [key: string]: unknown + } time: { created: number updated: number @@ -1904,6 +1910,9 @@ export type SyncEventSessionUpdated = { variant?: string } | null version?: string | null + metadata?: { + [key: string]: unknown + } | null time?: { created?: number | null updated?: number | null @@ -5168,6 +5177,9 @@ export type SessionCreateData = { providerID: string variant?: string } + metadata?: { + [key: string]: unknown + } permission?: PermissionRuleset workspaceID?: string } @@ -5298,6 +5310,9 @@ export type SessionGetResponse = SessionGetResponses[keyof SessionGetResponses] export type SessionUpdateData = { body?: { title?: string + metadata?: { + [key: string]: unknown + } permission?: PermissionRuleset time?: { archived?: number @@ -5592,6 +5607,7 @@ export type SessionMessageResponse = SessionMessageResponses[keyof SessionMessag export type SessionForkData = { body?: { messageID?: string + copyMetadata?: boolean } path: { sessionID: string From c3351cb1bd21ad62c1b8180da37a261e5e3c8094 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Mon, 11 May 2026 19:09:18 +0100 Subject: [PATCH 2/7] test(core): align session metadata tests with latest dev --- packages/opencode/test/cli/run/stream.transport.test.ts | 1 + packages/opencode/test/server/session-actions.test.ts | 3 +-- packages/opencode/test/server/session-list.test.ts | 5 ++--- packages/opencode/test/session/schema-decoding.test.ts | 8 +++++++- packages/opencode/test/session/session-schema.test.ts | 1 + packages/opencode/test/session/session.test.ts | 6 +++--- 6 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/opencode/test/cli/run/stream.transport.test.ts b/packages/opencode/test/cli/run/stream.transport.test.ts index 3358ae774d0f..f1fb9a6564b3 100644 --- a/packages/opencode/test/cli/run/stream.transport.test.ts +++ b/packages/opencode/test/cli/run/stream.transport.test.ts @@ -312,6 +312,7 @@ function child(id: string): SessionChild { directory: "/tmp", title: id, version: "1", + metadata: {}, time: { created: 1, updated: 1, diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index 747408512271..458c4a2e29b5 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, mock, test } from "bun:test" import { Effect } from "effect" -import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { Session as SessionNs } from "@/session/session" @@ -32,7 +31,7 @@ afterEach(async () => { describe("session action routes", () => { test("session routes expose metadata on create, update, get, and fork", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const app = Server.Default().app diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 64396752ee8f..2138e270aeae 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" -import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { Session as SessionNs } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" @@ -239,13 +238,13 @@ describe("session.list", () => { test("includes metadata in listed sessions", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const meta = { source: "sdk", trace: { id: "abc" } } const session = await svc.create({ title: "meta-session", metadata: meta }) - const listed = [...svc.list({ search: "meta-session" })].find((item) => item.id === session.id) + const listed = (await svc.list({ search: "meta-session" })).find((item) => item.id === session.id) expect(listed?.metadata).toEqual(meta) }, diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts index 67c438a38649..b43be74459ba 100644 --- a/packages/opencode/test/session/schema-decoding.test.ts +++ b/packages/opencode/test/session/schema-decoding.test.ts @@ -46,6 +46,7 @@ describe("Session.Info", () => { directory: "/tmp/proj", title: "First session", version: "0.1.0", + metadata: {}, time: { created: 1, updated: 2 }, } expect(decode(input)).toEqual(input) @@ -70,6 +71,7 @@ describe("Session.Info", () => { share: { url: "https://share.example.com/s/1" }, title: "Full session", version: "1.0.0", + metadata: { source: "test" }, time: { created: 100, updated: 200, compacting: 150, archived: 300 }, permission: [{ action: "allow" as const, pattern: "*", permission: "read" }], revert: { @@ -91,6 +93,7 @@ describe("Session.Info", () => { directory: "/tmp/proj", title: "Legacy diff", version: "0.1.0", + metadata: {}, summary: { additions: 1, deletions: 0, @@ -140,6 +143,7 @@ describe("Session.GlobalInfo", () => { directory: "/tmp/proj", title: "global", version: "0", + metadata: {}, time: { created: 0, updated: 0 }, project: null, } @@ -155,6 +159,7 @@ describe("Session.GlobalInfo", () => { directory: "/tmp/proj", title: "global", version: "0", + metadata: {}, time: { created: 0, updated: 0 }, project: { id: projectID, worktree: "/tmp/wt", name: "alpha" }, } @@ -172,6 +177,7 @@ describe("Session input schemas", () => { const populated = { parentID: sessionID, title: "child", + metadata: { source: "test" }, permission: [{ action: "ask" as const, pattern: "*", permission: "bash" }], workspaceID, } @@ -181,7 +187,7 @@ describe("Session input schemas", () => { test("ForkInput round-trips", () => { const decode = decodeUnknown(Session.ForkInput) - const input = { sessionID, messageID } + const input = { sessionID, messageID, copyMetadata: false } expect(decode(input)).toEqual(input) expect(Session.ForkInput.zod.parse(input)).toEqual(input) // messageID is optional diff --git a/packages/opencode/test/session/session-schema.test.ts b/packages/opencode/test/session/session-schema.test.ts index 38531d15b49a..5178bfbcaaf5 100644 --- a/packages/opencode/test/session/session-schema.test.ts +++ b/packages/opencode/test/session/session-schema.test.ts @@ -15,6 +15,7 @@ const info = { share: undefined, title: "Test session", version: "1.0.0", + metadata: {}, time: { created: 1, updated: 2, diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 288daec7241f..391e12c8340f 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -187,7 +187,7 @@ describe("Session", () => { test("persists metadata and copies it on fork by default", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const meta = { source: "sdk", trace: { id: "abc" } } @@ -210,7 +210,7 @@ describe("Session", () => { test("can fork without copying metadata", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await create({ metadata: { source: "sdk" } }) @@ -229,7 +229,7 @@ describe("Session", () => { test("defaults metadata to an empty object", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await create({ title: "empty-meta" }) From 2c9c3ac3f67db849364cf2bbc3f96e55bde853c6 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 14 May 2026 22:08:04 +0100 Subject: [PATCH 3/7] fix(session): address metadata review feedback --- packages/opencode/src/cli/cmd/import.ts | 1 + .../routes/instance/httpapi/groups/session.ts | 2 +- .../routes/instance/httpapi/handlers/session.ts | 1 - packages/opencode/src/session/session.sql.ts | 2 +- packages/opencode/src/session/session.ts | 17 ++++++----------- .../test/cli/cmd/tui/notifications.test.ts | 1 + .../test/server/session-actions.test.ts | 14 -------------- .../test/session/schema-decoding.test.ts | 2 +- packages/opencode/test/session/session.test.ts | 16 ---------------- packages/sdk/js/src/v2/gen/sdk.gen.ts | 4 +--- packages/sdk/js/src/v2/gen/types.gen.ts | 3 +-- 11 files changed, 13 insertions(+), 50 deletions(-) diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index 2fcf286f4670..9e6e91d4f20d 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -168,6 +168,7 @@ const runImport = Effect.fn("Cli.import.body")(function* (file: string, projectI const info = Schema.decodeUnknownSync(Session.Info)({ ...exportData.info, + metadata: exportData.info.metadata ?? {}, projectID, }) as Session.Info const row = Session.toRow(info) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 63b994e7b15b..2903c238df59 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -45,7 +45,7 @@ export const MessagesQuery = Schema.Struct({ export const StatusMap = Schema.Record(Schema.String, SessionStatus.Info) export const UpdatePayload = Schema.Struct({ title: Schema.optional(Schema.String), - metadata: Schema.optional(Schema.NullOr(Session.Info.fields.metadata)), + metadata: Schema.optional(Schema.Union([Session.Info.fields.metadata, Schema.Null])), permission: Schema.optional(Permission.Ruleset), time: Schema.optional( Schema.Struct({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index c8df528770f5..acf0eb2d86b5 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -201,7 +201,6 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload?.messageID, - copyMetadata: ctx.payload?.copyMetadata, }), ) }) diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index b689ef200ee7..47285b34ca20 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -33,7 +33,7 @@ export const SessionTable = sqliteTable( summary_deletions: integer(), summary_files: integer(), summary_diffs: text({ mode: "json" }).$type(), - metadata: text({ mode: "json" }).$type>(), + metadata: text({ mode: "json" }).$type>(), cost: real().notNull().default(0), tokens_input: integer().notNull().default(0), tokens_output: integer().notNull().default(0), diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 14ec080cf9c6..57cc58d274b8 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -260,7 +260,6 @@ export type CreateInput = Types.DeepMutable - metadata?: Record + metadata?: typeof Metadata.Type permission?: Permission.Ruleset workspaceID?: WorkspaceID }) => Effect.Effect - readonly fork: (input: { sessionID: SessionID; messageID?: MessageID; copyMetadata?: boolean }) => Effect.Effect + readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect readonly touch: (sessionID: SessionID) => Effect.Effect readonly get: (id: SessionID) => Effect.Effect readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect @@ -542,7 +541,7 @@ export const layer: Layer.Layer< workspaceID?: WorkspaceID directory: string path?: string - metadata?: Record + metadata?: typeof Metadata.Type permission?: Permission.Ruleset }) { const ctx = yield* InstanceState.context @@ -674,7 +673,7 @@ export const layer: Layer.Layer< title?: string agent?: string model?: Schema.Schema.Type - metadata?: Record + metadata?: typeof Metadata.Type permission?: Permission.Ruleset workspaceID?: WorkspaceID }) { @@ -693,11 +692,7 @@ export const layer: Layer.Layer< }) }) - const fork = Effect.fn("Session.fork")(function* (input: { - sessionID: SessionID - messageID?: MessageID - copyMetadata?: boolean - }) { + const fork = Effect.fn("Session.fork")(function* (input: { sessionID: SessionID; messageID?: MessageID }) { const ctx = yield* InstanceState.context const original = yield* get(input.sessionID) const title = getForkedTitle(original.title) @@ -706,7 +701,7 @@ export const layer: Layer.Layer< path: sessionPath(ctx.worktree, ctx.directory), workspaceID: original.workspaceID, title, - metadata: input.copyMetadata === false ? {} : structuredClone(original.metadata), + metadata: structuredClone(original.metadata), }) const msgs = yield* messages({ sessionID: input.sessionID }) const idMap = new Map() diff --git a/packages/opencode/test/cli/cmd/tui/notifications.test.ts b/packages/opencode/test/cli/cmd/tui/notifications.test.ts index 17ed54bafd03..aa676f770205 100644 --- a/packages/opencode/test/cli/cmd/tui/notifications.test.ts +++ b/packages/opencode/test/cli/cmd/tui/notifications.test.ts @@ -15,6 +15,7 @@ async function setup() { directory: "/workspace", ...(parentID && { parentID }), version: "0.0.0-test", + metadata: {}, time: { created: 0, updated: 0 }, }) const sessions: Record = { diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index 6819ef4d1db2..2c859d7bdf91 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -75,19 +75,6 @@ describe("session action routes", () => { const fork = (yield* Effect.promise(() => forked.json())) as SessionNs.Info expect(fork.metadata).toEqual(next.metadata) - const blanked = yield* Effect.promise(() => - Promise.resolve( - app.request(`/session/${session.id}/fork`, { - method: "POST", - headers, - body: JSON.stringify({ copyMetadata: false }), - }), - ), - ) - expect(blanked.status).toBe(200) - const empty = (yield* Effect.promise(() => blanked.json())) as SessionNs.Info - expect(empty.metadata).toEqual({}) - const cleared = yield* Effect.promise(() => Promise.resolve( app.request(`/session/${session.id}`, { @@ -101,7 +88,6 @@ describe("session action routes", () => { expect(((yield* Effect.promise(() => cleared.json())) as SessionNs.Info).metadata).toEqual({}) yield* SessionNs.Service.use((svc) => svc.remove(fork.id).pipe(Effect.ignore)) - yield* SessionNs.Service.use((svc) => svc.remove(empty.id).pipe(Effect.ignore)) yield* SessionNs.Service.use((svc) => svc.remove(session.id).pipe(Effect.ignore)) }), { git: true }, diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts index 468667246a9b..0910f99a454c 100644 --- a/packages/opencode/test/session/schema-decoding.test.ts +++ b/packages/opencode/test/session/schema-decoding.test.ts @@ -171,7 +171,7 @@ describe("Session input schemas", () => { test("ForkInput round-trips", () => { const decode = decodeUnknown(Session.ForkInput) - const input = { sessionID, messageID, copyMetadata: false } + const input = { sessionID, messageID } expect(decode(input)).toEqual(input) // messageID is optional const bare = { sessionID } diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 192b859e7aeb..72657773ebff 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -205,22 +205,6 @@ describe("Session", () => { }), ) - it.instance("can fork without copying metadata", () => - Effect.gen(function* () { - const session = yield* SessionNs.Service - const created = yield* Effect.acquireRelease( - session.create({ metadata: { source: "sdk" } }), - (info) => session.remove(info.id).pipe(Effect.ignore), - ) - const fork = yield* Effect.acquireRelease( - session.fork({ sessionID: created.id, copyMetadata: false }), - (info) => session.remove(info.id).pipe(Effect.ignore), - ) - - expect(fork.metadata).toEqual({}) - }), - ) - it.instance("defaults metadata to an empty object", () => Effect.gen(function* () { const session = yield* SessionNs.Service diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index f8d5fd76bcb0..7da297ed52d7 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -3156,7 +3156,7 @@ export class Session2 extends HeyApiClient { title?: string metadata?: { [key: string]: unknown - } + } | null permission?: PermissionRuleset time?: { archived?: number @@ -3469,7 +3469,6 @@ export class Session2 extends HeyApiClient { directory?: string workspace?: string messageID?: string - copyMetadata?: boolean }, options?: Options, ) { @@ -3482,7 +3481,6 @@ export class Session2 extends HeyApiClient { { in: "query", key: "directory" }, { in: "query", key: "workspace" }, { in: "body", key: "messageID" }, - { in: "body", key: "copyMetadata" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index fc6098b1e061..80967bf6c6b4 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5566,7 +5566,7 @@ export type SessionUpdateData = { title?: string metadata?: { [key: string]: unknown - } + } | null permission?: PermissionRuleset time?: { archived?: number @@ -5861,7 +5861,6 @@ export type SessionMessageResponse = SessionMessageResponses[keyof SessionMessag export type SessionForkData = { body?: { messageID?: string - copyMetadata?: boolean } path: { sessionID: string From 0e7307590601d7219e07cd4d2e78324fc7f865b3 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 29 May 2026 23:55:37 -0500 Subject: [PATCH 4/7] fix(core): make session metadata optional Match the optional treatment of every comparable schema field (Session.Info.permission, CreateInput, message-v2 parts). metadata is now optionalOmitUndefined and omitted when unset/cleared rather than materialized as {}. Lets unrelated session fixtures drop their forced metadata: {} entries. --- packages/opencode/src/cli/cmd/import.ts | 1 - .../routes/instance/httpapi/groups/session.ts | 2 +- .../instance/httpapi/handlers/session.ts | 2 +- packages/opencode/src/session/session.ts | 10 ++++----- .../test/cli/cmd/tui/notifications.test.ts | 1 - .../test/cli/run/stream.transport.test.ts | 1 - .../test/server/session-actions.test.ts | 6 ++++-- .../test/session/schema-decoding.test.ts | 4 ---- .../test/session/session-schema.test.ts | 1 - .../opencode/test/session/session.test.ts | 21 ++++++++----------- 10 files changed, 20 insertions(+), 29 deletions(-) diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index 77dd6565590d..569aa309a461 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -170,7 +170,6 @@ const runImport = Effect.fn("Cli.import.body")(function* (file: string, ctx: Ins const info = Schema.decodeUnknownSync(Session.Info)({ ...exportData.info, - metadata: exportData.info.metadata ?? {}, projectID: ctx.project.id, directory: ctx.directory, path: path.relative(path.resolve(ctx.worktree), ctx.directory).replaceAll("\\", "/"), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index da55c3819b95..a6f777c4d589 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -45,7 +45,7 @@ export const MessagesQuery = Schema.Struct({ export const StatusMap = Schema.Record(Schema.String, SessionStatus.Info) export const UpdatePayload = Schema.Struct({ title: Schema.optional(Schema.String), - metadata: Schema.optional(Schema.Union([Session.Info.fields.metadata, Schema.Null])), + metadata: Schema.optional(Schema.NullOr(Session.Metadata)), permission: Schema.optional(Permission.Ruleset), time: Schema.optional( Schema.Struct({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 422de781517e..fe29b32959fd 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -186,7 +186,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", yield* session.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title }) } if ("metadata" in ctx.payload) { - yield* session.setMetadata({ sessionID: ctx.params.sessionID, metadata: ctx.payload.metadata ?? {} }) + yield* session.setMetadata({ sessionID: ctx.params.sessionID, metadata: ctx.payload.metadata ?? null }) } if (ctx.payload.permission !== undefined) { yield* session.setPermission({ diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index d89651f0a28d..db47a9ba4d37 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -100,7 +100,7 @@ export function fromRow(row: SessionRow): Info { }, }, share, - metadata: row.metadata ?? {}, + metadata: row.metadata ?? undefined, revert, permission: row.permission ? [...row.permission] : undefined, time: { @@ -207,7 +207,7 @@ const Model = Schema.Struct({ variant: optionalOmitUndefined(Schema.String), }) -const Metadata = Schema.Record(Schema.String, Schema.Any) +export const Metadata = Schema.Record(Schema.String, Schema.Any) export const Info = Schema.Struct({ id: SessionID, @@ -225,7 +225,7 @@ export const Info = Schema.Struct({ agent: optionalOmitUndefined(Schema.String), model: optionalOmitUndefined(Model), version: Schema.String, - metadata: Metadata, + metadata: optionalOmitUndefined(Metadata), time: Time, permission: optionalOmitUndefined(Permission.Ruleset), revert: optionalOmitUndefined(Revert), @@ -558,7 +558,7 @@ export const layer: Layer.Layer< title: input.title ?? createDefaultTitle(!!input.parentID), agent: input.agent, model: input.model, - metadata: input.metadata ?? {}, + metadata: input.metadata, permission: input.permission ? [...input.permission] : undefined, cost: 0, tokens: EmptyTokens, @@ -751,7 +751,7 @@ export const layer: Layer.Layer< }) const setMetadata = Effect.fn("Session.setMetadata")(function* (input: typeof SetMetadataInput.Type) { - yield* patch(input.sessionID, { metadata: input.metadata ?? {}, time: { updated: Date.now() } }) + yield* patch(input.sessionID, { metadata: input.metadata, time: { updated: Date.now() } }) }) const setPermission = Effect.fn("Session.setPermission")(function* (input: { diff --git a/packages/opencode/test/cli/cmd/tui/notifications.test.ts b/packages/opencode/test/cli/cmd/tui/notifications.test.ts index aa676f770205..17ed54bafd03 100644 --- a/packages/opencode/test/cli/cmd/tui/notifications.test.ts +++ b/packages/opencode/test/cli/cmd/tui/notifications.test.ts @@ -15,7 +15,6 @@ async function setup() { directory: "/workspace", ...(parentID && { parentID }), version: "0.0.0-test", - metadata: {}, time: { created: 0, updated: 0 }, }) const sessions: Record = { diff --git a/packages/opencode/test/cli/run/stream.transport.test.ts b/packages/opencode/test/cli/run/stream.transport.test.ts index 2717f8a94e1d..d1b145db24b2 100644 --- a/packages/opencode/test/cli/run/stream.transport.test.ts +++ b/packages/opencode/test/cli/run/stream.transport.test.ts @@ -332,7 +332,6 @@ function child(id: string): SessionChild { directory: "/tmp", title: id, version: "1", - metadata: {}, time: { created: 1, updated: 1, diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index a40473685e70..5a85cf75c49f 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -56,7 +56,9 @@ describe("session action routes", () => { expect(next.metadata).toEqual({ source: "sdk", trace: { id: "def" }, tags: ["one"] }) const fetched = yield* Effect.promise(() => - Promise.resolve(app.request(`/session/${session.id}`, { headers: { "x-opencode-directory": test.directory } })), + Promise.resolve( + app.request(`/session/${session.id}`, { headers: { "x-opencode-directory": test.directory } }), + ), ) expect(fetched.status).toBe(200) expect(((yield* Effect.promise(() => fetched.json())) as SessionNs.Info).metadata).toEqual(next.metadata) @@ -85,7 +87,7 @@ describe("session action routes", () => { ), ) expect(cleared.status).toBe(200) - expect(((yield* Effect.promise(() => cleared.json())) as SessionNs.Info).metadata).toEqual({}) + expect(((yield* Effect.promise(() => cleared.json())) as SessionNs.Info).metadata).toBeUndefined() yield* SessionNs.Service.use((svc) => svc.remove(fork.id).pipe(Effect.ignore)) yield* SessionNs.Service.use((svc) => svc.remove(session.id).pipe(Effect.ignore)) diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts index 0910f99a454c..4fcb323fe710 100644 --- a/packages/opencode/test/session/schema-decoding.test.ts +++ b/packages/opencode/test/session/schema-decoding.test.ts @@ -41,7 +41,6 @@ describe("Session.Info", () => { directory: "/tmp/proj", title: "First session", version: "0.1.0", - metadata: {}, time: { created: 1, updated: 2 }, } expect(decode(input)).toEqual(input) @@ -86,7 +85,6 @@ describe("Session.Info", () => { directory: "/tmp/proj", title: "Legacy diff", version: "0.1.0", - metadata: {}, summary: { additions: 1, deletions: 0, @@ -131,7 +129,6 @@ describe("Session.GlobalInfo", () => { directory: "/tmp/proj", title: "global", version: "0", - metadata: {}, time: { created: 0, updated: 0 }, project: null, } @@ -146,7 +143,6 @@ describe("Session.GlobalInfo", () => { directory: "/tmp/proj", title: "global", version: "0", - metadata: {}, time: { created: 0, updated: 0 }, project: { id: projectID, worktree: "/tmp/wt", name: "alpha" }, } diff --git a/packages/opencode/test/session/session-schema.test.ts b/packages/opencode/test/session/session-schema.test.ts index edeb8b3ba41a..906414fdbe52 100644 --- a/packages/opencode/test/session/session-schema.test.ts +++ b/packages/opencode/test/session/session-schema.test.ts @@ -17,7 +17,6 @@ const info = { share: undefined, title: "Test session", version: "1.0.0", - metadata: {}, time: { created: 1, updated: 2, diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index b90f3907e778..2b958cb682bd 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -189,14 +189,12 @@ describe("Session", () => { Effect.gen(function* () { const session = yield* SessionNs.Service const meta = { source: "sdk", trace: { id: "abc" } } - const created = yield* Effect.acquireRelease( - session.create({ title: "with-meta", metadata: meta }), - (info) => session.remove(info.id).pipe(Effect.ignore), + const created = yield* Effect.acquireRelease(session.create({ title: "with-meta", metadata: meta }), (info) => + session.remove(info.id).pipe(Effect.ignore), ) const saved = yield* session.get(created.id) - const fork = yield* Effect.acquireRelease( - session.fork({ sessionID: created.id }), - (info) => session.remove(info.id).pipe(Effect.ignore), + const fork = yield* Effect.acquireRelease(session.fork({ sessionID: created.id }), (info) => + session.remove(info.id).pipe(Effect.ignore), ) expect(saved.metadata).toEqual(meta) @@ -205,17 +203,16 @@ describe("Session", () => { }), ) - it.instance("defaults metadata to an empty object", () => + it.instance("omits metadata when not provided", () => Effect.gen(function* () { const session = yield* SessionNs.Service - const created = yield* Effect.acquireRelease( - session.create({ title: "empty-meta" }), - (info) => session.remove(info.id).pipe(Effect.ignore), + const created = yield* Effect.acquireRelease(session.create({ title: "empty-meta" }), (info) => + session.remove(info.id).pipe(Effect.ignore), ) const saved = yield* session.get(created.id) - expect(created.metadata).toEqual({}) - expect(saved.metadata).toEqual({}) + expect(created.metadata).toBeUndefined() + expect(saved.metadata).toBeUndefined() }), ) }) From 3f9f6a6e0546feebca1dedff806e6cb29e4e151e Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 29 May 2026 23:55:43 -0500 Subject: [PATCH 5/7] chore: generate --- .../snapshot.json | 148 +++++------------- packages/sdk/js/src/v2/gen/sdk.gen.ts | 2 +- packages/sdk/js/src/v2/gen/types.gen.ts | 6 +- packages/sdk/openapi.json | 22 +++ 4 files changed, 63 insertions(+), 115 deletions(-) diff --git a/packages/opencode/migration/20260511173437_session-metadata/snapshot.json b/packages/opencode/migration/20260511173437_session-metadata/snapshot.json index 3678ed0ac05d..07d9ec016a8c 100644 --- a/packages/opencode/migration/20260511173437_session-metadata/snapshot.json +++ b/packages/opencode/migration/20260511173437_session-metadata/snapshot.json @@ -2,9 +2,7 @@ "version": "7", "dialect": "sqlite", "id": "85d2086c-3c95-4706-90b0-f7480b73db5c", - "prevIds": [ - "fdfcccee-fb3a-481f-b801-b9835fa30d5d" - ], + "prevIds": ["fdfcccee-fb3a-481f-b801-b9835fa30d5d"], "ddl": [ { "name": "account_state", @@ -1127,13 +1125,9 @@ "table": "event" }, { - "columns": [ - "active_account_id" - ], + "columns": ["active_account_id"], "tableTo": "account", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "SET NULL", "nameExplicit": false, @@ -1142,13 +1136,9 @@ "table": "account_state" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1157,13 +1147,9 @@ "table": "workspace" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1172,13 +1158,9 @@ "table": "message" }, { - "columns": [ - "message_id" - ], + "columns": ["message_id"], "tableTo": "message", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1187,13 +1169,9 @@ "table": "part" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1202,13 +1180,9 @@ "table": "permission" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1217,13 +1191,9 @@ "table": "session_message" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1232,13 +1202,9 @@ "table": "session" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1247,13 +1213,9 @@ "table": "todo" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1262,13 +1224,9 @@ "table": "session_share" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "tableTo": "event_sequence", - "columnsTo": [ - "aggregate_id" - ], + "columnsTo": ["aggregate_id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1277,137 +1235,105 @@ "table": "event" }, { - "columns": [ - "email", - "url" - ], + "columns": ["email", "url"], "nameExplicit": false, "name": "control_account_pk", "entityType": "pks", "table": "control_account" }, { - "columns": [ - "session_id", - "position" - ], + "columns": ["session_id", "position"], "nameExplicit": false, "name": "todo_pk", "entityType": "pks", "table": "todo" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_state_pk", "table": "account_state", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_pk", "table": "account", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "workspace_pk", "table": "workspace", "entityType": "pks" }, { - "columns": [ - "name" - ], + "columns": ["name"], "nameExplicit": false, "name": "data_migration_pk", "table": "data_migration", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "project_pk", "table": "project", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "message_pk", "table": "message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "part_pk", "table": "part", "entityType": "pks" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "nameExplicit": false, "name": "permission_pk", "table": "permission", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_message_pk", "table": "session_message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_pk", "table": "session", "entityType": "pks" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "nameExplicit": false, "name": "session_share_pk", "table": "session_share", "entityType": "pks" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "nameExplicit": false, "name": "event_sequence_pk", "table": "event_sequence", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "event_pk", "table": "event", @@ -1571,4 +1497,4 @@ } ], "renames": [] -} \ No newline at end of file +} diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 57d9a706da70..6c5ea3725ed2 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -3245,7 +3245,7 @@ export class Session2 extends HeyApiClient { title?: string metadata?: { [key: string]: unknown - } | null + } permission?: PermissionRuleset time?: { archived?: number diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 6fed72bb5b64..c99510e78fe0 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -779,7 +779,7 @@ export type Session = { variant?: string } version: string - metadata: { + metadata?: { [key: string]: unknown } time: { @@ -1516,7 +1516,7 @@ export type GlobalSession = { variant?: string } version: string - metadata: { + metadata?: { [key: string]: unknown } time: { @@ -6218,7 +6218,7 @@ export type SessionUpdateData = { title?: string metadata?: { [key: string]: unknown - } | null + } permission?: PermissionRuleset time?: { archived?: number diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 53b8966fa68d..35a12c905e13 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -5185,6 +5185,9 @@ "required": ["id", "providerID"], "additionalProperties": false }, + "metadata": { + "type": "object" + }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" }, @@ -5509,6 +5512,9 @@ "title": { "type": "string" }, + "metadata": { + "type": "object" + }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" }, @@ -12816,6 +12822,9 @@ "version": { "type": "string" }, + "metadata": { + "type": "object" + }, "time": { "type": "object", "properties": { @@ -14900,6 +14909,9 @@ "version": { "type": "string" }, + "metadata": { + "type": "object" + }, "time": { "type": "object", "properties": { @@ -16746,6 +16758,16 @@ } ] }, + "metadata": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + }, "time": { "type": "object", "properties": { From 9ec220c88ba1d731328f01cfcaa7802c45d350c8 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 30 May 2026 12:20:17 -0500 Subject: [PATCH 6/7] fix(core): drop null clear path for session metadata --- .../src/server/routes/instance/httpapi/groups/session.ts | 2 +- .../src/server/routes/instance/httpapi/handlers/session.ts | 4 ++-- packages/opencode/src/session/session.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index a6f777c4d589..ac41487fd30b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -45,7 +45,7 @@ export const MessagesQuery = Schema.Struct({ export const StatusMap = Schema.Record(Schema.String, SessionStatus.Info) export const UpdatePayload = Schema.Struct({ title: Schema.optional(Schema.String), - metadata: Schema.optional(Schema.NullOr(Session.Metadata)), + metadata: Schema.optional(Session.Metadata), permission: Schema.optional(Permission.Ruleset), time: Schema.optional( Schema.Struct({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index fe29b32959fd..0d4b9ff989bb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -185,8 +185,8 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", if (ctx.payload.title !== undefined) { yield* session.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title }) } - if ("metadata" in ctx.payload) { - yield* session.setMetadata({ sessionID: ctx.params.sessionID, metadata: ctx.payload.metadata ?? null }) + if (ctx.payload.metadata !== undefined) { + yield* session.setMetadata({ sessionID: ctx.params.sessionID, metadata: ctx.payload.metadata }) } if (ctx.payload.permission !== undefined) { yield* session.setPermission({ diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index db47a9ba4d37..e84da3340cd4 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -272,7 +272,7 @@ export const SetArchivedInput = Schema.Struct({ }) export const SetMetadataInput = Schema.Struct({ sessionID: SessionID, - metadata: Schema.NullOr(Metadata), + metadata: Metadata, }) export const SetPermissionInput = Schema.Struct({ sessionID: SessionID, From cb8673273d8b3e4eb234e3705d209ddae25b1491 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 30 May 2026 16:22:31 -0500 Subject: [PATCH 7/7] test(core): reset session metadata via empty object instead of null --- packages/opencode/test/server/session-actions.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index 5a85cf75c49f..1a16016a5679 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -77,17 +77,17 @@ describe("session action routes", () => { const fork = (yield* Effect.promise(() => forked.json())) as SessionNs.Info expect(fork.metadata).toEqual(next.metadata) - const cleared = yield* Effect.promise(() => + const reset = yield* Effect.promise(() => Promise.resolve( app.request(`/session/${session.id}`, { method: "PATCH", headers, - body: JSON.stringify({ metadata: null }), + body: JSON.stringify({ metadata: {} }), }), ), ) - expect(cleared.status).toBe(200) - expect(((yield* Effect.promise(() => cleared.json())) as SessionNs.Info).metadata).toBeUndefined() + expect(reset.status).toBe(200) + expect(((yield* Effect.promise(() => reset.json())) as SessionNs.Info).metadata).toEqual({}) yield* SessionNs.Service.use((svc) => svc.remove(fork.id).pipe(Effect.ignore)) yield* SessionNs.Service.use((svc) => svc.remove(session.id).pipe(Effect.ignore))