diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 1a2bc42736..464a93b853 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -214,7 +214,7 @@ jobs: # (the build job has the Go toolchain) and shipped to the shards as its # own artifact; global-setup fails fast if it is missing. - name: Build E2E fixture plugin package - run: make -C apps/backend e2e-plugin-package + run: make -C apps/backend e2e-plugin-ui e2e-plugin-package # Linux/amd64 helper binaries (agentctl + mock-agent) bind-mounted into # containers by the Docker E2E project. Cheap to build (~1s each) so we @@ -393,7 +393,9 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: e2e-plugin-package - path: apps/backend/.build/kandev-plugin-e2e-1.0.0.tar.gz + path: | + apps/backend/.build/kandev-plugin-e2e-1.0.0.tar.gz + apps/backend/.build/e2e-plugin-identity.json retention-days: 1 # Tar the Vite dist directory so shards can reuse the same static web build. diff --git a/apps/backend/Makefile b/apps/backend/Makefile index 18cbd998c1..44746dd42f 100644 --- a/apps/backend/Makefile +++ b/apps/backend/Makefile @@ -186,7 +186,7 @@ endif DEADCODE_VERSION ?= v0.48.0 DEADCODE_GO_TOOLCHAIN ?= go1.26.0 -.PHONY: all build build-dev build-runtime build-kandev build-all build-agentctl build-agentctl-dev build-agentctl-remote build-agentctl-linux build-agentctl-linux-arm64 build-agentctl-darwin-arm64 build-agentctl-darwin-amd64 build-acpdbg acpdbg build-mock-agent build-mock-agent-linux build-preview build-winjob e2e-plugin-package clean run dev start-debug test test-e2e test-sprites-e2e test-lifecycle-goleak lint deadcode fmt vet sqlguard check-make-shells help +.PHONY: all build build-dev build-runtime build-kandev build-all build-agentctl build-agentctl-dev build-agentctl-remote build-agentctl-linux build-agentctl-linux-arm64 build-agentctl-darwin-arm64 build-agentctl-darwin-amd64 build-acpdbg acpdbg build-mock-agent build-mock-agent-linux build-preview build-winjob e2e-plugin-ui e2e-plugin-package clean run dev start-debug test test-e2e test-sprites-e2e test-lifecycle-goleak lint deadcode fmt vet sqlguard check-make-shells help ## Default target all: build @@ -311,11 +311,16 @@ build-mock-agent-linux: FIXTURE_PACKAGE_DIR := cmd/plugin-fixture/fixture-package FIXTURE_STAGE_DIR := .build/fixture-stage FIXTURE_PACKAGE_OUT := .build/kandev-plugin-e2e-1.0.0.tar.gz +FIXTURE_IDENTITY_OUT := .build/e2e-plugin-identity.json HOST_GOOS := $(shell $(GO) env GOOS) HOST_GOARCH := $(shell $(GO) env GOARCH) +.PHONY: e2e-plugin-ui +e2e-plugin-ui: + cd ../web && pnpm run build:e2e-plugin -- --outDir ../../apps/backend/$(FIXTURE_PACKAGE_DIR)/ui + .PHONY: e2e-plugin-package -e2e-plugin-package: +e2e-plugin-package: e2e-plugin-ui @echo "Packaging plugin-fixture for e2e ($(HOST_GOOS)-$(HOST_GOARCH))..." @$(RMDIR) $(FIXTURE_STAGE_DIR) @$(call MKDIR,$(FIXTURE_STAGE_DIR)/server) @@ -323,6 +328,12 @@ e2e-plugin-package: cp -r $(FIXTURE_PACKAGE_DIR)/ui $(FIXTURE_STAGE_DIR)/ui $(GO) build $(GOFLAGS) -o $(FIXTURE_STAGE_DIR)/server/plugin-$(HOST_GOOS)-$(HOST_GOARCH)$(EXE) ./cmd/plugin-fixture $(GO) run ./cmd/plugin-pack -dir $(FIXTURE_STAGE_DIR) -out $(FIXTURE_PACKAGE_OUT) -platform-only + cd ../web && node scripts/write-e2e-plugin-identity.mjs \ + e2e/fixtures/plugins/prompt-history-plugin/bundle.js \ + ../backend/cmd/plugin-fixture/fixture-package/ui/bundle.js \ + ../backend/cmd/plugin-fixture/fixture-package/manifest.yaml \ + ../backend/$(FIXTURE_PACKAGE_OUT) \ + ../backend/$(FIXTURE_IDENTITY_OUT) @$(RMDIR) $(FIXTURE_STAGE_DIR) @echo "Wrote $(FIXTURE_PACKAGE_OUT)" diff --git a/apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml b/apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml index 0853cc8dd7..508c4d64ba 100644 --- a/apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml +++ b/apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml @@ -6,6 +6,7 @@ # cmd/plugin-pack into apps/backend/.build/kandev-plugin-e2e-1.0.0.tar.gz. id: "kandev-plugin-e2e" api_version: 2 +min_kandev_version: "0.91.1" version: "1.0.0" display_name: "Kandev E2E Fixture Plugin" description: "SDK-based plugin backend used by backend integration tests and Playwright e2e tests" @@ -23,6 +24,7 @@ runtime: windows-amd64: "server/plugin-windows-amd64.exe" capabilities: + api_read: ["messages"] events: ["task.created"] api_write: ["tasks"] agent_invoke: true diff --git a/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js b/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js index 6ebd7cab89..4aa8cad07c 100644 --- a/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js +++ b/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js @@ -34,6 +34,7 @@ * a tiny listener set, so it survives across route navigations (the page * component unmounts/remounts as the user navigates away and back). */ +/* eslint-disable max-lines, max-lines-per-function, sonarjs/no-duplicate-string -- The fixture is the literal self-contained, dependency-free browser bundle exercised and hashed by E2E. */ (function () { var moduleCount = 0; var listeners = new Set(); @@ -368,6 +369,179 @@ }); } + function promptDuration(startedAt, completedAt, translated) { + var started = Date.parse(startedAt); + var completed = Date.parse(completedAt); + if (Number.isNaN(started) || Number.isNaN(completed)) return ""; + var seconds = Math.max(0, Math.floor((completed - started) / 1000)); + var hours = Math.floor(seconds / 3600); + var minutes = Math.floor((seconds % 3600) / 60); + var remaining = seconds % 60; + if (hours > 0) { + return ( + hours + + translated.t("durationHours") + + " " + + minutes + + translated.t("durationMinutes") + + " " + + remaining + + translated.t("durationSeconds") + ); + } + if (minutes > 0) { + return ( + minutes + + translated.t("durationMinutes") + + " " + + remaining + + translated.t("durationSeconds") + ); + } + return remaining + translated.t("durationSeconds"); + } + + function PromptHistoryPluginRow(props) { + var message = props.message; + var turn = props.turn; + var translated = props.translated; + var duration = + turn && turn.completedAt + ? promptDuration(turn.startedAt, turn.completedAt, translated) + : ""; + var favorite = props.conversation.history.useMessageFavorite(props.sessionId, message.id); + var isExpanded = props.expanded === message.id; + return jsx( + "article", + { + "data-testid": "fixture-prompt-history-row", + "data-message-id": message.id, + "data-favorite": favorite ? "true" : "false", + className: "border-b p-3", + }, + jsx( + "button", + { + type: "button", + className: "min-h-11 w-full text-left", + onClick: function () { + props.setExpanded(isExpanded ? null : message.id); + }, + }, + translated.t("prompt", { values: { index: message.promptIndex || 0 } }), + message.senderTaskId ? " · " + translated.t("sentByAgent") : "", + duration + ? jsx("span", { "data-testid": "fixture-prompt-history-duration" }, " · " + duration) + : "", + ), + isExpanded + ? jsx( + "div", + { "data-testid": "fixture-prompt-history-detail" }, + jsx(ui.PromptMentionText, { text: message.content, interactive: true }), + jsx( + "button", + { + type: "button", + className: "min-h-11", + onClick: function () { + props.conversation.openMessage(message.id); + }, + }, + translated.t("openPrompt"), + ), + ) + : null, + ); + } + + function PromptHistoryPluginPanel(props) { + var translated = host.i18n.useTranslation(); + var messages = props.conversation.history.useSessionMessages({ + sessionId: props.sessionId, + taskId: props.taskId, + authorTypes: ["user"], + sort: "desc", + pageSize: 2, + }); + var turns = props.conversation.history.useSessionTurns(props.sessionId, props.taskId); + var expandedState = React.useState(null); + var expanded = expandedState[0]; + var setExpanded = expandedState[1]; + + if (messages.loading || turns.loading) { + return jsx( + "div", + { "data-testid": "fixture-prompt-history-loading" }, + translated.t("loading"), + ); + } + if (messages.error || turns.error) { + return jsx( + "button", + { + type: "button", + "data-testid": "fixture-prompt-history-error", + onClick: function () { + messages.retry(); + turns.retry(); + }, + }, + translated.t("retry"), + ); + } + if (messages.messages.length === 0) { + return jsx( + "div", + { "data-testid": "fixture-prompt-history-empty" }, + translated.t("empty"), + ); + } + var rows = messages.messages.map(function (message) { + var turn = turns.turns.find(function (candidate) { + return candidate.id === message.turnId; + }); + return jsx(PromptHistoryPluginRow, { + key: message.id, + message: message, + turn: turn, + translated: translated, + conversation: props.conversation, + sessionId: props.sessionId, + expanded: expanded, + setExpanded: setExpanded, + }); + }); + if (messages.hasMore) { + rows.push( + jsx( + "button", + { + key: "load-more", + type: "button", + className: "min-h-11 w-full", + "data-testid": "fixture-prompt-history-load-more", + disabled: messages.loadingMore, + onClick: function () { + messages.loadMore(); + }, + }, + translated.t("loadOlder"), + ), + ); + } + return jsx( + "div", + { + "data-testid": "fixture-prompt-history-panel", + "data-removed": messages.removed ? "true" : "false", + "data-presentation": props.presentation, + className: "h-full min-w-0 overflow-y-auto overflow-x-hidden", + }, + rows, + ); + } + function CardIndicator(props) { var slotProps = props.slotProps || {}; return jsx( @@ -691,6 +865,46 @@ Selector: FixtureReviewSelector, }); + registry.registerTranslations({ + en: { + promptHistoryTitle: "Prompt history fixture", + loading: "Loading prompt history", + retry: "Retry", + empty: "No prompts yet", + prompt: "Prompt {{index}}", + sentByAgent: "Sent by agent", + openPrompt: "Open prompt", + loadOlder: "Load older prompts", + durationSeconds: "s", + durationMinutes: "m", + durationHours: "h", + }, + pseudo: { + promptHistoryTitle: "Ƥřǿɱƥŧ ħīşŧǿřẏ ƒīẋŧŭřḗ", + loading: "Ŀǿȧḓīƞɠ ƥřǿɱƥŧ ħīşŧǿřẏ", + retry: "Řḗŧřẏ", + empty: "Ƞǿ ƥřǿɱƥŧş ẏḗŧ", + prompt: "Ƥřǿɱƥŧ {{index}}", + sentByAgent: "Şḗƞŧ ƀẏ ȧɠḗƞŧ", + openPrompt: "Ǿƥḗƞ ƥřǿɱƥŧ", + loadOlder: "Ŀǿȧḓ ǿŀḓḗř ƥřǿɱƥŧş", + durationSeconds: "ş", + durationMinutes: "ɱ", + durationHours: "ħ", + }, + }); + registry.registerTaskPanel({ + id: "prompt-history-plugin", + title: "Prompt history fixture", + titleKey: "promptHistoryTitle", + icon: "history", + Component: PromptHistoryPluginPanel, + mobileEnabled: true, + visible: function (context) { + return context.sessionKind === "managed"; + }, + }); + registry.registerTaskPanel({ id: "notes", title: "Notes", diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index 1fd01b6b40..482e45c935 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -1518,13 +1518,24 @@ func registerSecondaryRoutes( } if p.services.Plugins != nil { + p.gateway.SetPluginConversationService(p.services.Plugins) if p.authSvc != nil { // Lets an auth-capable plugin complete OIDC/SAML SSO: it asserts a // validated external identity on its webhook response and the host // mints + sets the session cookie (the plugin never sees the token). p.services.Plugins.SetAuthLoginBridge(pluginSSOBridge{auth: p.authSvc}) } - plugins.RegisterRoutes(p.router, p.services.Plugins, p.services.Plugins.Deliverer(), p.log) + conversationReaders := make([]plugins.ConversationReader, 0, 1) + if p.services.Task != nil { + conversationReaders = append(conversationReaders, p.services.Task) + } + plugins.RegisterRoutes( + p.router, + p.services.Plugins, + p.services.Plugins.Deliverer(), + p.log, + conversationReaders..., + ) if p.features.Canvases { plugins.RegisterWebAppRuntimeRoutes(p.router, p.services.Plugins.WebRuntime()) registerCanvasRoutes(p) diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index 2d400e42f1..07f79a6570 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -419,6 +419,9 @@ func startServices( //nolint:cyclop log.Error("Failed to initialize services", zap.Error(err)) return false } + if services.PluginsCleanup != nil { + addCleanup(services.PluginsCleanup) + } agentRegistry.SetManagedRuntimeSelectionStore(services.ManagedRuntimeSelections) if services.Workflow != nil { addCleanup(services.Workflow.Close) diff --git a/apps/backend/internal/backendapp/plugins_test.go b/apps/backend/internal/backendapp/plugins_test.go index f69efed23a..8cc20548f6 100644 --- a/apps/backend/internal/backendapp/plugins_test.go +++ b/apps/backend/internal/backendapp/plugins_test.go @@ -96,7 +96,10 @@ func newTestPluginsService(t *testing.T) *plugins.Service { t.Fatalf("load registry: %v", err) } svc := plugins.NewService(fsStore, registry, nil, testPluginsLogger(t)) - svc.SetPluginsDir(dir) + if err := svc.SetPluginsDir(dir); err != nil { + t.Fatalf("SetPluginsDir: %v", err) + } + t.Cleanup(func() { _ = svc.Close() }) svc.SetRuntime(alwaysUpRuntime{}) return svc } diff --git a/apps/backend/internal/backendapp/plugins_version_test.go b/apps/backend/internal/backendapp/plugins_version_test.go index bdeab15908..6cd6333213 100644 --- a/apps/backend/internal/backendapp/plugins_version_test.go +++ b/apps/backend/internal/backendapp/plugins_version_test.go @@ -134,6 +134,9 @@ func provideTestServices(t *testing.T, version string) (*Services, *config.Confi if err != nil { t.Fatalf("provideServices: %v", err) } + if services.PluginsCleanup != nil { + t.Cleanup(func() { _ = services.PluginsCleanup() }) + } if services.Workflow != nil { t.Cleanup(func() { _ = services.Workflow.Close() }) } diff --git a/apps/backend/internal/backendapp/postgres_boot_test.go b/apps/backend/internal/backendapp/postgres_boot_test.go index fc53828626..fd7f6eab11 100644 --- a/apps/backend/internal/backendapp/postgres_boot_test.go +++ b/apps/backend/internal/backendapp/postgres_boot_test.go @@ -87,6 +87,9 @@ func TestPostgresBootInitializesRepositories(t *testing.T) { if err != nil { t.Fatalf("provide services with postgres: %v", err) } + if services.PluginsCleanup != nil { + t.Cleanup(func() { _ = services.PluginsCleanup() }) + } if services.GitHub == nil { t.Fatal("GitHub service is unavailable after PostgreSQL boot") } diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index aaea7e7f2d..23f8f9c211 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -296,8 +296,11 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories if recordErr := recordRequiredStore(storeTracker, "workflow-sync", workflowSyncErr); recordErr != nil { return nil, nil, fmt.Errorf("initialize workflow sync: %w", recordErr) } - pluginsSvc, _, pluginStoreErrors := initPluginsServiceRequired(cfg, dbPool, eventBus, repos.Secrets, log) + pluginsSvc, pluginsCleanup, pluginStoreErrors := initPluginsServiceRequired(cfg, dbPool, eventBus, repos.Secrets, log) if recordErr := recordPluginStores(storeTracker, pluginStoreErrors); recordErr != nil { + if pluginsCleanup != nil { + _ = pluginsCleanup() + } return nil, nil, fmt.Errorf("initialize plugins: %w", recordErr) } if pluginsSvc != nil { @@ -434,6 +437,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories Share: shareHTTP, Automation: automationComponents, Plugins: pluginsSvc, + PluginsCleanup: pluginsCleanup, Canvas: canvasSvc, CanvasDistribution: canvasDistributionSvc, GitCredentials: gitCredentialBroker, diff --git a/apps/backend/internal/backendapp/types.go b/apps/backend/internal/backendapp/types.go index 7d7cfc87d6..20201af0d2 100644 --- a/apps/backend/internal/backendapp/types.go +++ b/apps/backend/internal/backendapp/types.go @@ -131,7 +131,8 @@ type Services struct { // Plugins is the extensible plugin system service (registration // registry, event delivery, health monitoring). Always constructed // (non-nil) when initialization succeeds. - Plugins *plugins.Service + Plugins *plugins.Service + PluginsCleanup func() error // Canvas is the gated lifecycle service for agent-authored plugin web // applications. It is nil while features.canvases is disabled. Canvas *canvasservice.Service diff --git a/apps/backend/internal/db/dialect/time.go b/apps/backend/internal/db/dialect/time.go index a93a8e0c18..e4e959d149 100644 --- a/apps/backend/internal/db/dialect/time.go +++ b/apps/backend/internal/db/dialect/time.go @@ -74,6 +74,17 @@ func DateTimeOf(driver, expr string) string { return fmt.Sprintf("datetime(%s)", expr) } +// RFC3339Millis renders an ISO-8601 timestamp with millisecond precision. +// +// SQLite: strftime('%Y-%m-%dT%H:%M:%fZ', expr) +// Postgres: to_char(expr, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') +func RFC3339Millis(driver, expr string) string { + if IsPostgres(driver) { + return fmt.Sprintf(`to_char(%s, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`, expr) + } + return fmt.Sprintf(`strftime('%%Y-%%m-%%dT%%H:%%M:%%fZ', %s)`, expr) +} + // NaiveUTCTimestampOf normalizes a naive timestamp expression — one with NO // embedded zone information, but KNOWN to always hold UTC wall-clock values // (every `TIMESTAMP`-typed column in this codebase, written via diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index 8a9414c228..5c612c26b7 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -76,6 +76,7 @@ const ( MessageAdded = "message.added" MessageUpdated = "message.updated" MessageDeleted = "message.deleted" + SessionRemoved = "session.removed" ) // Event types for message queue @@ -142,6 +143,7 @@ const ( const ( TurnStarted = "turn.started" TurnCompleted = "turn.completed" + TurnRemoved = "turn.removed" ) // Event types for repositories diff --git a/apps/backend/internal/gateway/websocket/client.go b/apps/backend/internal/gateway/websocket/client.go index 3cf1189cf6..1bd99e46aa 100644 --- a/apps/backend/internal/gateway/websocket/client.go +++ b/apps/backend/internal/gateway/websocket/client.go @@ -3,13 +3,16 @@ package websocket import ( "context" "encoding/json" + "errors" "strings" "sync" "time" + "github.com/google/uuid" "github.com/gorilla/websocket" "github.com/kandev/kandev/internal/auth/authn" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/plugins" "github.com/kandev/kandev/internal/user/store" ws "github.com/kandev/kandev/pkg/websocket" "go.uber.org/zap" @@ -32,7 +35,14 @@ const ( // Control frames (RPC responses and errors) use a separate bounded queue so // high-volume session notifications cannot fill the queue and make a user // action appear to time out. - controlSendBufferSize = 256 + controlSendBufferSize = 256 + orderedConsumerPlugin = "plugin" + orderedConsumerCore = "core" + responseErrorKey = "error" + sessionIDPayloadKey = "session_id" + eventTypePayloadKey = "type" + eventSequencePayloadKey = "sequence" + taskIDPayloadKey = "task_id" ) // Client represents a single WebSocket connection @@ -42,20 +52,21 @@ type Client struct { // anonymous connections (auth disabled and no synthetic identity set by // the HTTP middleware — e.g. direct hub tests); synthetic in disabled // mode; a real user when auth is enabled. - identity authn.Identity - conn *websocket.Conn - hub *Hub - send chan []byte - controlSend chan []byte - subscriptions map[string]bool // Task IDs this client is subscribed to - sessionSubscriptions map[string]bool // Session IDs this client is subscribed to - sessionFocus map[string]bool // Session IDs this client has focused (a strict subset of subscriptions, conceptually — see hub_session_mode.go) - userSubscriptions map[string]bool // User IDs this client is subscribed to - runSubscriptions map[string]bool // Office run IDs this client is subscribed to (for run.event.appended) - systemMetricsSubscribed bool - mu sync.RWMutex - closed bool - logger *logger.Logger + identity authn.Identity + conn *websocket.Conn + hub *Hub + send chan []byte + controlSend chan []byte + subscriptions map[string]bool // Task IDs this client is subscribed to + sessionSubscriptions map[string]bool // Session IDs this client is subscribed to + sessionFocus map[string]bool // Session IDs this client has focused (a strict subset of subscriptions, conceptually — see hub_session_mode.go) + orderedSessionSubscriptions map[string]map[string]plugins.SessionDeliveryCursorKey + userSubscriptions map[string]bool // User IDs this client is subscribed to + runSubscriptions map[string]bool // Office run IDs this client is subscribed to (for run.event.appended) + systemMetricsSubscribed bool + mu sync.RWMutex + closed bool + logger *logger.Logger // Replaceable session.message.updated traffic is scheduled separately from // semantic notifications so one noisy session cannot fill the shared FIFO. @@ -81,22 +92,23 @@ type Client struct { // NewClient creates a new WebSocket client func NewClient(id string, identity authn.Identity, conn *websocket.Conn, hub *Hub, log *logger.Logger) *Client { return &Client{ - ID: id, - identity: identity, - conn: conn, - hub: hub, - send: make(chan []byte, 256), - controlSend: make(chan []byte, controlSendBufferSize), - subscriptions: make(map[string]bool), - sessionSubscriptions: make(map[string]bool), - sessionFocus: make(map[string]bool), - userSubscriptions: make(map[string]bool), - runSubscriptions: make(map[string]bool), - replaceableByKey: make(map[queuedReplaceableKey]outboundNotification), - replaceableBySession: make(map[string][]sessionNotificationQueueItem), - replaceableCurrentByKey: make(map[replaceableNotificationKey]queuedReplaceableKey), - notificationWake: make(chan struct{}, 1), - logger: log.WithFields(zap.String("client_id", id)), + ID: id, + identity: identity, + conn: conn, + hub: hub, + send: make(chan []byte, 256), + controlSend: make(chan []byte, controlSendBufferSize), + subscriptions: make(map[string]bool), + sessionSubscriptions: make(map[string]bool), + sessionFocus: make(map[string]bool), + orderedSessionSubscriptions: make(map[string]map[string]plugins.SessionDeliveryCursorKey), + userSubscriptions: make(map[string]bool), + runSubscriptions: make(map[string]bool), + replaceableByKey: make(map[queuedReplaceableKey]outboundNotification), + replaceableBySession: make(map[string][]sessionNotificationQueueItem), + replaceableCurrentByKey: make(map[replaceableNotificationKey]queuedReplaceableKey), + notificationWake: make(chan struct{}, 1), + logger: log.WithFields(zap.String("client_id", id)), } } @@ -193,6 +205,12 @@ func (c *Client) handleMessage(msg *ws.Message) { case ws.ActionSessionUnsubscribe: c.handleSessionUnsubscribe(msg) return + case ws.ActionSessionAck: + c.handleSessionAck(msg) + return + case ws.ActionSessionPoisonRequeue: + c.handleSessionPoisonRequeue(msg) + return case ws.ActionSessionFocus: c.handleSessionFocus(msg) return @@ -303,7 +321,16 @@ type UserSubscribeRequest struct { } type SessionSubscribeRequest struct { - SessionID string `json:"session_id"` + SessionID string `json:"session_id"` + ConsumerKind string `json:"consumer_kind,omitempty"` + PluginID string `json:"plugin_id,omitempty"` + Generation int64 `json:"generation,omitempty"` + BindingToken string `json:"binding_token,omitempty"` + LastSeenSequence *uint64 `json:"last_seen_sequence,omitempty"` + ResumeToken string `json:"resume_token,omitempty"` + ReplaceCursor bool `json:"replace_cursor,omitempty"` + ConsumerID string `json:"consumer_id,omitempty"` + WireID string `json:"wire_id,omitempty"` } // ownUserTopic resolves the user-topic this client may subscribe to: its own @@ -351,6 +378,10 @@ func (c *Client) handleSessionSubscribe(msg *ws.Message) { c.sendError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) return } + if req.ConsumerKind != "" { + c.handleOrderedSessionSubscribe(msg, req) + return + } if !c.maySubscribeSession(msg, req.SessionID) { return @@ -370,6 +401,500 @@ func (c *Client) handleSessionSubscribe(msg *ws.Message) { } } +func (c *Client) handleOrderedSessionSubscribe(msg *ws.Message, req SessionSubscribeRequest) { + service := c.hub.pluginConversationService + if service == nil { + c.sendSessionStreamFailure(msg, req.SessionID, "upstream_failure", "session stream unavailable", true) + return + } + userID := c.ownUserTopic() + if req.ConsumerKind == orderedConsumerPlugin { + lastSeen := uint64(0) + if req.LastSeenSequence != nil { + lastSeen = *req.LastSeenSequence + } + candidate := plugins.SessionDeliveryCursorKey{ + SessionID: req.SessionID, ConsumerKind: req.ConsumerKind, ConsumerID: req.ConsumerID, + WireID: req.WireID, PluginID: req.PluginID, Generation: req.Generation, UserID: userID, + } + if req.ResumeToken == "" || service.ValidateSessionResume(req.ResumeToken, candidate, lastSeen) != nil { + req.ConsumerID = uuid.NewString() + } + } + key := plugins.SessionDeliveryCursorKey{ + SessionID: req.SessionID, ConsumerKind: req.ConsumerKind, + ConsumerID: req.ConsumerID, WireID: req.WireID, PluginID: req.PluginID, + Generation: req.Generation, UserID: userID, + } + if !c.authorizeOrderedSessionConsumer(msg, req, service, userID) { + return + } + if !c.maySubscribeOrderedSession(msg, req, key, service) { + return + } + c.hub.orderedSessionMu.Lock() + defer c.hub.orderedSessionMu.Unlock() + committedEvents, err := service.SyncCommittedSessionEvents(context.Background(), req.SessionID) + if err != nil { + c.sendSessionStreamFailure(msg, req.SessionID, "upstream_failure", "session stream unavailable", true) + return + } + for _, event := range committedEvents { + c.hub.broadcastCommittedOrderedSessionEvent(service, event) + } + replay := resolveOrderedSessionReplay(service, req, key) + if replay.terminal && (replay.resumeValid || service.SessionEvents().HasCursor(key)) { + c.sendSessionStreamFailure(msg, req.SessionID, "session_removed", "session was removed", false) + return + } + c.acceptOrderedSessionSubscription(msg, req, key, userID, replay) +} + +type orderedSessionReplay struct { + events []plugins.SessionEvent + watermark uint64 + cursorSequence uint64 + result string + resumeValid bool + terminal bool +} + +//nolint:goconst // Result values are protocol literals. +func resolveOrderedSessionReplay( + service *plugins.Service, + req SessionSubscribeRequest, + key plugins.SessionDeliveryCursorKey, +) orderedSessionReplay { + lastSeen := uint64(0) + lastSeenSupplied := req.LastSeenSequence != nil + if lastSeenSupplied { + lastSeen = *req.LastSeenSequence + } + events, watermark, terminal := service.SessionEvents().ReplayState(req.SessionID, lastSeen) + // A retained replay that starts past lastSeen+1 - or has no retained rows + // at all while the partition watermark is still ahead of the cursor - + // means intermediate rows aged out on both sides; replaying it as if + // contiguous would hide lost history. Rebind to the watermark instead + // (same replacement-cursor machinery as invalid_resume) so the client + // reconciles from the authoritative snapshot boundary. + retainedGap := lastSeenSupplied && + lastSeen < watermark && + (len(events) == 0 || events[0].Sequence > lastSeen+1) + if retainedGap { + return orderedSessionReplay{ + watermark: watermark, cursorSequence: watermark, + result: "invalid_resume", terminal: terminal, + } + } + if req.ResumeToken != "" { + if err := service.ValidateSessionResume(req.ResumeToken, key, lastSeen); err != nil { + return orderedSessionReplay{ + watermark: watermark, cursorSequence: watermark, + result: "invalid_resume", terminal: terminal, + } + } + result := "fresh" + if lastSeenSupplied && len(events) > 0 { + result = "replay" + } + return orderedSessionReplay{ + events: events, watermark: watermark, cursorSequence: lastSeen, + result: result, resumeValid: true, terminal: terminal, + } + } + if !lastSeenSupplied { + return orderedSessionReplay{ + watermark: watermark, cursorSequence: watermark, + result: "fresh", terminal: terminal, + } + } + result := "fresh" + if len(events) > 0 { + result = "replay" + } + return orderedSessionReplay{ + events: events, watermark: watermark, cursorSequence: lastSeen, + result: result, terminal: terminal, + } +} + +func (c *Client) acceptOrderedSessionSubscription( + msg *ws.Message, + req SessionSubscribeRequest, + key plugins.SessionDeliveryCursorKey, + userID string, + replay orderedSessionReplay, +) bool { + service := c.hub.pluginConversationService + replay, claims, err := prepareOrderedReplayDelivery(service, replay, time.Now().UTC()) + if err != nil { + c.sendSessionStreamFailure(msg, req.SessionID, "upstream_failure", "cannot claim session event delivery", true) + return false + } + cursorSequence := replay.cursorSequence + if req.ReplaceCursor || replay.result == "invalid_resume" { + cursorSequence = replay.watermark + } + snapshotToken, resumeToken, expiresAt, err := service.MintSessionStreamGrant( + req.PluginID, + userID, + req.Generation, + req.SessionID, + req.ConsumerID, + req.WireID, + replay.watermark, + cursorSequence, + ) + if err != nil { + c.releaseOrderedReplayClaims(service, claims) + c.sendSessionStreamFailure(msg, req.SessionID, "invalid_binding", "cannot mint session stream grant", true) + return false + } + var cursorErr error + if req.ReplaceCursor || replay.result == "invalid_resume" { + cursorErr = service.SessionEvents().ReplaceCursor(key, cursorSequence) + } else { + cursorErr = service.SessionEvents().RegisterCursor(key, cursorSequence) + } + if cursorErr != nil { + c.releaseOrderedReplayClaims(service, claims) + c.sendSessionStreamFailure(msg, req.SessionID, "upstream_failure", "cannot register session cursor", true) + return false + } + payload := orderedSessionSubscribePayload(req, replay, snapshotToken, resumeToken, expiresAt) + c.storeOrderedSessionSubscription(req, key) + response, _ := ws.NewResponse(msg.ID, msg.Action, payload) + c.sendMessage(response) + for _, event := range replay.events { + queued := c.sendOrderedSessionEvent(event) + if claim, ok := claims[event.ID]; ok { + if err := service.SessionDelivery().Complete(claim, queued, time.Now().UTC()); err != nil && c.logger != nil { + c.logger.Error("complete ordered replay poison", zap.String("event_id", event.ID), zap.Error(err)) + } + } + } + return true +} + +func (c *Client) releaseOrderedReplayClaims( + service *plugins.Service, + claims map[string]plugins.SessionDeliveryClaim, +) { + if err := releaseOrderedReplayClaims(service, claims); err != nil && c.logger != nil { + c.logger.Error("release ordered replay claims", zap.Error(err)) + } +} + +func prepareOrderedReplayDelivery( + service *plugins.Service, + replay orderedSessionReplay, + now time.Time, +) (orderedSessionReplay, map[string]plugins.SessionDeliveryClaim, error) { + claims := make(map[string]plugins.SessionDeliveryClaim) + for _, event := range replay.events { + claim, err := service.SessionDelivery().Claim(event.SessionID, event.ID, now) + if err != nil { + releaseErr := releaseOrderedReplayClaims(service, claims) + return replay, nil, errors.Join(err, releaseErr) + } + switch claim.Disposition { + case plugins.SessionDeliveryUntracked: + case plugins.SessionDeliveryClaimed: + claims[event.ID] = claim + default: + if err := releaseOrderedReplayClaims(service, claims); err != nil { + return replay, nil, err + } + replay.events = nil + replay.result = "invalid_resume" + replay.cursorSequence = replay.watermark + return replay, nil, nil + } + } + return replay, claims, nil +} + +func releaseOrderedReplayClaims( + service *plugins.Service, + claims map[string]plugins.SessionDeliveryClaim, +) error { + now := time.Now().UTC() + var result error + for _, claim := range claims { + result = errors.Join(result, service.SessionDelivery().Complete(claim, false, now)) + } + return result +} + +func orderedSessionSubscribePayload( + req SessionSubscribeRequest, + replay orderedSessionReplay, + snapshotToken string, + resumeToken string, + expiresAt time.Time, +) map[string]any { + payload := map[string]any{ + "success": true, sessionIDPayloadKey: req.SessionID, "result": replay.result, + "event_watermark": replay.watermark, "snapshot_cutoff": replay.watermark, + "snapshot_token": snapshotToken, "resume_token": resumeToken, "expires_at": expiresAt, + } + if req.ConsumerKind == orderedConsumerPlugin { + payload["consumer_id"] = req.ConsumerID + } else { + payload["wire_id"] = req.WireID + } + if replay.result == "replay" && len(replay.events) > 0 { + payload["replay_from"] = replay.events[0].Sequence + payload["replay_to"] = replay.events[len(replay.events)-1].Sequence + } + return payload +} + +func (c *Client) storeOrderedSessionSubscription( + req SessionSubscribeRequest, + key plugins.SessionDeliveryCursorKey, +) { + subscriptionID := req.ConsumerID + if subscriptionID == "" { + subscriptionID = req.WireID + } + c.mu.Lock() + defer c.mu.Unlock() + byConsumer := c.orderedSessionSubscriptions[req.SessionID] + if byConsumer == nil { + byConsumer = make(map[string]plugins.SessionDeliveryCursorKey) + c.orderedSessionSubscriptions[req.SessionID] = byConsumer + } + byConsumer[subscriptionID] = key +} + +func (c *Client) authorizeOrderedSessionConsumer( + msg *ws.Message, + req SessionSubscribeRequest, + service *plugins.Service, + userID string, +) bool { + switch req.ConsumerKind { + case orderedConsumerPlugin: + return c.authorizeOrderedPluginConsumer(msg, req, service, userID) + case orderedConsumerCore: + if req.WireID != "" && req.ConsumerID == "" && req.PluginID == "" && + req.Generation == 0 && req.BindingToken == "" { + return true + } + c.sendSessionStreamFailure(msg, req.SessionID, "invalid_request", "invalid core consumer identity", false) + default: + c.sendSessionStreamFailure(msg, req.SessionID, "invalid_request", "unsupported consumer kind", false) + } + return false +} + +func (c *Client) authorizeOrderedPluginConsumer( + msg *ws.Message, + req SessionSubscribeRequest, + service *plugins.Service, + userID string, +) bool { + if req.ConsumerID == "" || req.WireID != "" || req.PluginID == "" || req.Generation == 0 { + c.sendSessionStreamFailure(msg, req.SessionID, "invalid_request", "invalid plugin consumer identity", false) + return false + } + err := service.AuthorizeConversationConsumer( + req.PluginID, + userID, + req.Generation, + req.BindingToken, + ) + if err == nil { + return true + } + code := "invalid_binding" + retryable := false + if errors.Is(err, plugins.ErrConversationGenerationSuperseded) { + code = "generation_superseded" + retryable = true + } + c.sendSessionStreamFailure(msg, req.SessionID, code, "plugin conversation binding rejected", retryable) + return false +} + +func (c *Client) sendSessionStreamFailure( + msg *ws.Message, + sessionID string, + code string, + message string, + retryable bool, +) { + payload := map[string]any{ + "success": false, + responseErrorKey: map[string]any{"code": code, "message": message, "retryable": retryable}, + } + if sessionID != "" { + payload["session_id"] = sessionID + } + response, _ := ws.NewResponse(msg.ID, msg.Action, payload) + c.sendMessage(response) +} + +func (c *Client) sendOrderedSessionEvent(event plugins.SessionEvent) bool { + frame, err := sessionEventFrame(event) + if err != nil { + c.closeSend() + return false + } + if c.sendNotification(frame, "session.event") { + return true + } + // An ordered frame that is not queued cannot be acknowledged safely. End + // this connection so the client reconnects and replays from its cursor. + c.closeSend() + return false +} + +type SessionAckRequest struct { + SessionID string `json:"session_id"` + ConsumerKind string `json:"consumer_kind"` + PluginID string `json:"plugin_id,omitempty"` + Generation int64 `json:"generation,omitempty"` + Sequence uint64 `json:"sequence"` + ResumeToken string `json:"resume_token,omitempty"` + ConsumerID string `json:"consumer_id,omitempty"` + WireID string `json:"wire_id,omitempty"` +} + +func (c *Client) handleSessionAck(msg *ws.Message) { + var req SessionAckRequest + if err := msg.ParsePayload(&req); err != nil { + c.sendSessionStreamFailure(msg, "", "invalid_request", "invalid acknowledgement", false) + return + } + service := c.hub.pluginConversationService + identity, validIdentity := sessionAckSubscriptionID(req) + if !validIdentity { + c.sendSessionStreamFailure(msg, req.SessionID, "invalid_request", "invalid session consumer identity", false) + return + } + c.mu.RLock() + key, ok := c.orderedSessionSubscriptions[req.SessionID][identity] + c.mu.RUnlock() + if service == nil || !ok || !sessionAckMatchesKey(req, key) { + c.sendSessionStreamFailure(msg, req.SessionID, "invalid_request", "unknown session consumer", false) + return + } + if err := service.ValidateSessionResume(req.ResumeToken, key, req.Sequence); err != nil { + c.sendSessionStreamFailure(msg, req.SessionID, "invalid_binding", "invalid resume token", false) + return + } + if err := service.SessionEvents().Acknowledge(key, req.Sequence); err != nil { + code := "forward_gap" + retryable := true + if errors.Is(err, plugins.ErrPoisonEvent) { + code = "invalid_request" + retryable = false + } + c.sendSessionStreamFailure(msg, req.SessionID, code, "acknowledgement rejected", retryable) + return + } + _, resumeToken, _, err := service.MintSessionStreamGrant( + key.PluginID, + key.UserID, + key.Generation, + key.SessionID, + key.ConsumerID, + key.WireID, + req.Sequence, + req.Sequence, + ) + if err != nil { + c.sendSessionStreamFailure(msg, req.SessionID, "invalid_binding", "cannot mint resume token", true) + return + } + payload := map[string]any{ + "success": true, "session_id": req.SessionID, + "acknowledged_sequence": req.Sequence, "resume_token": resumeToken, + } + if key.ConsumerKind == orderedConsumerPlugin { + payload["consumer_id"] = key.ConsumerID + } else { + payload["wire_id"] = key.WireID + } + response, _ := ws.NewResponse(msg.ID, msg.Action, payload) + c.sendMessage(response) +} + +func sessionAckSubscriptionID(req SessionAckRequest) (string, bool) { + switch req.ConsumerKind { + case orderedConsumerPlugin: + return req.ConsumerID, req.ConsumerID != "" && req.WireID == "" + case orderedConsumerCore: + return req.WireID, req.WireID != "" && req.ConsumerID == "" + default: + return "", false + } +} + +func sessionAckMatchesKey(req SessionAckRequest, key plugins.SessionDeliveryCursorKey) bool { + if req.SessionID != key.SessionID || req.ConsumerKind != key.ConsumerKind { + return false + } + switch req.ConsumerKind { + case orderedConsumerPlugin: + return req.ConsumerID == key.ConsumerID && + req.PluginID == key.PluginID && + req.Generation == key.Generation + case orderedConsumerCore: + return req.WireID == key.WireID && req.PluginID == "" && req.Generation == 0 + default: + return false + } +} + +type sessionPoisonRequeueRequest struct { + SessionID string `json:"session_id"` + EventID string `json:"event_id"` + ExpectedOwnerEpoch uint64 `json:"expected_owner_epoch"` +} + +const sessionPoisonStateKey = "state" + +func (c *Client) handleSessionPoisonRequeue(msg *ws.Message) { + var req sessionPoisonRequeueRequest + if err := msg.ParsePayload(&req); err != nil || + req.SessionID == "" || + req.EventID == "" || + req.ExpectedOwnerEpoch == 0 { + c.sendSessionStreamFailure(msg, req.SessionID, "invalid_request", "invalid poison requeue request", false) + return + } + if !canRequeueSessionEvents(c.identity) || c.hub.pluginConversationService == nil { + c.sendSessionStreamFailure(msg, req.SessionID, "unauthorized", "session_events:requeue required", false) + return + } + err := c.hub.pluginConversationService.SessionDelivery().Requeue( + req.SessionID, + req.EventID, + req.ExpectedOwnerEpoch, + c.ownUserTopic(), + time.Now().UTC(), + ) + if err != nil { + c.sendSessionStreamFailure(msg, req.SessionID, "invalid_request", "poison requeue rejected", false) + return + } + response, _ := ws.NewResponse(msg.ID, msg.Action, map[string]any{ + "success": true, "session_id": req.SessionID, "event_id": req.EventID, + sessionPoisonStateKey: plugins.SessionPoisonPending, + }) + c.sendMessage(response) +} + +func canRequeueSessionEvents(identity authn.Identity) bool { + if identity.UserID == "" { + return false + } + return identity.Instance || identity.Synthetic +} + // maySubscribeSession applies the per-user session scoping check, emitting the // forbidden error itself. Returns true when the subscription may proceed. func (c *Client) maySubscribeSession(msg *ws.Message, sessionID string) bool { @@ -384,6 +909,27 @@ func (c *Client) maySubscribeSession(msg *ws.Message, sessionID string) bool { return true } +func (c *Client) maySubscribeOrderedSession( + msg *ws.Message, + req SessionSubscribeRequest, + key plugins.SessionDeliveryCursorKey, + service *plugins.Service, +) bool { + check := c.hub.authPolicy.Subscriptions.Session + if check == nil { + return true + } + if err := check(c.dispatchContext(), req.SessionID); err == nil { + return true + } + _, _, terminal := service.SessionEvents().ReplayState(req.SessionID, 0) + if terminal && service.SessionEvents().HasCursor(key) { + return true + } + c.sendError(msg.ID, msg.Action, ws.ErrorCodeForbidden, "cannot subscribe to this session", nil) + return false +} + func (c *Client) handleUserUnsubscribe(msg *ws.Message) { var req UserSubscribeRequest if err := msg.ParsePayload(&req); err != nil { @@ -550,7 +1096,6 @@ func (c *Client) handleSystemMetricsSubscribe(msg *ws.Message) { }) c.sendMessage(resp) } - func (c *Client) handleSystemMetricsUnsubscribe(msg *ws.Message) { c.hub.UnsubscribeFromSystemMetrics(c) resp, _ := ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ @@ -566,14 +1111,15 @@ func (c *Client) handleSessionUnsubscribe(msg *ws.Message) { c.sendError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) return } - if req.SessionID == "" { c.sendError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) return } - + if req.ConsumerKind != "" { + c.handleOrderedSessionUnsubscribe(msg, req) + return + } c.hub.UnsubscribeFromSession(c, req.SessionID) - resp, _ := ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ "success": true, "session_id": req.SessionID, @@ -581,6 +1127,55 @@ func (c *Client) handleSessionUnsubscribe(msg *ws.Message) { c.sendMessage(resp) } +func (c *Client) handleOrderedSessionUnsubscribe(msg *ws.Message, req SessionSubscribeRequest) { + key, removed := c.removeOrderedSessionSubscription(req) + if !removed { + c.sendSessionStreamFailure(msg, req.SessionID, "invalid_request", "ordered subscription not found", false) + return + } + if service := c.hub.pluginConversationService; service != nil { + if err := service.SessionEvents().ReleaseCursor(key); err != nil { + c.storeOrderedSessionSubscription(req, key) + c.sendSessionStreamFailure(msg, req.SessionID, "upstream_failure", "cannot release session cursor", true) + return + } + } + payload := map[string]any{"success": true, sessionIDPayloadKey: req.SessionID} + if req.ConsumerKind == orderedConsumerPlugin { + payload["consumer_id"] = req.ConsumerID + } else { + payload["wire_id"] = req.WireID + } + response, _ := ws.NewResponse(msg.ID, msg.Action, payload) + c.sendMessage(response) +} + +func (c *Client) removeOrderedSessionSubscription( + req SessionSubscribeRequest, +) (plugins.SessionDeliveryCursorKey, bool) { + subscriptionID := req.ConsumerID + if req.ConsumerKind == orderedConsumerCore { + subscriptionID = req.WireID + } + c.mu.Lock() + defer c.mu.Unlock() + byConsumer := c.orderedSessionSubscriptions[req.SessionID] + key, subscribed := byConsumer[subscriptionID] + if !subscribed || + key.ConsumerKind != req.ConsumerKind || + key.ConsumerID != req.ConsumerID || + key.WireID != req.WireID || + key.PluginID != req.PluginID || + key.Generation != req.Generation { + return plugins.SessionDeliveryCursorKey{}, false + } + delete(byConsumer, subscriptionID) + if len(byConsumer) == 0 { + delete(c.orderedSessionSubscriptions, req.SessionID) + } + return key, true +} + // handleSessionFocus handles session.focus — marks the session as actively // viewed by this client, lifting backend polling to fast mode for the workspace. // It intentionally sends only the control acknowledgement. Snapshot/data diff --git a/apps/backend/internal/gateway/websocket/client_session_ack_test.go b/apps/backend/internal/gateway/websocket/client_session_ack_test.go new file mode 100644 index 0000000000..6df758191f --- /dev/null +++ b/apps/backend/internal/gateway/websocket/client_session_ack_test.go @@ -0,0 +1,484 @@ +package websocket + +import ( + "encoding/json" + "testing" + "time" + + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/plugins" + ws "github.com/kandev/kandev/pkg/websocket" +) + +func TestSessionAckIdentityMatchesExactConsumerBranch(t *testing.T) { + pluginKey := plugins.SessionDeliveryCursorKey{ + SessionID: "session-1", ConsumerKind: orderedConsumerPlugin, + PluginID: "plugin-1", Generation: 7, ConsumerID: "consumer-1", + } + coreKey := plugins.SessionDeliveryCursorKey{ + SessionID: "session-1", ConsumerKind: orderedConsumerCore, WireID: "wire-1", + } + tests := []struct { + name string + req SessionAckRequest + key plugins.SessionDeliveryCursorKey + want bool + }{ + { + name: "plugin identity", + req: SessionAckRequest{SessionID: "session-1", ConsumerKind: orderedConsumerPlugin, + PluginID: "plugin-1", Generation: 7, ConsumerID: "consumer-1"}, + key: pluginKey, want: true, + }, + { + name: "plugin cannot also send wire identity", + req: SessionAckRequest{SessionID: "session-1", ConsumerKind: orderedConsumerPlugin, + PluginID: "plugin-1", Generation: 7, ConsumerID: "consumer-1", WireID: "wire-1"}, + key: pluginKey, + }, + { + name: "plugin id must match", + req: SessionAckRequest{SessionID: "session-1", ConsumerKind: orderedConsumerPlugin, + PluginID: "other", Generation: 7, ConsumerID: "consumer-1"}, + key: pluginKey, + }, + { + name: "core identity", + req: SessionAckRequest{SessionID: "session-1", ConsumerKind: orderedConsumerCore, + WireID: "wire-1"}, + key: coreKey, want: true, + }, + { + name: "core cannot send plugin fields", + req: SessionAckRequest{SessionID: "session-1", ConsumerKind: orderedConsumerCore, + WireID: "wire-1", PluginID: "plugin-1", Generation: 7}, + key: coreKey, + }, + { + name: "core wire must match", + req: SessionAckRequest{SessionID: "session-1", ConsumerKind: orderedConsumerCore, + WireID: "other"}, + key: coreKey, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + identity, valid := sessionAckSubscriptionID(test.req) + if test.want { + if !valid || identity == "" || !sessionAckMatchesKey(test.req, test.key) { + t.Fatalf("valid ACK identity rejected: valid=%v identity=%q", valid, identity) + } + return + } + if valid && sessionAckMatchesKey(test.req, test.key) { + t.Fatal("malformed or conflicting ACK identity accepted") + } + }) + } +} + +func TestPoisonRequeueRequiresOperatorCapability(t *testing.T) { + tests := []struct { + name string + identity authn.Identity + want bool + }{ + {name: "instance operator", identity: authn.Identity{UserID: "operator-1", Instance: true}, want: true}, + {name: "single user operator", identity: authn.Identity{UserID: "default-user", Role: authn.RoleAdmin, Synthetic: true}, want: true}, + {name: "organization admin has no operator capability", identity: authn.Identity{UserID: "admin-1", Role: authn.RoleAdmin}}, + {name: "anonymous instance bit is insufficient", identity: authn.Identity{Instance: true}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := canRequeueSessionEvents(test.identity); got != test.want { + t.Fatalf("canRequeueSessionEvents() = %v, want %v", got, test.want) + } + }) + } +} + +func TestOrderedReplayIsExplicitOnly(t *testing.T) { + service := plugins.NewService(nil, plugins.NewRegistry(), nil, testLogger()) + _, err := service.SessionEvents().Append( + "session-1", + nil, + "message.deleted", + json.RawMessage(`{"type":"message.deleted","session_id":"session-1","message_id":"message-1"}`), + ) + if err != nil { + t.Fatalf("append event: %v", err) + } + key := plugins.SessionDeliveryCursorKey{ + SessionID: "session-1", ConsumerKind: orderedConsumerPlugin, + PluginID: "plugin-1", Generation: 7, ConsumerID: "consumer-1", UserID: "user-1", + } + + fresh := resolveOrderedSessionReplay(service, SessionSubscribeRequest{ + SessionID: "session-1", ConsumerKind: orderedConsumerPlugin, + PluginID: "plugin-1", Generation: 7, ConsumerID: "consumer-1", + }, key) + if fresh.result != "fresh" || fresh.watermark != 1 || len(fresh.events) != 0 { + t.Fatalf("fresh result = %+v, want no replay at watermark 1", fresh) + } + + zero := uint64(0) + replay := resolveOrderedSessionReplay(service, SessionSubscribeRequest{ + SessionID: "session-1", ConsumerKind: orderedConsumerPlugin, + PluginID: "plugin-1", Generation: 7, ConsumerID: "consumer-1", + LastSeenSequence: &zero, + }, key) + if replay.result != "replay" || len(replay.events) != 1 { + t.Fatalf("explicit replay result = %+v, want one replay event", replay) + } + + invalid := resolveOrderedSessionReplay(service, SessionSubscribeRequest{ + SessionID: "session-1", ConsumerKind: orderedConsumerPlugin, + PluginID: "plugin-1", Generation: 7, ConsumerID: "consumer-1", + LastSeenSequence: &zero, ResumeToken: "invalid", + }, key) + if invalid.result != "invalid_resume" || len(invalid.events) != 0 { + t.Fatalf("invalid resume result = %+v, want replacement without replay", invalid) + } +} + +func TestOrderedReplayGrantAcceptsEveryContiguousAck(t *testing.T) { + tests := []struct { + name string + consumerKind string + consumerID string + wireID string + pluginID string + generation int64 + }{ + {name: "core", consumerKind: orderedConsumerCore, wireID: "wire-replay"}, + { + name: "plugin", + consumerKind: orderedConsumerPlugin, + consumerID: "consumer-replay", + pluginID: "plugin-replay", + generation: 7, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + service := plugins.NewService(nil, plugins.NewRegistry(), nil, testLogger()) + for sequence := 1; sequence <= 12; sequence++ { + _, err := service.SessionEvents().Append( + "session-replay", + nil, + "message.deleted", + json.RawMessage(`{"type":"message.deleted","session_id":"session-replay","message_id":"message"}`), + ) + if err != nil { + t.Fatalf("append event %d: %v", sequence, err) + } + } + + client := newTestClient("replay-client-" + test.name) + client.hub = newTestHub(t) + client.hub.SetPluginConversationService(service) + client.controlSend = make(chan []byte, 16) + client.orderedSessionSubscriptions = make(map[string]map[string]plugins.SessionDeliveryCursorKey) + userID := client.ownUserTopic() + key := plugins.SessionDeliveryCursorKey{ + SessionID: "session-replay", ConsumerKind: test.consumerKind, + ConsumerID: test.consumerID, WireID: test.wireID, PluginID: test.pluginID, + Generation: test.generation, UserID: userID, + } + lastSeen := uint64(10) + req := SessionSubscribeRequest{ + SessionID: "session-replay", ConsumerKind: test.consumerKind, + PluginID: test.pluginID, Generation: test.generation, + ConsumerID: test.consumerID, WireID: test.wireID, + LastSeenSequence: &lastSeen, + } + _, initialResume, _, err := service.MintSessionStreamGrant( + test.pluginID, + userID, + test.generation, + "session-replay", + test.consumerID, + test.wireID, + 10, + 10, + ) + if err != nil { + t.Fatalf("mint initial resume token: %v", err) + } + req.ResumeToken = initialResume + if err := service.SessionEvents().RegisterCursor(key, 10); err != nil { + t.Fatalf("register replay cursor: %v", err) + } + replay := resolveOrderedSessionReplay(service, req, key) + if replay.result != "replay" || replay.watermark != 12 || replay.cursorSequence != 10 { + t.Fatalf("replay = %+v, want cursor 10 and watermark 12", replay) + } + msg := &ws.Message{ID: "subscribe-replay", Type: ws.MessageTypeRequest, Action: "session.subscribe"} + if !client.acceptOrderedSessionSubscription(msg, req, key, userID, replay) { + t.Fatal("replay subscription was rejected") + } + var subscribeResponse struct { + Payload struct { + SnapshotCutoff uint64 `json:"snapshot_cutoff"` + ResumeToken string `json:"resume_token"` + } `json:"payload"` + } + if err := json.Unmarshal(<-client.controlSend, &subscribeResponse); err != nil { + t.Fatalf("decode replay response: %v", err) + } + if subscribeResponse.Payload.SnapshotCutoff != 12 { + t.Fatalf("snapshot cutoff = %d, want 12", subscribeResponse.Payload.SnapshotCutoff) + } + + ack := func(sequence uint64, resumeToken string) string { + raw, err := json.Marshal(SessionAckRequest{ + SessionID: "session-replay", ConsumerKind: test.consumerKind, + PluginID: test.pluginID, Generation: test.generation, + Sequence: sequence, ResumeToken: resumeToken, + ConsumerID: test.consumerID, WireID: test.wireID, + }) + if err != nil { + t.Fatalf("marshal ACK %d: %v", sequence, err) + } + client.handleSessionAck(&ws.Message{ + ID: "ack-replay", Type: ws.MessageTypeRequest, Action: "session.ack", Payload: raw, + }) + var response struct { + Type ws.MessageType `json:"type"` + Payload struct { + Success bool `json:"success"` + ResumeToken string `json:"resume_token"` + Acknowledged uint64 `json:"acknowledged_sequence"` + ErrorCode string `json:"code"` + } `json:"payload"` + } + if err := json.Unmarshal(<-client.controlSend, &response); err != nil { + t.Fatalf("decode ACK %d response: %v", sequence, err) + } + if !response.Payload.Success { + t.Fatalf("ACK %d failed with %s", sequence, response.Payload.ErrorCode) + } + if response.Payload.Acknowledged != sequence { + t.Fatalf("ACK %d acknowledged %d", sequence, response.Payload.Acknowledged) + } + return response.Payload.ResumeToken + } + + resumeToken := subscribeResponse.Payload.ResumeToken + resumeToken = ack(11, resumeToken) + resumeToken = ack(12, resumeToken) + if _, err := service.SessionEvents().Append( + "session-replay", + nil, + "message.deleted", + json.RawMessage(`{"type":"message.deleted","session_id":"session-replay","message_id":"message-13"}`), + ); err != nil { + t.Fatalf("append live event: %v", err) + } + _ = ack(13, resumeToken) + }) + } +} + +func TestOrderedReplayGrantRejectsInvalidAck(t *testing.T) { + service := plugins.NewService(nil, plugins.NewRegistry(), nil, testLogger()) + for sequence := 1; sequence <= 2; sequence++ { + if _, err := service.SessionEvents().Append( + "session-invalid-ack", + nil, + "message.deleted", + json.RawMessage(`{"type":"message.deleted","session_id":"session-invalid-ack","message_id":"message"}`), + ); err != nil { + t.Fatalf("append event %d: %v", sequence, err) + } + } + + client := newTestClient("invalid-ack-client") + client.hub = newTestHub(t) + client.hub.SetPluginConversationService(service) + client.controlSend = make(chan []byte, 8) + client.orderedSessionSubscriptions = make(map[string]map[string]plugins.SessionDeliveryCursorKey) + userID := client.ownUserTopic() + key := plugins.SessionDeliveryCursorKey{ + SessionID: "session-invalid-ack", ConsumerKind: orderedConsumerCore, + WireID: "wire-invalid-ack", UserID: userID, + } + if err := service.SessionEvents().RegisterCursor(key, 1); err != nil { + t.Fatalf("register ACK cursor: %v", err) + } + _, resumeToken, _, err := service.MintSessionStreamGrant( + "", userID, 0, key.SessionID, "", key.WireID, 1, 1, + ) + if err != nil { + t.Fatalf("mint resume token: %v", err) + } + client.orderedSessionSubscriptions[key.SessionID] = map[string]plugins.SessionDeliveryCursorKey{ + key.WireID: key, + } + + ack := func(sequence uint64, token string) (success bool, code string) { + raw, err := json.Marshal(SessionAckRequest{ + SessionID: key.SessionID, ConsumerKind: orderedConsumerCore, + Sequence: sequence, ResumeToken: token, WireID: key.WireID, + }) + if err != nil { + t.Fatalf("marshal ACK %d: %v", sequence, err) + } + client.handleSessionAck(&ws.Message{ + ID: "ack-invalid", Type: ws.MessageTypeRequest, Action: "session.ack", Payload: raw, + }) + var response struct { + Payload struct { + Success bool `json:"success"` + Error struct { + Code string `json:"code"` + } `json:"error"` + } `json:"payload"` + } + if err := json.Unmarshal(<-client.controlSend, &response); err != nil { + t.Fatalf("decode ACK %d response: %v", sequence, err) + } + return response.Payload.Success, response.Payload.Error.Code + } + + if success, code := ack(3, resumeToken); success || code != "forward_gap" { + t.Fatalf("forward-gap ACK result = success %v, code %q; want forward_gap", success, code) + } + if success, code := ack(2, resumeToken+"tampered"); success || code != "invalid_binding" { + t.Fatalf("tampered ACK result = success %v, code %q; want invalid_binding", success, code) + } + if success, code := ack(2, resumeToken); !success || code != "" { + t.Fatalf("valid ACK after rejected requests = success %v, code %q; want success", success, code) + } +} + +func TestOrderedReplayRebindsWhenPoisonDeliveryIsAlreadyClaimed(t *testing.T) { + service := plugins.NewService(nil, plugins.NewRegistry(), nil, testLogger()) + poison, err := service.SessionEvents().Append( + "session-poison-replay", + nil, + "unknown.event", + json.RawMessage(`{"type":"unknown.event","session_id":"session-poison-replay"}`), + ) + if err != nil { + t.Fatalf("append poison event: %v", err) + } + claim, err := service.SessionDelivery().Claim( + poison.SessionID, + poison.ID, + time.Now().UTC(), + ) + if err != nil || claim.Disposition != plugins.SessionDeliveryClaimed { + t.Fatalf("initial poison claim = %q, %v", claim.Disposition, err) + } + zero := uint64(0) + req := SessionSubscribeRequest{ + SessionID: "session-poison-replay", ConsumerKind: orderedConsumerCore, + WireID: "wire-poison", LastSeenSequence: &zero, + } + key := plugins.SessionDeliveryCursorKey{ + SessionID: "session-poison-replay", ConsumerKind: orderedConsumerCore, WireID: "wire-poison", + } + replay := resolveOrderedSessionReplay(service, req, key) + hub := newTestHub(t) + hub.SetPluginConversationService(service) + client := newTestClient("poison-replay-client") + client.hub = hub + client.controlSend = make(chan []byte, 1) + client.orderedSessionSubscriptions = make(map[string]map[string]plugins.SessionDeliveryCursorKey) + msg := &ws.Message{ID: "subscribe-poison", Type: ws.MessageTypeRequest, Action: "session.subscribe"} + + if !client.acceptOrderedSessionSubscription(msg, req, key, "", replay) { + t.Fatal("poison replay replacement subscription was rejected") + } + + var response struct { + Payload struct { + Result string `json:"result"` + } `json:"payload"` + } + if err := json.Unmarshal(<-client.controlSend, &response); err != nil { + t.Fatalf("decode subscription response: %v", err) + } + if response.Payload.Result != "invalid_resume" { + t.Fatalf("poison replay result = %q, want invalid_resume", response.Payload.Result) + } + if err := service.SessionEvents().Acknowledge(key, poison.Sequence); err != nil { + t.Fatalf("replacement cursor did not advance past poison: %v", err) + } +} + +// TestOrderedReplayRebindsOnRetainedGap pins that a replay whose first +// retained row starts past lastSeen+1 (intermediate rows aged out on both +// sides) returns the replacement-cursor result instead of masquerading as a +// contiguous replay that would hide the lost history. +func TestOrderedReplayRebindsOnRetainedGap(t *testing.T) { + service := plugins.NewService(nil, plugins.NewRegistry(), nil, testLogger()) + // A fresh durable-log partition with only a high sequence present mirrors + // the state after both sides pruned the intermediate rows. + appended, err := service.SessionEvents().AppendCommitted(plugins.SessionEvent{ + SessionID: "session-gap", ID: "session-gap:10", Sequence: 10, + EventType: "message.deleted", + Payload: json.RawMessage(`{"type":"message.deleted","session_id":"session-gap","message_id":"message-1"}`), + }) + if err != nil { + t.Fatalf("mirror event: %v", err) + } + if !appended { + t.Fatal("expected the gap heal to accept the retained row") + } + key := plugins.SessionDeliveryCursorKey{ + SessionID: "session-gap", ConsumerKind: orderedConsumerPlugin, + PluginID: "plugin-1", Generation: 7, ConsumerID: "consumer-1", UserID: "user-1", + } + lastSeen := uint64(5) + replay := resolveOrderedSessionReplay(service, SessionSubscribeRequest{ + SessionID: "session-gap", ConsumerKind: orderedConsumerPlugin, + PluginID: "plugin-1", Generation: 7, ConsumerID: "consumer-1", + LastSeenSequence: &lastSeen, + }, key) + if replay.result != "invalid_resume" || len(replay.events) != 0 || replay.cursorSequence != replay.watermark { + t.Fatalf("retained-gap result = %+v, want replacement cursor at watermark without replay", replay) + } +} + +// TestOrderedReplayRebindsWhenPartitionWatermarkAheadWithNoRows pins the +// fully-pruned case: the partition watermark is ahead of the cursor but every +// retained event row aged out, so the reconnect must rebind rather than treat +// the empty replay as a fresh success. +func TestOrderedReplayRebindsWhenPartitionWatermarkAheadWithNoRows(t *testing.T) { + service := plugins.NewService(nil, plugins.NewRegistry(), nil, testLogger()) + base := time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) + appended, err := service.SessionEvents().AppendCommitted(plugins.SessionEvent{ + SessionID: "session-pruned", ID: "session-pruned:10", Sequence: 10, + EventType: "message.added", ProtocolVersion: 1, + Payload: json.RawMessage(`{"type":"message.added","session_id":"session-pruned","message_id":"m1","author_type":"user","content":"x","created_at":"2026-09-07T12:00:00Z"}`), + CreatedAt: base, + }) + if err != nil || !appended { + t.Fatalf("mirror event appended=%v err=%v", appended, err) + } + // Age the only retained row out: the partition keeps its watermark. + if err := service.SessionEvents().CollectExpired(base.Add(plugins.SessionEventRetention + time.Hour)); err != nil { + t.Fatalf("collect expired: %v", err) + } + key := plugins.SessionDeliveryCursorKey{ + SessionID: "session-pruned", ConsumerKind: orderedConsumerPlugin, + PluginID: "plugin-1", Generation: 7, ConsumerID: "consumer-1", UserID: "user-1", + } + lastSeen := uint64(5) + replay := resolveOrderedSessionReplay(service, SessionSubscribeRequest{ + SessionID: "session-pruned", ConsumerKind: orderedConsumerPlugin, + PluginID: "plugin-1", Generation: 7, ConsumerID: "consumer-1", + LastSeenSequence: &lastSeen, + }, key) + if replay.result != "invalid_resume" || len(replay.events) != 0 || replay.cursorSequence != replay.watermark { + t.Fatalf("pruned-gap result = %+v, want replacement cursor at watermark without replay", replay) + } + if replay.watermark != 10 { + t.Fatalf("watermark = %d, want 10", replay.watermark) + } +} diff --git a/apps/backend/internal/gateway/websocket/hub.go b/apps/backend/internal/gateway/websocket/hub.go index 2d78145033..dd197af06b 100644 --- a/apps/backend/internal/gateway/websocket/hub.go +++ b/apps/backend/internal/gateway/websocket/hub.go @@ -7,6 +7,7 @@ import ( "sync" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/plugins" ws "github.com/kandev/kandev/pkg/websocket" "go.uber.org/zap" ) @@ -53,6 +54,7 @@ type Hub struct { sessionDataProvider SessionDataProvider sessionGitDataProvider SessionGitDataProvider userSubscriptionListeners []func(userID string) + pluginConversationService *plugins.Service // clientDisconnectListener releases connection-bound resources after a // client is removed from the hub. It runs asynchronously so durable cleanup @@ -75,8 +77,11 @@ type Hub struct { // value = unscoped, today's behavior. See access.go. authPolicy AuthPolicy - mu sync.RWMutex - logger *logger.Logger + // orderedSessionMu closes the replay/register/append race for the ordered + // session stream. Never acquire it while holding h.mu or a client mutex. + orderedSessionMu sync.Mutex + mu sync.RWMutex + logger *logger.Logger } // NewHub creates a new WebSocket hub @@ -98,6 +103,19 @@ func NewHub(dispatcher *ws.Dispatcher, log *logger.Logger) *Hub { } } +func (h *Hub) SetPluginConversationService(service *plugins.Service) { + h.mu.Lock() + h.pluginConversationService = service + h.mu.Unlock() + if service != nil { + service.SetSessionEventSink(func(event plugins.SessionEvent) { + h.orderedSessionMu.Lock() + defer h.orderedSessionMu.Unlock() + h.broadcastCommittedOrderedSessionEvent(service, event) + }) + } +} + type SystemMetricsInterestTracker interface { MetricsSubscribe(clientID string) MetricsUnsubscribe(clientID string) @@ -515,12 +533,22 @@ func (h *Hub) getSessionRecipientsLocked(sessionID string) []*Client { // BroadcastToSession sends a notification to clients subscribed to OR focused on // a specific session. See getSessionRecipientsLocked for why focus is included. func (h *Hub) BroadcastToSession(sessionID string, msg *ws.Message) { + h.appendAndBroadcastOrderedSessionEvent(sessionID, msg) data, err := json.Marshal(msg) if err != nil { h.logger.Error("Failed to marshal message", zap.Error(err)) return } clients := h.authorizedSessionRecipients(sessionID) + if _, ordered := orderedEventTypeByAction[msg.Action]; ordered { + legacyClients := clients[:0] + for _, client := range clients { + if !client.hasOrderedSessionSubscription(sessionID) { + legacyClients = append(legacyClients, client) + } + } + clients = legacyClients + } h.logger.Debug("BroadcastToSession", zap.String("session_id", sessionID), zap.String("action", msg.Action), diff --git a/apps/backend/internal/gateway/websocket/hub_broadcast_test.go b/apps/backend/internal/gateway/websocket/hub_broadcast_test.go index 96b42f7ffe..0c01313195 100644 --- a/apps/backend/internal/gateway/websocket/hub_broadcast_test.go +++ b/apps/backend/internal/gateway/websocket/hub_broadcast_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/plugins" ws "github.com/kandev/kandev/pkg/websocket" ) @@ -119,6 +120,31 @@ func TestBroadcastToSession_DeliversOnceToSubscribedAndFocusedClient(t *testing. } } +func TestBroadcastToSession_SuppressesLegacyCopyForOrderedCoreConsumer(t *testing.T) { + h := newTestHub(t) + c := newTestClient("c1") + registerTestClient(h, c) + h.SubscribeToSession(c, "sess-1") + c.orderedSessionSubscriptions = map[string]map[string]plugins.SessionDeliveryCursorKey{ + "sess-1": { + "core-1": {SessionID: "sess-1", ConsumerKind: "core", WireID: "core-1"}, + }, + } + + msg, err := ws.NewNotification( + ws.ActionSessionMessageAdded, + map[string]any{"session_id": "sess-1"}, + ) + if err != nil { + t.Fatalf("notification: %v", err) + } + h.BroadcastToSession("sess-1", msg) + + if clientReceived(c) { + t.Fatal("ordered core consumer received a duplicate legacy notification") + } +} + func TestBroadcastToUserReportsWhetherAFrameWasQueued(t *testing.T) { h := newTestHub(t) msg, err := ws.NewNotification("system.update_available", map[string]any{"occurrence_id": "v1.2.3"}) @@ -197,3 +223,110 @@ func TestSendToIdentityTargetsConnectedClientsWithoutSubscription(t *testing.T) t.Fatal("a different identity must not receive the notification") } } + +// TestBroadcastCommittedPoisonFrameDeliversAndCountsAttempt pins the unified +// poison contract: the mirrored frame IS delivered to live subscribers (their +// recovery depends on seeing it) and the delivery attempt is counted only when +// a recipient exists. +func TestBroadcastCommittedPoisonFrameDeliversAndCountsAttempt(t *testing.T) { + h := newTestHub(t) + svc := plugins.NewService(nil, plugins.NewRegistry(), nil, testLogger()) + h.SetPluginConversationService(svc) + + // message.added missing author/content -> projection poison. + event, err := svc.SessionEvents().Append( + "sess-poison", newStringPointer("task-poison"), "message.added", + []byte(`{"type":"message.added","session_id":"sess-poison","task_id":"task-poison"}`), + ) + if err != nil { + t.Fatalf("append poison event: %v", err) + } + if _, ok := svc.SessionEvents().Poison("sess-poison", event.ID); !ok { + t.Fatal("append did not poison the malformed event") + } + + c := newTestClient("c-poison") + registerTestClient(h, c) + c.orderedSessionSubscriptions = map[string]map[string]plugins.SessionDeliveryCursorKey{ + "sess-poison": { + "core-1": {SessionID: "sess-poison", ConsumerKind: "core", WireID: "core-1"}, + }, + } + + h.broadcastCommittedOrderedSessionEvent(svc, event) + + if !clientReceived(c) { + t.Fatal("live subscriber did not receive the poison frame") + } + record, ok := svc.SessionEvents().Poison("sess-poison", event.ID) + if !ok { + t.Fatal("poison record missing after broadcast") + } + if record.Attempts != 1 { + t.Fatalf("poison attempts = %d, want 1 (one delivery with a recipient)", record.Attempts) + } +} + +// TestBroadcastCommittedPoisonFrameWithoutRecipientsSkipsAttempt pins that a +// mirrored poison with nobody subscribed does not burn a delivery attempt. +func TestBroadcastCommittedPoisonFrameWithoutRecipientsSkipsAttempt(t *testing.T) { + h := newTestHub(t) + svc := plugins.NewService(nil, plugins.NewRegistry(), nil, testLogger()) + h.SetPluginConversationService(svc) + + event, err := svc.SessionEvents().Append( + "sess-poison-idle", newStringPointer("task-poison"), "message.added", + []byte(`{"type":"message.added","session_id":"sess-poison-idle","task_id":"task-poison"}`), + ) + if err != nil { + t.Fatalf("append poison event: %v", err) + } + + h.broadcastCommittedOrderedSessionEvent(svc, event) + + record, ok := svc.SessionEvents().Poison("sess-poison-idle", event.ID) + if !ok { + t.Fatal("poison record missing after broadcast") + } + if record.Attempts != 0 { + t.Fatalf("poison attempts = %d, want 0 with no recipients", record.Attempts) + } +} + +func TestBroadcastCommittedPoisonQueueFailureDoesNotCountAttempt(t *testing.T) { + h := newTestHub(t) + svc := plugins.NewService(nil, plugins.NewRegistry(), nil, testLogger()) + h.SetPluginConversationService(svc) + event, err := svc.SessionEvents().Append( + "sess-poison-full", newStringPointer("task-poison"), "message.added", + []byte(`{"type":"message.added","session_id":"sess-poison-full","task_id":"task-poison"}`), + ) + if err != nil { + t.Fatalf("append poison event: %v", err) + } + c := newTestClient("c-poison-full") + for range cap(c.send) { + c.send <- []byte("occupied") + } + registerTestClient(h, c) + c.orderedSessionSubscriptions = map[string]map[string]plugins.SessionDeliveryCursorKey{ + "sess-poison-full": { + "core-1": {SessionID: "sess-poison-full", ConsumerKind: "core", WireID: "core-1"}, + }, + } + + h.broadcastCommittedOrderedSessionEvent(svc, event) + + record, ok := svc.SessionEvents().Poison("sess-poison-full", event.ID) + if !ok { + t.Fatal("poison record missing after queue failure") + } + if record.Attempts != 0 || record.State != plugins.SessionPoisonPending { + t.Fatalf("poison after queue failure = attempts %d state %q, want pending at zero", record.Attempts, record.State) + } + if !c.closed { + t.Fatal("ordered queue failure must close the client for replay") + } +} + +func newStringPointer(value string) *string { return &value } diff --git a/apps/backend/internal/gateway/websocket/ordered_session_events.go b/apps/backend/internal/gateway/websocket/ordered_session_events.go new file mode 100644 index 0000000000..8a23ca34b0 --- /dev/null +++ b/apps/backend/internal/gateway/websocket/ordered_session_events.go @@ -0,0 +1,214 @@ +package websocket + +import ( + "context" + "encoding/json" + "time" + + "github.com/kandev/kandev/internal/plugins" + "github.com/kandev/kandev/internal/sysprompt" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +const ( + orderedMessageAddedEvent = "message.added" + orderedMessageUpdatedEvent = "message.updated" + orderedMessageDeletedEvent = "message.deleted" + orderedTurnStartedEvent = "session.turn.started" + orderedTurnCompletedEvent = "session.turn.completed" + orderedTurnRemovedEvent = "session.turn.removed" + orderedSessionRemovedEvent = "session.removed" +) + +var orderedEventTypeByAction = map[string]string{ + ws.ActionSessionMessageAdded: orderedMessageAddedEvent, + ws.ActionSessionMessageUpdated: orderedMessageUpdatedEvent, + ws.ActionSessionMessageDeleted: orderedMessageDeletedEvent, + ws.ActionSessionTurnStarted: orderedTurnStartedEvent, + ws.ActionSessionTurnCompleted: orderedTurnCompletedEvent, + ws.ActionSessionTurnRemoved: orderedTurnRemovedEvent, + ws.ActionSessionRemoved: orderedSessionRemovedEvent, +} + +const orderedContentPayloadKey = "content" + +//nolint:cyclop // This boundary coordinates journal sync, poison recording, and live fan-out. +func (h *Hub) appendAndBroadcastOrderedSessionEvent(sessionID string, message *ws.Message) { + h.orderedSessionMu.Lock() + defer h.orderedSessionMu.Unlock() + service := h.pluginConversationService + eventType := orderedEventTypeByAction[message.Action] + if service == nil || eventType == "" { + return + } + if service.HasConversationJournal() { + events, err := service.SyncCommittedSessionEvents(context.Background(), sessionID) + if err != nil { + if h.logger != nil { + h.logger.Error("mirror committed ordered session events", zap.Error(err)) + } + return + } + for _, event := range events { + h.broadcastCommittedOrderedSessionEvent(service, event) + } + return + } + var source map[string]any + if err := json.Unmarshal(message.Payload, &source); err != nil { + source = map[string]any{"raw": message.Payload} + } + payload := sanitizedOrderedSessionPayload(eventType, source) + encoded, err := json.Marshal(payload) + if err != nil { + return + } + var taskID *string + if value, ok := payload["task_id"].(string); ok && value != "" { + taskID = &value + } + event, err := service.SessionEvents().Append(sessionID, taskID, eventType, encoded) + if err != nil { + if h.logger != nil { + h.logger.Error("append ordered session event", zap.Error(err)) + } + return + } + h.deliverOrderedSessionEvent(service, event, h.orderedSessionRecipients(sessionID)) +} + +// broadcastCommittedOrderedSessionEvent fans a mirrored primary-journal event +// out through the same atomic delivery claim as locally appended events. +func (h *Hub) broadcastCommittedOrderedSessionEvent(service *plugins.Service, event plugins.SessionEvent) { + h.deliverOrderedSessionEvent(service, event, h.orderedSessionRecipients(event.SessionID)) +} + +func (h *Hub) deliverOrderedSessionEvent( + service *plugins.Service, + event plugins.SessionEvent, + recipients []*Client, +) { + if len(recipients) == 0 { + return + } + claim, err := service.SessionDelivery().Claim(event.SessionID, event.ID, time.Now().UTC()) + if err != nil { + if h.logger != nil { + h.logger.Error("claim ordered session poison", zap.String("event_id", event.ID), zap.Error(err)) + } + return + } + if claim.Disposition != plugins.SessionDeliveryUntracked && + claim.Disposition != plugins.SessionDeliveryClaimed { + return + } + queued := false + for _, client := range recipients { + queued = client.sendOrderedSessionEvent(event) || queued + } + if claim.Disposition == plugins.SessionDeliveryClaimed { + if err := service.SessionDelivery().Complete(claim, queued, time.Now().UTC()); err != nil && h.logger != nil { + h.logger.Error("complete ordered session poison", zap.String("event_id", event.ID), zap.Error(err)) + } + } +} + +func sanitizedOrderedSessionPayload(eventType string, source map[string]any) map[string]any { + payload := map[string]any{eventTypePayloadKey: eventType} + keys := []string{sessionIDPayloadKey, taskIDPayloadKey} + switch eventType { + case orderedMessageAddedEvent, orderedMessageUpdatedEvent: + keys = append(keys, + "message_id", "turn_id", "author_type", orderedContentPayloadKey, + "created_at", "updated_at", "prompt_index", "requests_input", + ) + if messageType, exists := source[eventTypePayloadKey]; exists { + payload["message_type"] = messageType + } + if metadata, ok := source["metadata"].(map[string]any); ok { + if sanitized := plugins.SanitizeConversationMessageMetadata(metadata); len(sanitized) > 0 { + payload["metadata"] = sanitized + } + } + if senderTaskID, ok := source["sender_task_id"].(string); ok && senderTaskID != "" { + payload["sender_task_id"] = senderTaskID + if metadata, ok := payload["metadata"].(map[string]any); !ok { + payload["metadata"] = map[string]any{"sender_task_id": senderTaskID} + } else { + metadata["sender_task_id"] = senderTaskID + } + } + case orderedMessageDeletedEvent: + keys = append(keys, "message_id") + case orderedTurnStartedEvent, orderedTurnCompletedEvent, orderedTurnRemovedEvent: + keys = append(keys, "id", "started_at", "completed_at", "updated_at", "had_output") + } + for _, key := range keys { + if value, exists := source[key]; exists { + if key == orderedContentPayloadKey { + if content, ok := value.(string); ok { + value = sysprompt.StripSystemContent(content) + } + } + payload[key] = value + } + } + return payload +} + +func (h *Hub) orderedSessionRecipients(sessionID string) []*Client { + h.mu.RLock() + clients := make([]*Client, 0) + for client := range h.clients { + if client.hasOrderedSessionSubscription(sessionID) { + clients = append(clients, client) + } + } + h.mu.RUnlock() + // Re-authorize at fanout time, mirroring the legacy session recipients: a + // client whose workspace/session membership was revoked after subscribing + // must not keep receiving ordered frames. Denied clients lose the ordered + // subscription itself AND its durable cursors (same revocation semantics + // as the legacy path), so the partition stops being pinned by them and + // retention can reclaim it. + allowed, denied := h.partitionAuthorized(clients, sessionID, h.authPolicy.Subscriptions.Session) + service := h.pluginConversationService + for _, client := range denied { + client.mu.Lock() + revoked := make([]plugins.SessionDeliveryCursorKey, 0, len(client.orderedSessionSubscriptions[sessionID])) + for _, key := range client.orderedSessionSubscriptions[sessionID] { + revoked = append(revoked, key) + } + delete(client.orderedSessionSubscriptions, sessionID) + client.mu.Unlock() + if service == nil { + continue + } + for _, key := range revoked { + if err := service.SessionEvents().ReleaseCursor(key); err != nil && h.logger != nil { + h.logger.Warn( + "release revoked ordered session cursor", + zap.String("session_id", sessionID), + zap.Error(err), + ) + } + } + } + return allowed +} + +func (c *Client) hasOrderedSessionSubscription(sessionID string) bool { + c.mu.RLock() + defer c.mu.RUnlock() + return len(c.orderedSessionSubscriptions[sessionID]) > 0 +} + +func sessionEventFrame(event plugins.SessionEvent) ([]byte, error) { + return json.Marshal(map[string]any{ + eventTypePayloadKey: "session.event", "protocol_version": event.ProtocolVersion, + "event_type": event.EventType, sessionIDPayloadKey: event.SessionID, + taskIDPayloadKey: event.TaskID, eventSequencePayloadKey: event.Sequence, + "event_id": event.ID, "payload": event.Payload, + }) +} diff --git a/apps/backend/internal/gateway/websocket/ordered_session_events_test.go b/apps/backend/internal/gateway/websocket/ordered_session_events_test.go new file mode 100644 index 0000000000..30012fc9c3 --- /dev/null +++ b/apps/backend/internal/gateway/websocket/ordered_session_events_test.go @@ -0,0 +1,62 @@ +package websocket + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/plugins" + ws "github.com/kandev/kandev/pkg/websocket" + "github.com/stretchr/testify/require" +) + +func TestSanitizedOrderedSessionPayloadStripsSystemContent(t *testing.T) { + payload := sanitizedOrderedSessionPayload("message.added", map[string]any{ + "session_id": "session-1", + "task_id": "task-1", + "message_id": "message-1", + orderedContentPayloadKey: "visible secret", + }) + + require.Equal(t, "visible", payload["content"]) +} +func TestTurnRemovedUsesOrderedSessionEventMapping(t *testing.T) { + require.Equal(t, "session.turn.removed", orderedEventTypeByAction[ws.ActionSessionTurnRemoved]) + payload := sanitizedOrderedSessionPayload(orderedTurnRemovedEvent, map[string]any{ + "id": "turn-1", + "session_id": "session-1", + "task_id": "task-1", + }) + require.Equal(t, "turn-1", payload["id"]) +} + +// TestOrderedRecipientsRevocationReleasesDurableCursor pins the round-9 fix: +// when authorization denies a previously-subscribed client at fanout time, the +// denied client is removed and its durable cursor is released so retention can +// reclaim the partition. +func TestOrderedRecipientsRevocationReleasesDurableCursor(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + h := NewHub(ws.NewDispatcher(), testLogger()) + go h.Run(ctx) + svc := plugins.NewService(nil, plugins.NewRegistry(), nil, testLogger()) + h.SetPluginConversationService(svc) + key := plugins.SessionDeliveryCursorKey{ + SessionID: "sess-revoked", ConsumerKind: "core", WireID: "wire-revoked", UserID: "user-1", + } + require.NoError(t, svc.SessionEvents().RegisterCursor(key, 0)) + + c := NewClient("c-revoked", authn.Identity{}, nil, h, testLogger()) + registerTestClient(h, c) + c.orderedSessionSubscriptions = map[string]map[string]plugins.SessionDeliveryCursorKey{ + "sess-revoked": {"wire-revoked": key}, + } + deny := func(context.Context, string) error { return errors.New("membership revoked") } + h.authPolicy.Subscriptions.Session = deny + + recipients := h.orderedSessionRecipients("sess-revoked") + require.Len(t, recipients, 0) + require.False(t, c.hasOrderedSessionSubscription("sess-revoked")) + require.False(t, svc.SessionEvents().HasCursor(key)) +} diff --git a/apps/backend/internal/gateway/websocket/setup.go b/apps/backend/internal/gateway/websocket/setup.go index 49dabdf357..cd3ad4a046 100644 --- a/apps/backend/internal/gateway/websocket/setup.go +++ b/apps/backend/internal/gateway/websocket/setup.go @@ -6,6 +6,7 @@ import ( "github.com/kandev/kandev/internal/agent/runtime/lifecycle" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/common/scripts" + "github.com/kandev/kandev/internal/plugins" ws "github.com/kandev/kandev/pkg/websocket" ) @@ -40,6 +41,10 @@ func NewGateway(log *logger.Logger) *Gateway { } } +func (g *Gateway) SetPluginConversationService(service *plugins.Service) { + g.Hub.SetPluginConversationService(service) +} + // SetLifecycleManager enables the dedicated terminal WebSocket handler for passthrough mode. // This must be called before SetupRoutes if terminal passthrough is needed. func (g *Gateway) SetLifecycleManager(lifecycleMgr *lifecycle.Manager, userService UserService, scriptService scripts.ScriptService) { diff --git a/apps/backend/internal/gateway/websocket/task_notifications.go b/apps/backend/internal/gateway/websocket/task_notifications.go index e6ce2c1dab..7d25f46253 100644 --- a/apps/backend/internal/gateway/websocket/task_notifications.go +++ b/apps/backend/internal/gateway/websocket/task_notifications.go @@ -95,6 +95,8 @@ func RegisterTaskNotifications(ctx context.Context, eventBus bus.EventBus, hub * b.subscribe(eventBus, events.AgentctlError, ws.ActionSessionAgentctlError) b.subscribe(eventBus, events.TurnStarted, ws.ActionSessionTurnStarted) b.subscribe(eventBus, events.TurnCompleted, ws.ActionSessionTurnCompleted) + b.subscribe(eventBus, events.TurnRemoved, ws.ActionSessionTurnRemoved) + b.subscribe(eventBus, events.SessionRemoved, ws.ActionSessionRemoved) b.subscribe(eventBus, events.MessageQueueStatusChanged, ws.ActionMessageQueueStatusChanged) b.subscribe(eventBus, events.GitHubTaskPRUpdated, ws.ActionGitHubTaskPRUpdated) b.subscribe(eventBus, events.GitHubTaskPRDeleted, ws.ActionGitHubTaskPRDeleted) @@ -277,11 +279,17 @@ func (b *TaskEventBroadcaster) routeBroadcast( // the owning workspace's user when auth is enabled. b.hub.BroadcastToWorkspace(workspaceID, msg) return nil - case ws.ActionSessionMessageAdded, ws.ActionSessionMessageUpdated, ws.ActionSessionMessageDeleted: + case ws.ActionSessionMessageAdded, ws.ActionSessionMessageUpdated, ws.ActionSessionMessageDeleted, + ws.ActionSessionTurnStarted, ws.ActionSessionTurnCompleted, ws.ActionSessionTurnRemoved: if sessionID != "" { b.hub.BroadcastToSession(sessionID, msg) return nil } + case ws.ActionSessionRemoved: + if sessionID != "" { + b.hub.appendAndBroadcastOrderedSessionEvent(sessionID, msg) + return nil + } case ws.ActionSessionWorkspaceSourcesUpdated: if sessionID != "" { b.hub.BroadcastToSession(sessionID, msg) diff --git a/apps/backend/internal/gateway/websocket/task_notifications_test.go b/apps/backend/internal/gateway/websocket/task_notifications_test.go index 9dbe65c050..eba6e9afca 100644 --- a/apps/backend/internal/gateway/websocket/task_notifications_test.go +++ b/apps/backend/internal/gateway/websocket/task_notifications_test.go @@ -112,7 +112,7 @@ func TestTaskEventBroadcaster_NoDuplicateSubscriptions(t *testing.T) { // // Update this number when adding or removing event subscriptions in // RegisterTaskNotifications — it is intentionally exact. - const wantSubscriptions = 75 + const wantSubscriptions = 77 if got := len(b.subscriptions); got != wantSubscriptions { t.Errorf("RegisterTaskNotifications created %d subscriptions, want %d — "+ "did an event get subscribed twice?", got, wantSubscriptions) diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index 6db931eea6..6d2635436d 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -3651,12 +3651,22 @@ func (h *Handlers) deleteTaskMessageRollbackSession( if err != nil { return err } - if attachmentSvc := h.taskSvc.AttachmentService(); attachmentSvc != nil { - attachmentSvc.RemoveBytes(attachments) + if h.taskSvc != nil { + if attachmentSvc := h.taskSvc.AttachmentService(); attachmentSvc != nil { + attachmentSvc.RemoveBytes(attachments) + } } + } else if err := repo.DeleteTaskSession(ctx, session); err != nil { + return err + } + if h.eventBus == nil { return nil } - return repo.DeleteTaskSession(ctx, session) + return h.eventBus.Publish(ctx, events.SessionRemoved, bus.NewEvent( + events.SessionRemoved, + "mcp-handlers", + map[string]interface{}{"session_id": session.ID, "task_id": session.TaskID}, + )) } func (r taskMessageReviewRollback) primarySessionID() string { diff --git a/apps/backend/internal/office/testharness/routes.go b/apps/backend/internal/office/testharness/routes.go index d9888ee256..523bcb733a 100644 --- a/apps/backend/internal/office/testharness/routes.go +++ b/apps/backend/internal/office/testharness/routes.go @@ -51,10 +51,6 @@ const errInvalidJSONPrefix = "invalid JSON: " // duplicated again. const respKeyError = "error" -// messageCreatedAtKey is the WS event payload key for a seeded message's -// creation timestamp (see publishMessageAddedFallback). -const messageCreatedAtKey = "created_at" - // errJSON writes a `{"error": msg}` body with the given status code. func errJSON(c *gin.Context, code int, msg string) { c.JSON(code, gin.H{respKeyError: msg}) @@ -105,7 +101,11 @@ func RegisterRoutes( }) g.POST("/tasks", seedTaskHandler(repo, log)) g.POST("/task-sessions", seedTaskSessionHandler(repo, eventBus, log)) + g.DELETE("/task-sessions/:id", deleteTaskSessionHandler(repo, eventBus, log)) g.POST("/messages", seedMessageHandler(repo, taskSvc, eventBus, log)) + g.PATCH("/messages/:id", updateMessageHandler(repo, eventBus, log)) + g.DELETE("/messages/:id", deleteMessageHandler(repo, eventBus, log)) + g.POST("/turns/:id/complete", completeTurnHandler(repo, eventBus, log)) g.POST("/workflows", seedWorkflowHandler(repo, log)) g.PUT("/repositories/:id/git-remote", configureGitRemoteHandler(repo, log)) g.DELETE("/repositories/:id/git-remote", configureGitRemoteHandler(repo, log)) @@ -409,6 +409,34 @@ func statusForSeedSessionUpdateError(err error) int { return http.StatusInternalServerError } +func deleteTaskSessionHandler( + repo *sqliterepo.Repository, + eventBus bus.EventBus, + log *logger.Logger, +) gin.HandlerFunc { + return func(c *gin.Context) { + sessionID := c.Param("id") + session, err := repo.GetTaskSession(c.Request.Context(), sessionID) + if err != nil || session == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "task session not found"}) + return + } + if err := repo.DeleteTaskSession(c.Request.Context(), session); err != nil { + log.Error("delete seeded task session", zap.Error(err), zap.String("session_id", sessionID)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete task session"}) + return + } + if eventBus != nil { + _ = eventBus.Publish(c.Request.Context(), events.SessionRemoved, bus.NewEvent( + events.SessionRemoved, + "office-testharness", + map[string]interface{}{testSessionIDKey: session.ID, testTaskIDKey: session.TaskID}, + )) + } + c.JSON(http.StatusOK, gin.H{"success": true}) + } +} + func getExistingSeedSession(ctx context.Context, repo *sqliterepo.Repository, req *seedTaskSessionRequest) *models.TaskSession { if req.SessionID != "" { existing, _ := repo.GetTaskSession(ctx, req.SessionID) @@ -654,6 +682,16 @@ func publishSessionStateChanged(ctx context.Context, eventBus bus.EventBus, sess } } +// Event key constants keep the test mutation routes aligned with seed payloads. +const ( + messageCreatedAtKey = "created_at" + testSessionIDKey = "session_id" + testMessageIDKey = "message_id" + testCompletedAtKey = "completed_at" + testTaskIDKey = "task_id" + testTurnIDKey = "turn_id" +) + type seedMessageRequest struct { SessionID string `json:"session_id"` Type string `json:"type"` @@ -806,23 +844,15 @@ func seedMessageHandler( } } - // Prefer routing through the task service's own publish path (rather - // than a hand-rolled bus.Publish) so a seeded clarification/permission - // message gets the same session-scoped pending_action projection a - // real agent turn triggers (AC-34/AC-51) — the mobile session - // switcher's live icon reads that projection, not the raw message. - // Callers with no service handle (older/lighter-weight route tests) - // still get the plain message.added event via the fallback so their - // coverage of the raw event payload (prompt_index, created_at - // precision) keeps working. if taskSvc != nil { if err := taskSvc.PublishMessageEvent(ctx, events.MessageAdded, msg); err != nil { - log.Warn("test harness: publish message added failed", zap.Error(err)) + log.Warn("test harness: publish message added failed; using fallback", zap.Error(err)) + publishMessageAddedFallback(ctx, eventBus, msg, log) } } else { publishMessageAddedFallback(ctx, eventBus, msg, log) } - c.JSON(http.StatusOK, gin.H{"message_id": msg.ID}) + c.JSON(http.StatusOK, gin.H{testMessageIDKey: msg.ID, testTurnIDKey: msg.TurnID}) } } @@ -836,10 +866,10 @@ func publishMessageAddedFallback(ctx context.Context, eventBus bus.EventBus, msg return } data := map[string]interface{}{ - "message_id": msg.ID, - "session_id": msg.TaskSessionID, - "task_id": msg.TaskID, - "turn_id": msg.TurnID, + testMessageIDKey: msg.ID, + testSessionIDKey: msg.TaskSessionID, + testTaskIDKey: msg.TaskID, + testTurnIDKey: msg.TurnID, "author_type": string(msg.AuthorType), "content": msg.Content, "type": string(msg.Type), @@ -847,6 +877,7 @@ func publishMessageAddedFallback(ctx context.Context, eventBus bus.EventBus, msg // RFC3339Nano preserves fractional precision so seeded user prompts // order deterministically on the client. messageCreatedAtKey: msg.CreatedAt.Format(time.RFC3339Nano), + "updated_at": msg.UpdatedAt.Format(time.RFC3339Nano), } // User rows carry their stable prompt ordinal in the live WS event. if msg.PromptIndex > 0 { @@ -855,8 +886,128 @@ func publishMessageAddedFallback(ctx context.Context, eventBus bus.EventBus, msg if msg.Metadata != nil { data["metadata"] = msg.Metadata } - if err := eventBus.Publish(ctx, events.MessageAdded, bus.NewEvent(events.MessageAdded, "e2e-mock", data)); err != nil { - log.Warn("test harness: publish message added failed", zap.Error(err)) + publishMessageEvent(ctx, eventBus, events.MessageAdded, data, log) +} + +func publishMessageEvent( + ctx context.Context, + eventBus bus.EventBus, + eventType string, + data map[string]interface{}, + log *logger.Logger, +) { + if eventBus == nil { + return + } + if err := eventBus.Publish(ctx, eventType, bus.NewEvent(eventType, "e2e-mock", data)); err != nil { + log.Warn("test harness: publish message event failed", zap.Error(err)) + } +} + +func updateMessageHandler( + repo *sqliterepo.Repository, + eventBus bus.EventBus, + log *logger.Logger, +) gin.HandlerFunc { + return func(c *gin.Context) { + var request struct { + Content string `json:"content" binding:"required"` + } + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + message, err := repo.GetMessage(c.Request.Context(), c.Param("id")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) + return + } + message.Content = request.Content + message.UpdatedAt = time.Now().UTC() + if err := repo.UpdateMessage(c.Request.Context(), message); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + data := messageEventData(message) + publishMessageEvent(c.Request.Context(), eventBus, events.MessageUpdated, data, log) + c.JSON(http.StatusOK, gin.H{"message_id": message.ID, "updated_at": data["updated_at"]}) + } +} + +func deleteMessageHandler( + repo *sqliterepo.Repository, + eventBus bus.EventBus, + log *logger.Logger, +) gin.HandlerFunc { + return func(c *gin.Context) { + message, err := repo.GetMessage(c.Request.Context(), c.Param("id")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) + return + } + if err := repo.DeleteMessage(c.Request.Context(), message.ID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + data := messageEventData(message) + publishMessageEvent(c.Request.Context(), eventBus, events.MessageDeleted, data, log) + c.JSON(http.StatusOK, gin.H{testMessageIDKey: message.ID}) + } +} + +func messageEventData(message *models.Message) map[string]interface{} { + data := map[string]interface{}{ + testMessageIDKey: message.ID, testSessionIDKey: message.TaskSessionID, + testTaskIDKey: message.TaskID, testTurnIDKey: message.TurnID, + "author_type": string(message.AuthorType), "content": message.Content, + "type": string(message.Type), + messageCreatedAtKey: message.CreatedAt.Format(time.RFC3339Nano), + "updated_at": message.UpdatedAt.Format(time.RFC3339Nano), + } + if message.PromptIndex > 0 { + data["prompt_index"] = message.PromptIndex + } + if message.Metadata != nil { + data["metadata"] = message.Metadata + } + return data +} + +func completeTurnHandler( + repo *sqliterepo.Repository, + eventBus bus.EventBus, + log *logger.Logger, +) gin.HandlerFunc { + return func(c *gin.Context) { + turnID := c.Param("id") + if err := repo.CompleteTurn(c.Request.Context(), turnID); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "turn not found"}) + return + } + turn, err := repo.GetTurn(c.Request.Context(), turnID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + data := map[string]interface{}{ + "id": turn.ID, testSessionIDKey: turn.TaskSessionID, testTaskIDKey: turn.TaskID, + "started_at": turn.StartedAt.UTC().Format(time.RFC3339Nano), + testCompletedAtKey: nil, + "updated_at": turn.UpdatedAt.UTC().Format(time.RFC3339Nano), + } + if turn.CompletedAt != nil { + data[testCompletedAtKey] = turn.CompletedAt.UTC().Format(time.RFC3339Nano) + } + if eventBus != nil { + if err := eventBus.Publish( + c.Request.Context(), + events.TurnCompleted, + bus.NewEvent(events.TurnCompleted, "e2e-mock", data), + ); err != nil { + log.Warn("test harness: publish turn completion failed", zap.Error(err)) + } + } + c.JSON(http.StatusOK, gin.H{testTurnIDKey: turn.ID, testCompletedAtKey: turn.CompletedAt}) } } diff --git a/apps/backend/internal/orchestrator/event_handlers_agent.go b/apps/backend/internal/orchestrator/event_handlers_agent.go index 80a95176a3..88aefb02e6 100644 --- a/apps/backend/internal/orchestrator/event_handlers_agent.go +++ b/apps/backend/internal/orchestrator/event_handlers_agent.go @@ -924,8 +924,11 @@ func (s *Service) handleAgentReady(ctx context.Context, data watcher.AgentEventD return } - // Explicit agent-requested moves (move_task_kandev) take precedence over pending clarifications. + // Agent.ready is the authoritative turn boundary. Detach the request before + // publishing WAITING_FOR_INPUT so a client that immediately reloads sees the + // deferred-answer metadata in its first snapshot. if s.sessionHasPendingClarification(ctx, data.SessionID) { + s.detachClarificationWaiters(ctx, data.SessionID) s.logger.Info("deferring on_turn_complete while clarification is pending", zap.String("task_id", data.TaskID), zap.String("session_id", data.SessionID)) diff --git a/apps/backend/internal/orchestrator/event_handlers_clarification_pause_drain_test.go b/apps/backend/internal/orchestrator/event_handlers_clarification_pause_drain_test.go index 1f66d4d118..c92f7e51e5 100644 --- a/apps/backend/internal/orchestrator/event_handlers_clarification_pause_drain_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_clarification_pause_drain_test.go @@ -108,6 +108,9 @@ func TestPauseForClarificationInput_SameTurnBarrierUnchanged(t *testing.T) { agentMgr := &mockAgentManager{repoForExecutionLookup: repo} svc := createEngineService(t, repo, stepGetter, agentMgr) svc.turnService = &repoBackedTurnService{repo: repo} + svc.SetClarificationCanceller( + clarification.NewCanceller(clarification.NewStore(time.Minute), repo, nil, testLogger()), + ) // Manually call handleAgentReady without ever going through // PauseForClarificationInput — the same-turn barrier must hold. @@ -117,6 +120,13 @@ func TestPauseForClarificationInput_SameTurnBarrierUnchanged(t *testing.T) { if err != nil { t.Fatalf("get task: %v", err) } + clarificationMessage, err := repo.GetMessage(ctx, "clarification-s1") + if err != nil { + t.Fatalf("get clarification: %v", err) + } + if detached, _ := clarificationMessage.Metadata["agent_disconnected"].(bool); !detached { + t.Fatalf("clarification metadata = %#v, want agent_disconnected=true", clarificationMessage.Metadata) + } if task.WorkflowStepID != "step1" { t.Fatalf("same-turn barrier broken: workflow step = %q, want step1", task.WorkflowStepID) } diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index c5b2a87416..4aa13b9bc6 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -3555,7 +3555,7 @@ func (s *Service) prepareWorkflowReplacementSession( launchProfileID, err := s.resolveDynamicLaunchExecution(ctx, newSession, newAgentProfileID, true) if err != nil { resolutionErr := fmt.Errorf("failed to resolve workflow replacement profile: %w", err) - if deleteErr := s.deleteSessionAndCleanAttachments(ctx, newSession); deleteErr != nil { + if deleteErr := s.deleteSessionAndPublishRemoval(ctx, taskID, sessionID); deleteErr != nil { s.logger.Warn("failed to delete workflow replacement after profile resolution failure", zap.String("task_id", taskID), zap.String("session_id", sessionID), diff --git a/apps/backend/internal/orchestrator/session_delete_contract_test.go b/apps/backend/internal/orchestrator/session_delete_contract_test.go index cf0908cf28..18e5fe7530 100644 --- a/apps/backend/internal/orchestrator/session_delete_contract_test.go +++ b/apps/backend/internal/orchestrator/session_delete_contract_test.go @@ -5,6 +5,8 @@ import ( "testing" "time" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/task/models" ) @@ -72,3 +74,36 @@ func TestDeleteSession_PreservesTaskWorkspaceAndNeverEnqueuesCleanup(t *testing. t.Fatalf("cleanup jobs = %d, want 0 — session deletion must not reserve cleanup", jobCount) } } + +func TestDeleteSessionPublishesTerminalOrderedEvent(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task-terminal", "session-terminal", models.TaskSessionStateCompleted) + svc := createTestServiceWithAgent( + repo, + newMockStepGetter(), + newMockTaskRepo(), + &mockAgentManager{}, + ) + eventBus := bus.NewMemoryEventBus(testLogger()) + t.Cleanup(func() { eventBus.Close() }) + svc.eventBus = eventBus + var received map[string]interface{} + if _, err := svc.eventBus.Subscribe( + events.SessionRemoved, + func(_ context.Context, event *bus.Event) error { + received, _ = event.Data.(map[string]interface{}) + return nil + }, + ); err != nil { + t.Fatalf("subscribe: %v", err) + } + + if err := svc.DeleteSession(ctx, "session-terminal"); err != nil { + t.Fatalf("DeleteSession: %v", err) + } + + if received["session_id"] != "session-terminal" || received["task_id"] != "task-terminal" { + t.Fatalf("terminal event payload = %#v", received) + } +} diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 4b6f71d569..043254a8c5 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -1837,11 +1837,7 @@ func (s *Service) prepareSessionForStartWithWorkflowRoute( // session row. Compensate before returning so callers never observe a // partial sibling session when the required parent/group workspace is // unavailable. - createdSession, lookupErr := s.repo.GetTaskSession(ctx, sessionID) - if lookupErr != nil { - s.logger.Warn("failed to load inherited workspace session for compensation", - zap.String("session_id", sessionID), zap.Error(lookupErr)) - } else if deleteErr := s.deleteSessionAndCleanAttachments(ctx, createdSession); deleteErr != nil { + if deleteErr := s.deleteSessionAndPublishRemoval(ctx, task.ID, sessionID); deleteErr != nil { s.logger.Warn("failed to compensate inherited workspace session", zap.String("session_id", sessionID), zap.Error(deleteErr)) } @@ -4309,6 +4305,32 @@ func (s *Service) StopSessionSynchronously(ctx context.Context, sessionID string return s.executor.StopSessionSynchronously(ctx, sessionID, reason, force) } +// deleteSessionAndPublishRemoval commits a session deletion before publishing +// the terminal event consumed by ordered conversation subscribers. +func (s *Service) deleteSessionAndPublishRemoval(ctx context.Context, taskID, sessionID string) error { + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil { + return err + } + if err := s.deleteSessionAndCleanAttachments(ctx, session); err != nil { + return err + } + if s.eventBus != nil { + if err := s.eventBus.Publish(ctx, events.SessionRemoved, bus.NewEvent( + events.SessionRemoved, + "orchestrator", + map[string]interface{}{metaKeySessionID: sessionID, metaKeyTaskID: taskID}, + )); err != nil { + s.logger.Warn("session deleted but removal event publish failed", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.Error(err)) + } + } + return nil + +} + // DeleteSession deletes a session that is not currently running. func (s *Service) DeleteSession(ctx context.Context, sessionID string) error { if err := s.authorizeSession(ctx, sessionID); err != nil { @@ -4420,7 +4442,7 @@ func (s *Service) deleteSessionAndPublishError(ctx context.Context, session *mod lock.Lock() defer lock.Unlock() - if err := s.deleteSessionAndCleanAttachments(ctx, session); err != nil { + if err := s.deleteSessionAndPublishRemoval(ctx, session.TaskID, session.ID); err != nil { return err } s.publishDeletedSessionError(ctx, session.TaskID, session.ID) @@ -4457,7 +4479,6 @@ func (s *Service) publishDeletedSessionError(ctx context.Context, taskID, sessio zap.Error(err)) } } - func (s *Service) newestRetainedSessionError( ctx context.Context, taskID string, diff --git a/apps/backend/internal/plugins/conversation_handlers.go b/apps/backend/internal/plugins/conversation_handlers.go new file mode 100644 index 0000000000..5170bb1b59 --- /dev/null +++ b/apps/backend/internal/plugins/conversation_handlers.go @@ -0,0 +1,685 @@ +package plugins + +import ( + "context" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/plugins/store" + "github.com/kandev/kandev/internal/sysprompt" + taskmodels "github.com/kandev/kandev/internal/task/models" + taskservice "github.com/kandev/kandev/internal/task/service" +) + +const conversationNoStore = "no-store" + +// ConversationReader is the narrow task-service surface needed by browser +// plugin conversation reads. The task service applies workspace authorization. +type ConversationReader interface { + GetTaskSession(context.Context, string) (*taskmodels.TaskSession, error) + ListMessagesPaginated( + context.Context, + taskservice.ListMessagesRequest, + ) ([]*taskmodels.Message, bool, error) + ListTurnsBySession(context.Context, string) ([]*taskmodels.Turn, error) +} + +type conversationError struct { + Code string `json:"code"` + Message string `json:"message"` + Retryable bool `json:"retryable"` +} + +type conversationErrorEnvelope struct { + Error conversationError `json:"error"` +} + +type conversationBindingResponse struct { + BindingToken string `json:"bindingToken"` + Generation int64 `json:"generation"` + ExpiresAt time.Time `json:"expiresAt"` +} + +type conversationMessageDTO struct { + ID string `json:"id"` + TaskID *string `json:"taskId"` + SessionID string `json:"sessionId"` + TurnID *string `json:"turnId,omitempty"` + AuthorType string `json:"authorType"` + Type string `json:"type"` + Content string `json:"content"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + PromptIndex *int `json:"promptIndex,omitempty"` + SenderTaskID *string `json:"senderTaskId,omitempty"` +} + +func loadFallbackTurns(ctx context.Context, reader ConversationReader, sessionID, taskID string) ([]*taskmodels.Turn, error) { + turns, err := reader.ListTurnsBySession(ctx, sessionID) + if err != nil { + return nil, err + } + return filterTurnsByTask(turns, taskID), nil +} + +func filterTurnsByTask(turns []*taskmodels.Turn, taskID string) []*taskmodels.Turn { + if taskID == "" { + return turns + } + filtered := make([]*taskmodels.Turn, 0, len(turns)) + for _, turn := range turns { + if turn.TaskID == taskID { + filtered = append(filtered, turn) + } + } + return filtered +} + +type conversationMessagesResponse struct { + Messages []conversationMessageDTO `json:"messages"` + HasMore bool `json:"hasMore"` + Cursor *string `json:"cursor"` +} + +type conversationTurnDTO struct { + ID string `json:"id"` + TaskID *string `json:"taskId"` + SessionID string `json:"sessionId"` + StartedAt string `json:"startedAt"` + CompletedAt *string `json:"completedAt,omitempty"` + UpdatedAt string `json:"updatedAt"` +} + +type conversationTurnsResponse struct { + Turns []conversationTurnDTO `json:"turns"` +} + +type conversationContinuationRenewRequest struct { + Cursor string `json:"cursor"` + SnapshotToken string `json:"snapshot_token"` +} + +type conversationContinuationRenewResponse struct { + Cursor string `json:"cursor"` + SnapshotToken string `json:"snapshot_token"` + Cutoff int64 `json:"cutoff"` + Fingerprint string `json:"fingerprint"` + ExpiresAt time.Time `json:"expires_at"` +} +type conversationMessageQuery struct { + limit int + sort string + authorType string + authors []string + taskID *string + cursor string +} + +func registerConversationRoutes(api *gin.RouterGroup, ctrl *Controller) { + api.GET("/:id/conversation/binding", ctrl.conversationBinding) + api.GET("/:id/conversation/task-sessions/:sessionId/messages", ctrl.conversationMessages) + api.GET("/:id/conversation/task-sessions/:sessionId/turns", ctrl.conversationTurns) + api.POST("/:id/conversation/continuation/renew", ctrl.conversationContinuationRenew) +} + +func (c *Controller) conversationBinding(ctx *gin.Context) { + ctx.Header("Cache-Control", conversationNoStore) + identity, ok := authn.IdentityFromContext(ctx.Request.Context()) + if !ok { + writeConversationError(ctx, http.StatusUnauthorized, "unauthenticated", "authentication required", false) + return + } + record, ok := c.conversationRecord(ctx.Param("id")) + if !ok { + writeConversationError(ctx, http.StatusNotFound, "not_found", "plugin not found", false) + return + } + generation := conversationGeneration(record.InstalledAt) + token, expiresAt, err := c.conversationTokens.mintBinding(record.ID, identity.UserID, generation) + if err != nil { + c.log.Error("mint plugin conversation binding", zap.Error(err)) + writeConversationError(ctx, http.StatusInternalServerError, "upstream_failure", "conversation service unavailable", true) + return + } + ctx.JSON(http.StatusOK, conversationBindingResponse{ + BindingToken: token, + Generation: generation, + ExpiresAt: expiresAt, + }) +} + +func (c *Controller) conversationRecord(pluginID string) (*store.Record, bool) { + record, err := c.svc.Get(pluginID) + if err != nil || record.Status != StatusActive || !record.Capabilities.CanRead("messages") { + return nil, false + } + return record, true +} + +//nolint:cyclop,goconst // This handler owns authorization, snapshot validation, pagination, and cursor minting. +func (c *Controller) conversationMessages(ctx *gin.Context) { + ctx.Header("Cache-Control", conversationNoStore) + record, identity, ok := c.authorizeConversationRequest(ctx) + if !ok || !c.validBinding(ctx, record, identity.UserID) { + return + } + query, ok := parseConversationMessageQuery(ctx) + if !ok { + return + } + if c.conversationReader == nil { + writeConversationError(ctx, http.StatusInternalServerError, "upstream_failure", "conversation service unavailable", true) + return + } + sessionID := ctx.Param("sessionId") + snapshot, ok := c.validSnapshot(ctx, record, identity.UserID, sessionID) + if !ok { + return + } + session, err := c.conversationReader.GetTaskSession(ctx.Request.Context(), sessionID) + if err != nil || session == nil || session.ID != sessionID { + if !c.svc.isSessionRemoved(sessionID) { + writeConversationError(ctx, http.StatusNotFound, "not_found", "task session not found", false) + return + } + } else if query.taskID != nil && *query.taskID != session.TaskID { + writeConversationError(ctx, http.StatusBadRequest, "invalid_query", "task_id does not match the task session", false) + return + } + request := taskservice.ListMessagesRequest{ + TaskSessionID: sessionID, + Limit: query.limit, + Sort: query.sort, + AuthorType: query.authorType, + // Parity with the journal branch: multi-author filters and an + // explicit task_id must narrow the fallback page the same way. + AuthorTypes: query.authors, + TaskID: optionalStringValue(query.taskID), + } + if !c.applyConversationMessageCursor(ctx, record, identity.UserID, sessionID, query, snapshot, &request) { + return + } + var messages []*taskmodels.Message + var hasMore bool + if c.svc.HasConversationJournal() { + cursorID := request.After + if query.sort == "desc" { + cursorID = request.Before + } + messages, hasMore, err = c.svc.conversationMessagesAt( + ctx.Request.Context(), sessionID, uint64(snapshot.Cutoff), + query.taskID, query.authors, query.sort, cursorID, query.limit, + ) + } else { + messages, hasMore, err = c.conversationReader.ListMessagesPaginated(ctx.Request.Context(), request) + } + if err != nil { + writeConversationError(ctx, http.StatusInternalServerError, "upstream_failure", "conversation service unavailable", true) + return + } + response, ok := c.buildConversationMessagesResponse( + ctx, + record, + identity.UserID, + sessionID, + query, + snapshot, + messages, + hasMore, + ) + if !ok { + return + } + ctx.JSON(http.StatusOK, response) +} +func (c *Controller) applyConversationMessageCursor( + ctx *gin.Context, + record *store.Record, + userID string, + sessionID string, + query conversationMessageQuery, + snapshot conversationTokenClaims, + request *taskservice.ListMessagesRequest, +) bool { + if query.cursor == "" { + return true + } + claims, err := c.conversationTokens.parse(query.cursor) + if err != nil || !validConversationMessageCursor(claims, record, userID, sessionID, query, snapshot) { + writeConversationError(ctx, http.StatusBadRequest, "invalid_query", "invalid cursor", false) + return false + } + if query.sort == "asc" { + request.After = claims.LastID + } else { + request.Before = claims.LastID + } + return true +} + +func validConversationMessageCursor( + claims conversationTokenClaims, + record *store.Record, + userID string, + sessionID string, + query conversationMessageQuery, + snapshot conversationTokenClaims, +) bool { + return claims.Kind == tokenKindCursor && + claims.PluginID == record.ID && + claims.UserID == userID && + claims.Generation == conversationGeneration(record.InstalledAt) && + claims.SessionID == sessionID && + equalOptionalString(claims.TaskID, query.taskID) && + claims.Sort == query.sort && + equalStrings(claims.Authors, query.authors) && + claims.LastID != "" && + claims.Cutoff == snapshot.Cutoff && + claims.Fingerprint == snapshot.Fingerprint +} + +func (c *Controller) buildConversationMessagesResponse( + ctx *gin.Context, + record *store.Record, + userID string, + sessionID string, + query conversationMessageQuery, + snapshot conversationTokenClaims, + messages []*taskmodels.Message, + hasMore bool, +) (conversationMessagesResponse, bool) { + response := conversationMessagesResponse{ + Messages: make([]conversationMessageDTO, 0, len(messages)), + HasMore: hasMore, + } + for _, message := range messages { + response.Messages = append(response.Messages, conversationMessageModelToDTO(message)) + } + if !hasMore { + return response, true + } + if len(messages) == 0 { + writeConversationError(ctx, http.StatusInternalServerError, "upstream_failure", "conversation service returned an invalid page", true) + return conversationMessagesResponse{}, false + } + cursor, err := c.conversationTokens.mintCursor( + record.ID, + userID, + conversationGeneration(record.InstalledAt), + sessionID, + query.taskID, + query.sort, + query.authors, + messages[len(messages)-1].ID, + snapshot.Cutoff, + snapshot.Fingerprint, + ) + if err != nil { + writeConversationError(ctx, http.StatusInternalServerError, "upstream_failure", "conversation service unavailable", true) + return conversationMessagesResponse{}, false + } + response.Cursor = &cursor + return response, true +} + +//nolint:cyclop // Turn lookup supports primary, task-scoped, and fallback sources. +func (c *Controller) conversationTurns(ctx *gin.Context) { + ctx.Header("Cache-Control", conversationNoStore) + record, identity, ok := c.authorizeConversationRequest(ctx) + if !ok || !c.validBinding(ctx, record, identity.UserID) { + return + } + if c.conversationReader == nil { + writeConversationError(ctx, http.StatusInternalServerError, "upstream_failure", "conversation service unavailable", true) + return + } + sessionID := ctx.Param("sessionId") + snapshot, ok := c.validSnapshot(ctx, record, identity.UserID, sessionID) + if !ok { + return + } + var taskID *string + if rawTaskID, exists := ctx.GetQuery("task_id"); exists && rawTaskID != nullJSONValue { + if rawTaskID == "" { + writeConversationError(ctx, http.StatusBadRequest, "invalid_query", "task_id does not match the task session", false) + return + } + taskID = &rawTaskID + } + session, err := c.conversationReader.GetTaskSession(ctx.Request.Context(), sessionID) + sessionFound := err == nil && session != nil && session.ID == sessionID + if !sessionFound { + if !c.svc.isSessionRemoved(sessionID) { + writeConversationError(ctx, http.StatusNotFound, "not_found", "task session not found", false) + return + } + } else if taskID != nil && *taskID != session.TaskID { + writeConversationError(ctx, http.StatusBadRequest, "invalid_query", "task_id does not match the task session", false) + return + } + var scopedTaskID *string + if taskID != nil { + scopedTaskID = taskID + } else if sessionFound { + scopedTaskID = &session.TaskID + } + var turns []*taskmodels.Turn + if c.svc.HasConversationJournal() { + turns, err = c.svc.conversationTurnsAt(ctx.Request.Context(), sessionID, uint64(snapshot.Cutoff), scopedTaskID) + } else { + // The no-journal fallback must honor the requested/inherited task + // scope like the journal branch: a session that ever accumulated + // mixed-task turn rows must not leak turns outside the validated task. + turns, err = loadFallbackTurns(ctx.Request.Context(), c.conversationReader, sessionID, *scopedTaskID) + } + if err != nil { + writeConversationError(ctx, http.StatusInternalServerError, "upstream_failure", "conversation service unavailable", true) + return + } + response := conversationTurnsResponse{Turns: make([]conversationTurnDTO, 0, len(turns))} + for _, turn := range turns { + response.Turns = append(response.Turns, conversationTurnModelToDTO(turn)) + } + ctx.JSON(http.StatusOK, response) +} + +func (c *Controller) conversationContinuationRenew(ctx *gin.Context) { + ctx.Header("Cache-Control", conversationNoStore) + record, identity, ok := c.authorizeConversationRequest(ctx) + if !ok || !c.validBinding(ctx, record, identity.UserID) { + return + } + var request conversationContinuationRenewRequest + if err := ctx.ShouldBindJSON(&request); err != nil || + request.Cursor == "" || + request.SnapshotToken == "" { + writeConversationError(ctx, http.StatusBadRequest, "invalid_query", "cursor and snapshot_token are required", false) + return + } + cursorClaims, cursorErr := c.conversationTokens.parse(request.Cursor) + snapshotClaims, snapshotErr := c.conversationTokens.parse(request.SnapshotToken) + if !validContinuationClaims(cursorClaims, snapshotClaims, cursorErr, snapshotErr) { + writeConversationError(ctx, http.StatusBadRequest, "invalid_query", "invalid continuation", false) + return + } + if cursorClaims.PluginID != record.ID || + cursorClaims.UserID != identity.UserID || + cursorClaims.Generation != conversationGeneration(record.InstalledAt) { + writeConversationError(ctx, http.StatusConflict, "upstream_failure", "conversation generation changed", true) + return + } + cursor, expiresAt, err := c.conversationTokens.renew(cursorClaims) + if err != nil { + writeConversationError(ctx, http.StatusInternalServerError, "upstream_failure", "conversation service unavailable", true) + return + } + snapshotToken, _, err := c.conversationTokens.renew(snapshotClaims) + if err != nil { + writeConversationError(ctx, http.StatusInternalServerError, "upstream_failure", "conversation service unavailable", true) + return + } + ctx.JSON(http.StatusOK, conversationContinuationRenewResponse{ + Cursor: cursor, + SnapshotToken: snapshotToken, + Cutoff: cursorClaims.Cutoff, + Fingerprint: cursorClaims.Fingerprint, + ExpiresAt: expiresAt, + }) +} + +func (c *Controller) authorizeConversationRequest( + ctx *gin.Context, +) (*store.Record, authn.Identity, bool) { + identity, ok := authn.IdentityFromContext(ctx.Request.Context()) + if !ok { + writeConversationError(ctx, http.StatusUnauthorized, "unauthenticated", "authentication required", false) + return nil, authn.Identity{}, false + } + record, ok := c.conversationRecord(ctx.Param("id")) + if !ok { + writeConversationError(ctx, http.StatusNotFound, "not_found", "plugin not found", false) + return nil, authn.Identity{}, false + } + return record, identity, true +} + +func (c *Controller) validBinding(ctx *gin.Context, record *store.Record, userID string) bool { + claims, err := c.conversationTokens.parse(ctx.GetHeader("X-Kandev-Plugin-Binding")) + if err != nil || + claims.Kind != tokenKindBinding || + claims.PluginID != record.ID || + claims.UserID != userID || + claims.Generation != conversationGeneration(record.InstalledAt) { + writeConversationError(ctx, http.StatusUnauthorized, "unauthenticated", "invalid conversation binding", false) + return false + } + return true +} + +func (c *Controller) validSnapshot( + ctx *gin.Context, + record *store.Record, + userID string, + sessionID string, +) (conversationTokenClaims, bool) { + claims, err := c.conversationTokens.parse(ctx.GetHeader("X-Kandev-Snapshot-Token")) + if err != nil || + claims.Kind != tokenKindSnapshot || + claims.PluginID != record.ID || + claims.UserID != userID || + claims.Generation != conversationGeneration(record.InstalledAt) || + claims.SessionID != sessionID { + writeConversationError(ctx, http.StatusBadRequest, "invalid_query", "invalid snapshot token", false) + return conversationTokenClaims{}, false + } + return claims, true +} + +func parseConversationMessageQuery(ctx *gin.Context) (conversationMessageQuery, bool) { + limit, ok := parseConversationMessageLimit(ctx) + if !ok { + return conversationMessageQuery{}, false + } + sortOrder, ok := parseConversationMessageSort(ctx) + if !ok { + return conversationMessageQuery{}, false + } + authors, authorType, ok := parseConversationMessageAuthors(ctx) + if !ok { + return conversationMessageQuery{}, false + } + taskID, ok := parseOptionalConversationQuery(ctx, "task_id") + if !ok { + return conversationMessageQuery{}, false + } + cursor, ok := parseOptionalConversationQuery(ctx, "cursor") + if !ok { + return conversationMessageQuery{}, false + } + if taskID != nil && *taskID == nullJSONValue { + taskID = nil + } + return conversationMessageQuery{ + limit: limit, sort: sortOrder, authorType: authorType, + authors: authors, taskID: taskID, cursor: optionalStringValue(cursor), + }, true +} + +func parseConversationMessageLimit(ctx *gin.Context) (int, bool) { + raw, exists := ctx.GetQuery("limit") + if !exists { + return 20, true + } + limit, err := strconv.Atoi(raw) + if err != nil || limit < 1 || limit > 100 { + writeConversationError(ctx, http.StatusBadRequest, "invalid_query", "limit must be between 1 and 100", false) + return 0, false + } + return limit, true +} + +func parseConversationMessageSort(ctx *gin.Context) (string, bool) { + raw, exists := ctx.GetQuery("sort") + if !exists { + return "desc", true + } + if raw != "asc" && raw != "desc" { + writeConversationError(ctx, http.StatusBadRequest, "invalid_query", "sort must be asc or desc", false) + return "", false + } + return raw, true +} + +func parseConversationMessageAuthors(ctx *gin.Context) ([]string, string, bool) { + authors := ctx.QueryArray("author_type") + if len(authors) > 2 { + writeConversationError(ctx, http.StatusBadRequest, "invalid_query", "unsupported author_type", false) + return nil, "", false + } + seen := make(map[string]struct{}, len(authors)) + for _, author := range authors { + if author != string(taskmodels.MessageAuthorUser) && author != string(taskmodels.MessageAuthorAgent) { + writeConversationError(ctx, http.StatusBadRequest, "invalid_query", "unsupported author_type", false) + return nil, "", false + } + if _, duplicate := seen[author]; duplicate { + writeConversationError(ctx, http.StatusBadRequest, "invalid_query", "duplicate author_type", false) + return nil, "", false + } + seen[author] = struct{}{} + } + if len(authors) == 1 { + return authors, authors[0], true + } + return authors, "", true +} + +func parseOptionalConversationQuery(ctx *gin.Context, name string) (*string, bool) { + raw, exists := ctx.GetQuery(name) + if !exists { + return nil, true + } + if raw == "" { + writeConversationError(ctx, http.StatusBadRequest, "invalid_query", name+" must not be empty", false) + return nil, false + } + return &raw, true +} + +func optionalStringValue(value *string) string { + if value == nil { + return "" + } + return *value +} + +func conversationMessageModelToDTO(message *taskmodels.Message) conversationMessageDTO { + messageType := string(message.Type) + if messageType == "" { + messageType = string(taskmodels.MessageTypeMessage) + } + updatedAt := message.UpdatedAt + if updatedAt.IsZero() { + updatedAt = message.CreatedAt + } + dto := conversationMessageDTO{ + ID: message.ID, + TaskID: nonEmptyStringPointer(message.TaskID), + SessionID: message.TaskSessionID, + TurnID: nonEmptyStringPointer(message.TurnID), + AuthorType: string(message.AuthorType), + Type: messageType, + Content: sysprompt.StripSystemContent(message.Content), + CreatedAt: message.CreatedAt.UTC().Format(time.RFC3339Nano), + UpdatedAt: updatedAt.UTC().Format(time.RFC3339Nano), + } + if message.PromptIndex > 0 { + dto.PromptIndex = &message.PromptIndex + } + if senderTaskID, ok := message.Metadata["sender_task_id"].(string); ok && senderTaskID != "" { + dto.SenderTaskID = &senderTaskID + } + return dto +} + +func conversationTurnModelToDTO(turn *taskmodels.Turn) conversationTurnDTO { + updatedAt := turn.UpdatedAt + if updatedAt.IsZero() { + updatedAt = turn.StartedAt + } + dto := conversationTurnDTO{ + ID: turn.ID, + TaskID: nonEmptyStringPointer(turn.TaskID), + SessionID: turn.TaskSessionID, + StartedAt: turn.StartedAt.UTC().Format(time.RFC3339Nano), + UpdatedAt: updatedAt.UTC().Format(time.RFC3339Nano), + } + if turn.CompletedAt != nil { + completedAt := turn.CompletedAt.UTC().Format(time.RFC3339Nano) + dto.CompletedAt = &completedAt + } + return dto +} + +func validContinuationClaims( + cursor conversationTokenClaims, + snapshot conversationTokenClaims, + cursorErr error, + snapshotErr error, +) bool { + return cursorErr == nil && + snapshotErr == nil && + cursor.Kind == tokenKindCursor && + snapshot.Kind == tokenKindSnapshot && + matchingContinuationClaims(cursor, snapshot) +} + +func matchingContinuationClaims( + cursor conversationTokenClaims, + snapshot conversationTokenClaims, +) bool { + return cursor.PluginID == snapshot.PluginID && + cursor.UserID == snapshot.UserID && + cursor.Generation == snapshot.Generation && + cursor.SessionID == snapshot.SessionID && + cursor.Cutoff == snapshot.Cutoff && + cursor.Fingerprint != "" && + cursor.Fingerprint == snapshot.Fingerprint +} + +func nonEmptyStringPointer(value string) *string { + if value == "" { + return nil + } + return &value +} + +func equalOptionalString(left, right *string) bool { + if left == nil || right == nil { + return left == nil && right == nil + } + return *left == *right +} + +func equalStrings(left, right []string) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if left[i] != right[i] { + return false + } + } + return true +} + +func writeConversationError(ctx *gin.Context, status int, code, message string, retryable bool) { + ctx.JSON(status, conversationErrorEnvelope{Error: conversationError{ + Code: code, Message: message, Retryable: retryable, + }}) +} diff --git a/apps/backend/internal/plugins/conversation_handlers_test.go b/apps/backend/internal/plugins/conversation_handlers_test.go new file mode 100644 index 0000000000..6930603684 --- /dev/null +++ b/apps/backend/internal/plugins/conversation_handlers_test.go @@ -0,0 +1,606 @@ +package plugins + +import ( + "context" + "encoding/json" + "github.com/gin-gonic/gin" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/plugins/manifest" + "github.com/kandev/kandev/internal/plugins/store" + taskmodels "github.com/kandev/kandev/internal/task/models" + taskservice "github.com/kandev/kandev/internal/task/service" +) + +// @covers AC-PLUGINS-PROMPT-HISTORY-HOST-002.7 +func TestConversationBindingRequiresAuthentication(t *testing.T) { + router, _ := newTestRouter(t) + + response := doRequest( + router, + http.MethodGet, + "/api/plugins/kandev-plugin-history/conversation/binding", + "", + nil, + ) + + require.Equal(t, http.StatusUnauthorized, response.Code) + require.JSONEq(t, `{"error":{"code":"unauthenticated","message":"authentication required","retryable":false}}`, response.Body.String()) + require.Equal(t, "no-store", response.Header().Get("Cache-Control")) +} + +// @covers AC-PLUGINS-PROMPT-HISTORY-HOST-002.7 +func TestConversationBindingHidesUnavailablePlugins(t *testing.T) { + router, service := newTestRouter(t) + service.registry.Add(&store.Record{ + Manifest: manifest.Manifest{ + ID: "kandev-plugin-history", + Capabilities: manifest.Capabilities{}, + }, + Status: StatusActive, + InstalledAt: time.Now().UTC(), + }) + + response := doAuthedRequest( + router, + http.MethodGet, + "/api/plugins/kandev-plugin-history/conversation/binding", + "", + nil, + ) + + require.Equal(t, http.StatusNotFound, response.Code) + require.JSONEq(t, `{"error":{"code":"not_found","message":"plugin not found","retryable":false}}`, response.Body.String()) + require.Equal(t, "no-store", response.Header().Get("Cache-Control")) +} + +// @covers AC-PLUGINS-PROMPT-HISTORY-HOST-002.13 +func TestConversationBindingReturnsShortLivedGenerationGrant(t *testing.T) { + router, service := newTestRouter(t) + installedAt := time.Date(2026, 9, 7, 12, 0, 0, 123, time.UTC) + service.registry.Add(&store.Record{ + Manifest: manifest.Manifest{ + ID: "kandev-plugin-history", + Capabilities: manifest.Capabilities{ + APIRead: []string{"messages"}, + }, + }, + Status: StatusActive, + InstalledAt: installedAt, + }) + + response := doAuthedRequest( + router, + http.MethodGet, + "/api/plugins/kandev-plugin-history/conversation/binding", + "", + nil, + ) + + require.Equal(t, http.StatusOK, response.Code) + var body struct { + BindingToken string `json:"bindingToken"` + Generation int64 `json:"generation"` + ExpiresAt time.Time `json:"expiresAt"` + } + require.NoError(t, json.Unmarshal(response.Body.Bytes(), &body)) + require.NotEmpty(t, body.BindingToken) + require.Equal(t, conversationGeneration(installedAt), body.Generation) + require.LessOrEqual(t, body.Generation, int64(1<<53-1)) + require.WithinDuration(t, time.Now().UTC().Add(10*time.Minute), body.ExpiresAt, time.Second) + require.LessOrEqual(t, body.ExpiresAt.Sub(time.Now().UTC()), 10*time.Minute) +} + +// @covers AC-PLUGINS-PROMPT-HISTORY-HOST-002.7 +func TestConversationReadsRequireAuthenticationBeforeLookup(t *testing.T) { + router, _ := newTestRouter(t) + + for _, path := range []string{ + "/api/plugins/kandev-plugin-history/conversation/task-sessions/session-1/messages", + "/api/plugins/kandev-plugin-history/conversation/task-sessions/session-1/turns", + } { + response := doRequest(router, http.MethodGet, path, "", nil) + require.Equal(t, http.StatusUnauthorized, response.Code, path) + require.JSONEq(t, `{"error":{"code":"unauthenticated","message":"authentication required","retryable":false}}`, response.Body.String()) + } +} + +// @covers AC-PLUGINS-PROMPT-HISTORY-HOST-002.7 +func TestConversationReadsRejectJournalFallbackForUnauthorizedLiveSession(t *testing.T) { + _, service := newTestRouter(t) + service.SetConversationJournalDB(newTestPool(t).Writer()) + service.registry.Add(conversationPluginRecord("kandev-plugin-history", time.Now().UTC())) + router := registerPluginRoutesWithIdentity( + t, + service, + authn.Identity{UserID: "user_1", Role: authn.RoleMember}, + &fakeConversationReader{}, + ) + headers := conversationReadHeaders(t, service, router, "kandev-plugin-history", "session-1") + + for _, path := range []string{ + "/api/plugins/kandev-plugin-history/conversation/task-sessions/session-1/messages", + "/api/plugins/kandev-plugin-history/conversation/task-sessions/session-1/turns", + } { + response := doAuthedRequest(router, http.MethodGet, path, "", headers) + require.Equal(t, http.StatusNotFound, response.Code, path) + require.JSONEq(t, `{"error":{"code":"not_found","message":"task session not found","retryable":false}}`, response.Body.String(), path) + } +} + +type fakeConversationReader struct { + session *taskmodels.TaskSession + messages []*taskmodels.Message + turns []*taskmodels.Turn + hasMore bool + request taskservice.ListMessagesRequest +} + +func (f *fakeConversationReader) GetTaskSession( + _ context.Context, + _ string, +) (*taskmodels.TaskSession, error) { + return f.session, nil +} + +func (f *fakeConversationReader) ListMessagesPaginated( + _ context.Context, + request taskservice.ListMessagesRequest, +) ([]*taskmodels.Message, bool, error) { + f.request = request + return f.messages, f.hasMore, nil +} + +func (f *fakeConversationReader) ListTurnsBySession( + _ context.Context, + _ string, +) ([]*taskmodels.Turn, error) { + return f.turns, nil +} + +// @covers AC-PLUGINS-PROMPT-HISTORY-HOST-002.1 +// @covers AC-PLUGINS-PROMPT-HISTORY-HOST-002.2 +// @covers AC-PLUGINS-PROMPT-HISTORY-HOST-002.3 +// @covers AC-PLUGINS-PROMPT-HISTORY-HOST-002.4 +// @covers AC-PLUGINS-PROMPT-HISTORY-HOST-002.12 +func TestConversationMessagesReturnSanitizedNarrowDTOs(t *testing.T) { + created := time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) + reader := &fakeConversationReader{ + session: &taskmodels.TaskSession{ID: "session-1", TaskID: "task-1"}, + messages: []*taskmodels.Message{{ + ID: "message-1", + TaskSessionID: "session-1", + TaskID: "task-1", + TurnID: "turn-1", + AuthorType: taskmodels.MessageAuthorUser, + Content: "hiddenVisible prompt.", + Metadata: map[string]any{ + "sender_task_id": "sender-task", + "sender_task_title": "must not leak", + "raw_content": "must not leak", + }, + CreatedAt: created, + PromptIndex: 3, + }}, + } + _, service := newTestRouter(t) + service.registry.Add(conversationPluginRecord("kandev-plugin-history", created)) + router := registerPluginRoutesWithIdentity( + t, + service, + authn.Identity{UserID: "user_1", Role: authn.RoleMember}, + reader, + ) + headers := conversationReadHeaders(t, service, router, "kandev-plugin-history", "session-1") + + response := doAuthedRequest( + router, + http.MethodGet, + "/api/plugins/kandev-plugin-history/conversation/task-sessions/session-1/messages?task_id=task-1&author_type=user&sort=asc&limit=25", + "", + headers, + ) + + require.Equal(t, http.StatusOK, response.Code, response.Body.String()) + require.JSONEq(t, `{ + "messages":[{ + "id":"message-1", + "taskId":"task-1", + "sessionId":"session-1", + "turnId":"turn-1", + "authorType":"user", + "type":"message", + "content":"Visible prompt.", + "createdAt":"2026-09-07T12:00:00Z", + "updatedAt":"2026-09-07T12:00:00Z", + "promptIndex":3, + "senderTaskId":"sender-task" + }], + "hasMore":false, + "cursor":null + }`, response.Body.String()) + require.Equal(t, "session-1", reader.request.TaskSessionID) + require.Equal(t, "asc", reader.request.Sort) + require.Equal(t, "user", reader.request.AuthorType) + require.Equal(t, 25, reader.request.Limit) +} + +// @covers AC-PLUGINS-PROMPT-HISTORY-HOST-002.2 +// @covers AC-PLUGINS-PROMPT-HISTORY-HOST-002.5 +func TestConversationMessageCursorIsOpaqueAndQueryBound(t *testing.T) { + created := time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) + reader := &fakeConversationReader{ + session: &taskmodels.TaskSession{ID: "session-1", TaskID: "task-1"}, + messages: []*taskmodels.Message{{ + ID: "message-1", + TaskSessionID: "session-1", + TaskID: "task-1", + AuthorType: taskmodels.MessageAuthorUser, + Content: "Visible prompt.", + CreatedAt: created, + }}, + hasMore: true, + } + _, service := newTestRouter(t) + service.registry.Add(conversationPluginRecord("kandev-plugin-history", created)) + router := registerPluginRoutesWithIdentity( + t, + service, + authn.Identity{UserID: "user_1", Role: authn.RoleMember}, + reader, + ) + headers := conversationReadHeaders(t, service, router, "kandev-plugin-history", "session-1") + + first := doAuthedRequest( + router, + http.MethodGet, + "/api/plugins/kandev-plugin-history/conversation/task-sessions/session-1/messages?task_id=task-1&author_type=user&sort=desc&limit=1", + "", + headers, + ) + require.Equal(t, http.StatusOK, first.Code, first.Body.String()) + var page conversationMessagesResponse + require.NoError(t, json.Unmarshal(first.Body.Bytes(), &page)) + require.True(t, page.HasMore) + require.NotNil(t, page.Cursor) + require.NotContains(t, *page.Cursor, "message-1") + + changedQuery := doAuthedRequest( + router, + http.MethodGet, + "/api/plugins/kandev-plugin-history/conversation/task-sessions/session-1/messages?task_id=task-1&author_type=agent&sort=desc&limit=1&cursor="+*page.Cursor, + "", + headers, + ) + require.Equal(t, http.StatusBadRequest, changedQuery.Code) + require.JSONEq(t, `{"error":{"code":"invalid_query","message":"invalid cursor","retryable":false}}`, changedQuery.Body.String()) + + tampered := doAuthedRequest( + router, + http.MethodGet, + "/api/plugins/kandev-plugin-history/conversation/task-sessions/session-1/messages?task_id=task-1&author_type=user&sort=desc&limit=1&cursor="+*page.Cursor+"x", + "", + headers, + ) + require.Equal(t, http.StatusBadRequest, tampered.Code) +} + +// @covers AC-PLUGINS-PROMPT-HISTORY-HOST-002.4 +// @covers AC-PLUGINS-PROMPT-HISTORY-HOST-002.13 +func TestConversationStreamGrantFirstPageRenewsQueryBoundCursor(t *testing.T) { + installedAt := time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) + reader := &fakeConversationReader{ + session: &taskmodels.TaskSession{ID: "session-1", TaskID: "task-1"}, + messages: []*taskmodels.Message{{ + ID: "message-1", + TaskSessionID: "session-1", + TaskID: "task-1", + AuthorType: taskmodels.MessageAuthorUser, + Content: "Visible prompt.", + CreatedAt: installedAt, + }}, + hasMore: true, + } + _, service := newTestRouter(t) + service.registry.Add(conversationPluginRecord("kandev-plugin-history", installedAt)) + router := registerPluginRoutesWithIdentity( + t, + service, + authn.Identity{UserID: "user_1", Role: authn.RoleMember}, + reader, + ) + binding := conversationBindingToken(t, router, "kandev-plugin-history") + snapshot, _, _, err := service.MintSessionStreamGrant( + "kandev-plugin-history", + "user_1", + conversationGeneration(installedAt), + "session-1", + "consumer-1", + "", + 0, + 0, + ) + require.NoError(t, err) + headers := map[string]string{ + "X-Kandev-Plugin-Binding": binding, + "X-Kandev-Snapshot-Token": snapshot, + } + + first := doAuthedRequest( + router, + http.MethodGet, + "/api/plugins/kandev-plugin-history/conversation/task-sessions/session-1/messages?task_id=task-1&author_type=user&sort=desc&limit=1", + "", + headers, + ) + require.Equal(t, http.StatusOK, first.Code, first.Body.String()) + var page conversationMessagesResponse + require.NoError(t, json.Unmarshal(first.Body.Bytes(), &page)) + require.NotNil(t, page.Cursor) + + renewed := doAuthedRequest( + router, + http.MethodPost, + "/api/plugins/kandev-plugin-history/conversation/continuation/renew", + `{"cursor":"`+*page.Cursor+`","snapshot_token":"`+snapshot+`"}`, + map[string]string{ + "Content-Type": "application/json", + "X-Kandev-Plugin-Binding": binding, + }, + ) + require.Equal(t, http.StatusOK, renewed.Code, renewed.Body.String()) + var continuation conversationContinuationRenewResponse + require.NoError(t, json.Unmarshal(renewed.Body.Bytes(), &continuation)) + require.NotEqual(t, *page.Cursor, continuation.Cursor) + require.NotEqual(t, snapshot, continuation.SnapshotToken) +} + +// @covers AC-PLUGINS-PROMPT-HISTORY-HOST-002.1 +// @covers AC-PLUGINS-PROMPT-HISTORY-HOST-002.3 +func TestConversationTurnsReturnNarrowNullableDTOs(t *testing.T) { + started := time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) + completed := started.Add(time.Minute) + reader := &fakeConversationReader{ + session: &taskmodels.TaskSession{ID: "session-1", TaskID: "task-1"}, + turns: []*taskmodels.Turn{{ + ID: "turn-1", + TaskSessionID: "session-1", + TaskID: "task-1", + StartedAt: started, + CompletedAt: &completed, + Metadata: map[string]any{"secret": "must not leak"}, + }}, + } + _, service := newTestRouter(t) + service.registry.Add(conversationPluginRecord("kandev-plugin-history", started)) + router := registerPluginRoutesWithIdentity( + t, + service, + authn.Identity{UserID: "user_1", Role: authn.RoleMember}, + reader, + ) + headers := conversationReadHeaders(t, service, router, "kandev-plugin-history", "session-1") + + response := doAuthedRequest( + router, + http.MethodGet, + "/api/plugins/kandev-plugin-history/conversation/task-sessions/session-1/turns", + "", + headers, + ) + + require.Equal(t, http.StatusOK, response.Code, response.Body.String()) + require.JSONEq(t, `{"turns":[{ + "id":"turn-1", + "taskId":"task-1", + "sessionId":"session-1", + "startedAt":"2026-09-07T12:00:00Z", + "completedAt":"2026-09-07T12:01:00Z", + "updatedAt":"2026-09-07T12:00:00Z" + }]}`, response.Body.String()) +} + +// @covers AC-PLUGINS-PROMPT-HISTORY-HOST-002.6 +func TestConversationContinuationRenewPreservesSnapshot(t *testing.T) { + installedAt := time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) + _, service := newTestRouter(t) + service.registry.Add(conversationPluginRecord("kandev-plugin-history", installedAt)) + tokens := newConversationTokenManager() + binding, _, err := tokens.mintBinding( + "kandev-plugin-history", + "user_1", + conversationGeneration(installedAt), + ) + require.NoError(t, err) + cursor, err := tokens.mintCursor( + "kandev-plugin-history", + "user_1", + conversationGeneration(installedAt), + "session-1", + stringPtr("task-1"), + "desc", + []string{"user"}, + "message-1", + 47, + "fingerprint-1", + ) + require.NoError(t, err) + snapshot, err := tokens.mintSnapshot( + "kandev-plugin-history", + "user_1", + conversationGeneration(installedAt), + "session-1", + stringPtr("task-1"), + "desc", + []string{"user"}, + 47, + "fingerprint-1", + ) + require.NoError(t, err) + router := gin.New() + ctrl := &Controller{ + svc: service, log: testLogger(t), conversationTokens: tokens, + } + registerConversationRoutes(router.Group("/api/plugins"), ctrl) + + response := doAuthedRequest( + router, + http.MethodPost, + "/api/plugins/kandev-plugin-history/conversation/continuation/renew", + `{"cursor":"`+cursor+`","snapshot_token":"`+snapshot+`"}`, + map[string]string{ + "Content-Type": "application/json", + "X-Kandev-Plugin-Binding": binding, + }, + ) + + require.Equal(t, http.StatusOK, response.Code, response.Body.String()) + require.Equal(t, "no-store", response.Header().Get("Cache-Control")) + var renewed conversationContinuationRenewResponse + require.NoError(t, json.Unmarshal(response.Body.Bytes(), &renewed)) + require.NotEqual(t, cursor, renewed.Cursor) + require.NotEqual(t, snapshot, renewed.SnapshotToken) + require.Equal(t, int64(47), renewed.Cutoff) + require.Equal(t, "fingerprint-1", renewed.Fingerprint) + require.WithinDuration(t, time.Now().UTC().Add(10*time.Minute), renewed.ExpiresAt, time.Second) +} + +// @covers AC-PLUGINS-PROMPT-HISTORY-HOST-002.7 +func TestConversationContinuationRenewRejectsExpiredTokens(t *testing.T) { + current := time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) + _, service := newTestRouter(t) + service.conversationTokens.now = func() time.Time { return current } + installedAt := current + service.registry.Add(conversationPluginRecord("kandev-plugin-history", installedAt)) + generation := conversationGeneration(installedAt) + + cursor, err := service.conversationTokens.mintCursor( + "kandev-plugin-history", + "user_1", + generation, + "session-1", + stringPtr("task-1"), + "desc", + []string{"user"}, + "message-1", + 47, + "fingerprint-1", + ) + require.NoError(t, err) + snapshot, err := service.conversationTokens.mintSnapshot( + "kandev-plugin-history", + "user_1", + generation, + "session-1", + stringPtr("task-1"), + "desc", + []string{"user"}, + 47, + "fingerprint-1", + ) + require.NoError(t, err) + current = current.Add(9 * time.Minute) + binding, _, err := service.conversationTokens.mintBinding( + "kandev-plugin-history", + "user_1", + generation, + ) + require.NoError(t, err) + current = current.Add(2 * time.Minute) + + router := registerPluginRoutesWithIdentity( + t, + service, + authn.Identity{UserID: "user_1", Role: authn.RoleMember}, + &fakeConversationReader{}, + ) + response := doAuthedRequest( + router, + http.MethodPost, + "/api/plugins/kandev-plugin-history/conversation/continuation/renew", + `{"cursor":"`+cursor+`","snapshot_token":"`+snapshot+`"}`, + map[string]string{ + "Content-Type": "application/json", + "X-Kandev-Plugin-Binding": binding, + }, + ) + + require.Equal(t, http.StatusBadRequest, response.Code) + require.JSONEq(t, `{"error":{"code":"invalid_query","message":"invalid continuation","retryable":false}}`, response.Body.String()) +} + +func conversationPluginRecord(id string, installedAt time.Time) *store.Record { + return &store.Record{ + Manifest: manifest.Manifest{ + ID: id, + Capabilities: manifest.Capabilities{ + APIRead: []string{"messages"}, + }, + }, + Status: StatusActive, + InstalledAt: installedAt, + } +} + +func conversationBindingToken(t *testing.T, router *gin.Engine, pluginID string) string { + t.Helper() + response := doAuthedRequest( + router, + http.MethodGet, + "/api/plugins/"+pluginID+"/conversation/binding", + "", + nil, + ) + require.Equal(t, http.StatusOK, response.Code, response.Body.String()) + var binding conversationBindingResponse + require.NoError(t, json.Unmarshal(response.Body.Bytes(), &binding)) + return binding.BindingToken +} + +func conversationReadHeaders( + t *testing.T, + service *Service, + router *gin.Engine, + pluginID string, + sessionID string, +) map[string]string { + t.Helper() + binding := conversationBindingToken(t, router, pluginID) + record, err := service.Get(pluginID) + require.NoError(t, err) + snapshot, _, _, err := service.MintSessionStreamGrant( + pluginID, + "user_1", + conversationGeneration(record.InstalledAt), + sessionID, + "test-consumer", + "", + service.SessionEvents().Watermark(sessionID), + service.SessionEvents().Watermark(sessionID), + ) + require.NoError(t, err) + return map[string]string{ + "X-Kandev-Plugin-Binding": binding, + "X-Kandev-Snapshot-Token": snapshot, + } +} + +func TestFilterTurnsByTaskNarrowsMixedTaskSession(t *testing.T) { + turns := []*taskmodels.Turn{ + {ID: "turn-a", TaskID: "task-session"}, + {ID: "turn-b", TaskID: "task-other"}, + {ID: "turn-c", TaskID: "task-session"}, + } + filtered := filterTurnsByTask(turns, "task-session") + if len(filtered) != 2 || filtered[0].ID != "turn-a" || filtered[1].ID != "turn-c" { + t.Fatalf("filtered = %+v, want only the session task's turns", filtered) + } + if filtered := filterTurnsByTask(turns, ""); len(filtered) != 3 { + t.Fatalf("empty task filter must be a no-op, got %d", len(filtered)) + } +} diff --git a/apps/backend/internal/plugins/conversation_journal.go b/apps/backend/internal/plugins/conversation_journal.go new file mode 100644 index 0000000000..b26c3c9dcb --- /dev/null +++ b/apps/backend/internal/plugins/conversation_journal.go @@ -0,0 +1,721 @@ +//nolint:revive // The journal projection and sanitization contract is intentionally co-located. +package plugins + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/sysprompt" + taskmodels "github.com/kandev/kandev/internal/task/models" + "go.uber.org/zap" +) + +// errConversationCursorGone reports a page cursor whose message was +// deleted/tombstoned before the walk reached it; the page would otherwise +// silently truncate. +var errConversationCursorGone = errors.New("conversation page cursor message no longer exists") + +const ( + conversationContentKey = "content" + conversationTypeKey = "type" + conversationSessionIDKey = "session_id" + conversationTaskIDKey = "task_id" + conversationMessageIDKey = "message_id" + conversationAuthorTypeKey = "author_type" + conversationTurnCompleted = "session.turn.completed" + conversationTurnRemoved = "session.turn.removed" + conversationTurnStarted = "session.turn.started" +) + +//nolint:goconst // These keys are an explicit privacy allowlist. +var conversationMessageMetadataKeys = map[string]struct{}{ + "action_details": {}, "action_type": {}, "action_visibility": {}, + "actions": {}, "agent_disconnected": {}, + "attempt": {}, "attachments": {}, "auth_methods": {}, "auto_start": {}, + "base_branch": {}, "context": {}, "context_files": {}, "decision_id": {}, + "effective_model": {}, "entity_references": {}, "error_output": {}, + "failure_code": {}, "failure_details": {}, "failure_kind": {}, + "fallback_model": {}, "has_hidden_prompts": {}, "has_resume_token": {}, + "has_review_comments": {}, "is_auth_error": {}, "kind": {}, "max_attempts": {}, + "message": {}, "missing_branch": {}, "model_id": {}, "new_branch": {}, + "original_branch": {}, "options": {}, "pending_id": {}, "plan_mode": {}, "progress": {}, + "provider_name": {}, "question": {}, "question_id": {}, "question_index": {}, + "question_total": {}, "recovery_actions": {}, "remediation": {}, + "remediation_url": {}, "requested_model": {}, "request_id": {}, + "requests_input": {}, "response": {}, "reset_at": {}, "retry_at": {}, + "retry_in_seconds": {}, + "retrying": {}, "sender_session_id": {}, "sender_session_name": {}, + "sender_task_id": {}, "sender_task_title": {}, "stage": {}, "status": {}, + "script_type": {}, "agent_name": {}, "command": {}, "exit_code": {}, + "is_resuming": {}, "started_at": {}, "completed_at": {}, "error": {}, + "task_id": {}, "text": {}, "tool_call_id": {}, "variant": {}, "workflow_message": {}, + "workflow_step_color": {}, "workflow_step_id": {}, "workflow_step_name": {}, +} + +// SetConversationJournalDB configures the primary database that owns source +// mutations and their immutable conversation journal rows. +func (s *Service) SetConversationJournalDB(database *sqlx.DB) { + s.conversationJournal = database +} + +func (s *Service) HasConversationJournal() bool { + return s.conversationJournal != nil +} + +func (s *Service) conversationJournalTablesExist(ctx context.Context) (bool, error) { + return s.conversationTableExists(ctx, "conversation_message_versions") +} + +func toAnySlice(values []string) []any { + out := make([]any, len(values)) + for index, value := range values { + out[index] = value + } + return out +} + +func (s *Service) conversationTableExists(ctx context.Context, table string) (bool, error) { + present, err := db.TableExistsContext(ctx, s.conversationJournal, table) + if err != nil { + return false, fmt.Errorf("check conversation journal schema (%s): %w", table, err) + } + return present, nil +} + +// SyncCommittedSessionEvents mirrors primary-journal rows into the durable +// delivery log without allocating a second identity or sequence. +func (s *Service) SyncCommittedSessionEvents(ctx context.Context, sessionID string) ([]SessionEvent, error) { + if s.conversationJournal == nil || s.sessionEvents == nil { + return nil, nil + } + watermark := s.sessionEvents.Watermark(sessionID) + query := s.conversationJournal.Rebind(` + SELECT session_id, event_id, sequence, protocol_version, event_type, task_id, payload, created_at + FROM conversation_session_events + WHERE session_id = ? AND sequence > ? + ORDER BY sequence ASC`) + rows, err := s.conversationJournal.QueryxContext(ctx, query, sessionID, watermark) + if err != nil { + return nil, fmt.Errorf("query committed conversation events: %w", err) + } + defer func() { _ = rows.Close() }() + + mirrored := make([]SessionEvent, 0) + expected := watermark + for rows.Next() { + var event SessionEvent + var taskID sql.NullString + var payload string + if err := rows.Scan(&event.SessionID, &event.ID, &event.Sequence, &event.ProtocolVersion, &event.EventType, &taskID, &payload, &event.CreatedAt); err != nil { + return nil, fmt.Errorf("scan committed conversation event: %w", err) + } + event.Payload = sanitizeConversationEventPayload(event.EventType, json.RawMessage(payload)) + if taskID.Valid { + event.TaskID = &taskID.String + } + appended, err := s.sessionEvents.AppendCommitted(event) + if err != nil { + return nil, fmt.Errorf("mirror committed conversation event: %w", err) + } + if !appended { + continue + } + mirrored = append(mirrored, event) + if event.Sequence > expected+1 && s.log != nil { + s.log.Warn("journal mirror healed a retention gap", + zap.String("session_id", sessionID), + zap.Uint64("expected_sequence", expected+1), + zap.Uint64("sequence", event.Sequence), + ) + } + expected = event.Sequence + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate committed conversation events: %w", err) + } + return mirrored, nil +} + +// emptyConversationPayload is the durable payload persisted for a +// primary-journal row whose JSON is malformed. It carries no raw bytes yet is +// non-nil, so the BLOB NOT NULL session_events column accepts the write and +// the mirrored row is retained as a poison record instead of failing the +// append (the projection rejects it because no type/identity fields exist). +var emptyConversationPayload = json.RawMessage("{}") + +func sanitizeConversationEventPayload(eventType string, raw json.RawMessage) json.RawMessage { + var source map[string]any + if err := json.Unmarshal(raw, &source); err != nil { + return emptyConversationPayload + } + payload := map[string]any{conversationTypeKey: eventType} + copyConversationPayloadFields(payload, source, conversationSessionIDKey, conversationTaskIDKey) + switch eventType { + case events.MessageAdded, events.MessageUpdated: + copyConversationPayloadFields(payload, source, + conversationMessageIDKey, "turn_id", conversationAuthorTypeKey, + "created_at", "updated_at", "prompt_index", "requests_input", + "message_type") + sanitizeConversationMessagePayload(payload, source) + case events.MessageDeleted: + copyConversationPayloadFields(payload, source, conversationMessageIDKey) + case conversationTurnStarted, conversationTurnCompleted, conversationTurnRemoved: + copyConversationPayloadFields(payload, source, "id", "started_at", "completed_at", "updated_at", "had_output", "metadata") + case events.SessionRemoved: + // The common identity fields above are the complete public removal DTO. + } + sanitized, err := json.Marshal(payload) + if err != nil { + return nil + } + return sanitized +} + +func copyConversationPayloadFields(target, source map[string]any, keys ...string) { + for _, key := range keys { + if value, exists := source[key]; exists { + target[key] = value + } + } +} + +// SanitizeConversationMessageMetadata returns the presentation metadata that +// the web client needs for a conversation message. Internal agent/tool +// metadata is deliberately excluded from ordered session events. +func SanitizeConversationMessageMetadata(source map[string]any) map[string]any { + if len(source) == 0 { + return nil + } + target := make(map[string]any) + for key := range conversationMessageMetadataKeys { + value, exists := source[key] + if !exists || value == nil { + continue + } + target[key] = sanitizeConversationMetadataValue(value) + } + return target +} + +func sanitizeConversationMetadataValue(value any) any { + switch typed := value.(type) { + case string: + return sysprompt.StripSystemContent(typed) + case map[string]any: + target := make(map[string]any, len(typed)) + for key, nested := range typed { + target[key] = sanitizeConversationMetadataValue(nested) + } + return target + case []any: + target := make([]any, len(typed)) + for index, nested := range typed { + target[index] = sanitizeConversationMetadataValue(nested) + } + return target + default: + return value + } +} + +func sanitizeConversationMessagePayload(target, source map[string]any) { + if content, ok := source[conversationContentKey].(string); ok { + target[conversationContentKey] = sysprompt.StripSystemContent(content) + } + metadata, _ := source["metadata"].(map[string]any) + sanitizedMetadata := SanitizeConversationMessageMetadata(metadata) + if senderTaskID, ok := source["sender_task_id"].(string); ok && senderTaskID != "" { + target["sender_task_id"] = senderTaskID + if sanitizedMetadata == nil { + sanitizedMetadata = map[string]any{} + } + sanitizedMetadata["sender_task_id"] = senderTaskID + } + if len(sanitizedMetadata) > 0 { + target["metadata"] = sanitizedMetadata + if senderTaskID, ok := sanitizedMetadata["sender_task_id"].(string); ok && senderTaskID != "" { + target["sender_task_id"] = senderTaskID + } + } +} + +func (s *Service) syncAllCommittedSessionEvents(ctx context.Context) ([]SessionEvent, error) { + if s.conversationJournal == nil || s.sessionEvents == nil { + return nil, nil + } + rows, err := s.conversationJournal.QueryxContext(ctx, `SELECT session_id FROM conversation_session_streams ORDER BY session_id`) + if err != nil { + return nil, fmt.Errorf("query conversation stream partitions: %w", err) + } + defer func() { _ = rows.Close() }() + var sessionIDs []string + for rows.Next() { + var sessionID string + if err := rows.Scan(&sessionID); err != nil { + return nil, fmt.Errorf("scan conversation stream partition: %w", err) + } + sessionIDs = append(sessionIDs, sessionID) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate conversation stream partitions: %w", err) + } + var mirrored []SessionEvent + var failures []string + for _, sessionID := range sessionIDs { + events, err := s.SyncCommittedSessionEvents(ctx, sessionID) + if err != nil { + // Isolate one unhealthy partition: it must not freeze retention + // for every other session. The combined error is still returned + // so the failure stays loud (it is only reachable through state + // tampering, since the maintenance tick prunes both sides). + failures = append(failures, sessionID+": "+err.Error()) + continue + } + mirrored = append(mirrored, events...) + } + if len(failures) > 0 { + return mirrored, fmt.Errorf("mirror %d session partition(s): %s", len(failures), strings.Join(failures, "; ")) + } + return mirrored, nil +} + +func (s *Service) SetSessionEventSink(sink func(SessionEvent)) { + s.mu.Lock() + defer s.mu.Unlock() + + s.sessionEventSink = sink +} + +//nolint:cyclop // Retention orchestration: schema gates plus four prune passes over three tables. +func (s *Service) pruneConversationJournal( + ctx context.Context, + cutoff time.Time, + retained map[string]struct{}, +) error { + if s.conversationJournal == nil { + return nil + } + exists, err := s.conversationJournalTablesExist(ctx) + if err != nil { + return err + } + if !exists { + return nil + } + purgeable, err := s.deadSessionPurgeEnabled(ctx) + if err != nil { + return err + } + ids := make([]string, 0, len(retained)) + for sessionID := range retained { + ids = append(ids, sessionID) + } + sort.Strings(ids) + exclusion, extraArgs := retainedSessionExclusion(ids) + args := append([]any{cutoff.UTC()}, extraArgs...) + tx, err := s.conversationJournal.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("begin conversation journal retention: %w", err) + } + rollback := func(cause error) error { + _ = tx.Rollback() + return cause + } + eventQuery := s.conversationJournal.Rebind( + `DELETE FROM conversation_session_events + WHERE created_at < ? AND event_type <> 'session.removed' AND ` + exclusion, + ) + if _, err := tx.ExecContext(ctx, eventQuery, args...); err != nil { + return rollback(fmt.Errorf("prune conversation events: %w", err)) + } + messageQuery := s.conversationJournal.Rebind( + `DELETE FROM conversation_message_versions AS current_version + WHERE current_version.created_at < ? AND ` + exclusion + ` + AND EXISTS ( + SELECT 1 FROM conversation_message_versions AS newer + WHERE newer.session_id = current_version.session_id + AND newer.message_id = current_version.message_id + AND newer.row_sequence > current_version.row_sequence + )`, + ) + if _, err := tx.ExecContext(ctx, messageQuery, args...); err != nil { + return rollback(fmt.Errorf("prune conversation message versions: %w", err)) + } + turnQuery := s.conversationJournal.Rebind( + `DELETE FROM conversation_turn_versions AS current_version + WHERE current_version.started_at < ? AND ` + exclusion + ` + AND EXISTS ( + SELECT 1 FROM conversation_turn_versions AS newer + WHERE newer.session_id = current_version.session_id + AND newer.turn_id = current_version.turn_id + AND newer.row_sequence > current_version.row_sequence + )`, + ) + if _, err := tx.ExecContext(ctx, turnQuery, args...); err != nil { + return rollback(fmt.Errorf("prune conversation turn versions: %w", err)) + } + if purgeable { + if err := s.purgeDeadSessionPartitions(ctx, tx, exclusion, args); err != nil { + return rollback(err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit conversation journal retention: %w", err) + } + return nil +} + +// deadSessionPurgeEnabled reports whether the retention pass can purge whole +// dead-session partitions: both the journal stream table and the source +// session table must be present. Checked before the retention tx opens so a +// single-connection pool (in-memory test DBs) cannot deadlock on the catalog +// lookups. +func (s *Service) deadSessionPurgeEnabled(ctx context.Context) (bool, error) { + streams, err := s.conversationTableExists(ctx, "conversation_session_streams") + if err != nil { + return false, err + } + if !streams { + return false, nil + } + return s.conversationTableExists(ctx, "task_sessions") +} + +// retainedSessionExclusion builds the SQL exclusion clause and its bound +// arguments for the retained session ids; with none retained it returns the +// no-op "1 = 1" clause so every row is eligible for age-based pruning. +func retainedSessionExclusion(ids []string) (string, []any) { + if len(ids) == 0 { + return "1 = 1", nil + } + placeholders := make([]string, len(ids)) + extra := make([]any, len(ids)) + for index, sessionID := range ids { + placeholders[index] = "?" + extra[index] = sessionID + } + return "session_id NOT IN (" + strings.Join(placeholders, ",") + ")", extra +} + +// purgeDeadSessionPartitions deletes every journal row (message and turn +// versions, events, and the stream row) for sessions whose source task_sessions +// row no longer exists and whose partition is older than the retention cutoff +// (args[0]) and not protected by a live cursor/poison (remaining args). The +// dead ids are materialized first because a CTE whose DELETE targets the same +// table the CTE reads can stall SQLite. +// +// The session.removed terminal marker intentionally lives exactly as long as +// the rest of the partition: this pass fires only after updated_at has aged +// past SessionEventRetention, so a subscriber arriving within that window +// still receives session_removed (the durable-log mirror keeps the terminal +// tombstone through the same window and only then drops it). After both +// stores forget the partition, the session is gone for good and later +// snapshot reads correctly 404. +func (s *Service) purgeDeadSessionPartitions(ctx context.Context, tx *sqlx.Tx, exclusion string, args []any) error { + deadQuery := s.conversationJournal.Rebind(` + SELECT st.session_id + FROM conversation_session_streams st + WHERE st.updated_at < ? AND ` + exclusion + ` + AND NOT EXISTS ( + SELECT 1 FROM task_sessions ts WHERE ts.id = st.session_id + )`) + var deadIDs []string + if err := tx.SelectContext(ctx, &deadIDs, deadQuery, args...); err != nil { + return fmt.Errorf("select dead session journal partitions: %w", err) + } + if len(deadIDs) == 0 { + return nil + } + placeholders := make([]string, len(deadIDs)) + for index := range deadIDs { + placeholders[index] = "?" + } + inClause := strings.Join(placeholders, ",") + for _, child := range []string{"conversation_message_versions", "conversation_turn_versions", "conversation_session_events"} { + query := s.conversationJournal.Rebind( + `DELETE FROM ` + child + ` WHERE session_id IN (` + inClause + `)`) + if _, err := tx.ExecContext(ctx, query, toAnySlice(deadIDs)...); err != nil { + return fmt.Errorf("purge dead session journal rows (%s): %w", child, err) + } + } + streamQuery := s.conversationJournal.Rebind( + `DELETE FROM conversation_session_streams WHERE session_id IN (` + inClause + `)`) + if _, err := tx.ExecContext(ctx, streamQuery, toAnySlice(deadIDs)...); err != nil { + return fmt.Errorf("purge dead session stream rows: %w", err) + } + return nil +} + +type journalMessagePayload struct { + MessageID string `json:"message_id"` + TurnID string `json:"turn_id"` + TaskID string `json:"task_id"` + AuthorType string `json:"author_type"` + Content string `json:"content"` + MessageType string `json:"message_type"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + PromptIndex int `json:"prompt_index"` + RequestsInput any `json:"requests_input"` + SenderTaskID string `json:"sender_task_id"` + Metadata map[string]any `json:"metadata"` +} + +//nolint:goconst // SQL projection values are protocol literals. +func (s *Service) conversationMessagesAt( + ctx context.Context, + sessionID string, + cutoff uint64, + taskID *string, + authors []string, + sortOrder string, + cursorID string, + limit int, +) ([]*taskmodels.Message, bool, error) { + if s.conversationJournal == nil { + return nil, false, nil + } + args := []any{sessionID, cutoff} + where := []string{"version_rank = 1", "tombstone = FALSE"} + if taskID != nil { + where = append(where, "task_id = ?") + args = append(args, *taskID) + } + if len(authors) > 0 { + placeholders := make([]string, len(authors)) + for index, author := range authors { + placeholders[index] = "?" + args = append(args, author) + } + where = append(where, "author_type IN ("+strings.Join(placeholders, ",")+")") + } + orderDirection := "ASC" + comparison := ">" + if sortOrder == "desc" { + orderDirection = "DESC" + comparison = "<" + } + if cursorID != "" { + // A cursor whose message was deleted/tombstoned mid-walk is gone from + // the live projection; the cursor predicate would otherwise match + // nothing and the client would read a silent empty terminal page. + // Surface an error (parity with the source-table fallback) instead. + live, err := s.liveConversationMessageAtCutoff(ctx, sessionID, cutoff, cursorID) + if err != nil { + return nil, false, err + } + if !live { + return nil, false, fmt.Errorf("%w: %s", errConversationCursorGone, cursorID) + } + where = append(where, fmt.Sprintf("(created_at %s (SELECT created_at FROM live WHERE message_id = ?) OR (created_at = (SELECT created_at FROM live WHERE message_id = ?) AND message_id %s ?))", comparison, comparison)) + args = append(args, cursorID, cursorID, cursorID) + } + args = append(args, limit+1) + query := fmt.Sprintf(` + WITH ranked AS ( + SELECT message_id, task_id, author_type, created_at, tombstone, payload, + ROW_NUMBER() OVER (PARTITION BY message_id ORDER BY row_sequence DESC) AS version_rank + FROM conversation_message_versions + WHERE session_id = ? AND row_sequence <= ? + ), live AS ( + SELECT * FROM ranked WHERE version_rank = 1 AND tombstone = FALSE + ) + SELECT payload FROM live + WHERE %s + ORDER BY created_at %s, message_id %s + LIMIT ?`, strings.Join(where, " AND "), orderDirection, orderDirection) + query = s.conversationJournal.Rebind(query) + rows, err := s.conversationJournal.QueryxContext(ctx, query, args...) + if err != nil { + return nil, false, fmt.Errorf("query conversation message snapshot: %w", err) + } + defer func() { _ = rows.Close() }() + return decodeConversationSnapshotRows(rows, sessionID, limit) +} + +type conversationRowScanner interface { + Next() bool + Scan(dest ...any) error + Err() error +} + +// decodeConversationSnapshotRows scans message-version payload rows into +// messages, applying the limit+1 page bound, and reports whether another page +// remains. +func decodeConversationSnapshotRows(rows conversationRowScanner, sessionID string, limit int) ([]*taskmodels.Message, bool, error) { + messages := make([]*taskmodels.Message, 0, limit+1) + for rows.Next() { + var raw []byte + if err := rows.Scan(&raw); err != nil { + return nil, false, fmt.Errorf("scan conversation message snapshot: %w", err) + } + message, err := decodeJournalMessage(raw, sessionID) + if err != nil { + return nil, false, err + } + messages = append(messages, message) + } + if err := rows.Err(); err != nil { + return nil, false, fmt.Errorf("iterate conversation message snapshot: %w", err) + } + hasMore := len(messages) > limit + if hasMore { + messages = messages[:limit] + } + return messages, hasMore, nil +} + +// liveConversationMessageAtCutoff reports whether the message cursor still has +// a live (non-tombstoned) version at or before the snapshot cutoff. +func (s *Service) liveConversationMessageAtCutoff(ctx context.Context, sessionID string, cutoff uint64, cursorID string) (bool, error) { + query := s.conversationJournal.Rebind(` + WITH ranked AS ( + SELECT message_id, tombstone, + ROW_NUMBER() OVER (PARTITION BY message_id ORDER BY row_sequence DESC) AS version_rank + FROM conversation_message_versions + WHERE session_id = ? AND row_sequence <= ? + ) + SELECT COUNT(*) FROM ranked WHERE message_id = ? AND version_rank = 1 AND tombstone = FALSE`) + var live int + if err := s.conversationJournal.GetContext(ctx, &live, query, sessionID, cutoff, cursorID); err != nil { + return false, fmt.Errorf("resolve conversation page cursor: %w", err) + } + return live > 0, nil +} + +func conversationRequestsInput(value any) bool { + switch typed := value.(type) { + case bool: + return typed + case float64: + return typed != 0 + default: + return false + } +} + +func decodeJournalMessage(raw []byte, sessionID string) (*taskmodels.Message, error) { + var payload journalMessagePayload + if err := json.Unmarshal(raw, &payload); err != nil { + return nil, fmt.Errorf("decode conversation message version: %w", err) + } + createdAt, err := parseJournalTime(payload.CreatedAt) + if err != nil { + return nil, fmt.Errorf("decode conversation message created_at: %w", err) + } + updatedAt, err := parseJournalTime(payload.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("decode conversation message updated_at: %w", err) + } + metadata := payload.Metadata + if metadata == nil { + metadata = map[string]any{} + } + if payload.SenderTaskID != "" { + metadata["sender_task_id"] = payload.SenderTaskID + } + return &taskmodels.Message{ + ID: payload.MessageID, TaskSessionID: sessionID, TurnID: payload.TurnID, TaskID: payload.TaskID, + AuthorType: taskmodels.MessageAuthorType(payload.AuthorType), + Content: payload.Content, Type: taskmodels.MessageType(payload.MessageType), + RequestsInput: conversationRequestsInput(payload.RequestsInput), + CreatedAt: createdAt, UpdatedAt: updatedAt, PromptIndex: payload.PromptIndex, Metadata: metadata, + }, nil +} + +type journalTurnPayload struct { + TurnID string `json:"id"` + TaskID string `json:"task_id"` + StartedAt string `json:"started_at"` + CompletedAt *string `json:"completed_at"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Metadata map[string]any `json:"metadata"` +} + +func (s *Service) conversationTurnsAt(ctx context.Context, sessionID string, cutoff uint64, taskID *string) ([]*taskmodels.Turn, error) { + if s.conversationJournal == nil { + return nil, nil + } + args := []any{sessionID, cutoff} + where := "version_rank = 1 AND tombstone = FALSE" + if taskID != nil { + where += " AND task_id = ?" + args = append(args, *taskID) + } + query := fmt.Sprintf(` + WITH ranked AS ( + SELECT turn_id, task_id, started_at, tombstone, payload, + ROW_NUMBER() OVER (PARTITION BY turn_id ORDER BY row_sequence DESC) AS version_rank + FROM conversation_turn_versions + WHERE session_id = ? AND row_sequence <= ? + ) + SELECT payload FROM ranked WHERE %s ORDER BY started_at ASC, turn_id ASC`, where) + query = s.conversationJournal.Rebind(query) + rows, err := s.conversationJournal.QueryxContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("query conversation turn snapshot: %w", err) + } + defer func() { _ = rows.Close() }() + var turns []*taskmodels.Turn + for rows.Next() { + var raw []byte + if err := rows.Scan(&raw); err != nil { + return nil, fmt.Errorf("scan conversation turn snapshot: %w", err) + } + var payload journalTurnPayload + if err := json.Unmarshal(raw, &payload); err != nil { + return nil, fmt.Errorf("decode conversation turn version: %w", err) + } + startedAt, err := parseJournalTime(payload.StartedAt) + if err != nil { + return nil, fmt.Errorf("decode conversation turn started_at: %w", err) + } + createdAt, err := parseJournalTime(payload.CreatedAt) + if err != nil { + return nil, fmt.Errorf("decode conversation turn created_at: %w", err) + } + updatedAt, err := parseJournalTime(payload.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("decode conversation turn updated_at: %w", err) + } + metadata := payload.Metadata + if metadata == nil { + metadata = map[string]any{} + } + turn := &taskmodels.Turn{ + ID: payload.TurnID, TaskSessionID: sessionID, TaskID: payload.TaskID, + StartedAt: startedAt, Metadata: metadata, CreatedAt: createdAt, UpdatedAt: updatedAt, + } + if payload.CompletedAt != nil { + completedAt, err := parseJournalTime(*payload.CompletedAt) + if err != nil { + return nil, fmt.Errorf("decode conversation turn completed_at: %w", err) + } + turn.CompletedAt = &completedAt + } + turns = append(turns, turn) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate conversation turn snapshot: %w", err) + } + return turns, nil +} + +func parseJournalTime(value string) (time.Time, error) { + for _, layout := range []string{time.RFC3339Nano, "2006-01-02 15:04:05.999999999Z07:00", "2006-01-02 15:04:05Z07:00"} { + if parsed, err := time.Parse(layout, value); err == nil { + return parsed, nil + } + } + return time.Time{}, fmt.Errorf("unsupported timestamp %q", value) +} diff --git a/apps/backend/internal/plugins/conversation_journal_postgres_test.go b/apps/backend/internal/plugins/conversation_journal_postgres_test.go new file mode 100644 index 0000000000..b973cffbeb --- /dev/null +++ b/apps/backend/internal/plugins/conversation_journal_postgres_test.go @@ -0,0 +1,133 @@ +package plugins + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/task/models" + tasksqlite "github.com/kandev/kandev/internal/task/repository/sqlite" + "github.com/kandev/kandev/internal/testutil" + "github.com/stretchr/testify/require" +) + +// TestPostgresConversationJournalProjectsRFC3339Times covers the real +// PostgreSQL trigger output at both consumers: immutable snapshot decoding and +// ordered ProjectSessionEvent validation. It skips when KANDEV_TEST_POSTGRES_DSN +// is unavailable. +func TestPostgresConversationJournalProjectsRFC3339Times(t *testing.T) { + database := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repository, err := tasksqlite.NewWithDB(database, database, nil) + require.NoError(t, err) + _, err = database.Exec(`SET TIME ZONE 'America/New_York'`) + require.NoError(t, err) + + const ( + taskID = "task-pg-journal-projection" + sessionID = "session-pg-journal-projection" + turnID = "turn-pg-journal-projection" + ) + startedAt := time.Date(2026, 9, 7, 12, 0, 0, 123456000, time.UTC) + insertPostgresConversationTurn(t, database, taskID, sessionID, turnID, startedAt) + messageAt := time.Date(2026, 9, 7, 12, 1, 0, 654321000, time.UTC) + require.NoError(t, repository.CreateMessage(context.Background(), &models.Message{ + ID: "message-pg-journal-projection", + TaskSessionID: sessionID, + TaskID: taskID, + TurnID: turnID, + AuthorType: models.MessageAuthorAgent, + Type: models.MessageTypeMessage, + Content: "visible", + CreatedAt: messageAt, + UpdatedAt: messageAt, + })) + + service := NewService(nil, NewRegistry(), nil, testLogger(t)) + service.SetConversationJournalDB(database) + var cutoff uint64 + require.NoError(t, database.Get(&cutoff, + `SELECT watermark FROM conversation_session_streams WHERE session_id = $1`, sessionID)) + + turns, err := service.conversationTurnsAt(context.Background(), sessionID, cutoff, nil) + require.NoError(t, err) + require.Len(t, turns, 1) + require.True(t, turns[0].StartedAt.Equal(startedAt), "started_at = %s", turns[0].StartedAt) + require.True(t, turns[0].UpdatedAt.Equal(startedAt), "updated_at = %s", turns[0].UpdatedAt) + messages, more, err := service.conversationMessagesAt( + context.Background(), sessionID, cutoff, nil, nil, "asc", "", 20, + ) + require.NoError(t, err) + require.False(t, more) + require.Len(t, messages, 1) + require.True(t, messages[0].CreatedAt.Equal(messageAt), "created_at = %s", messages[0].CreatedAt) + require.True(t, messages[0].UpdatedAt.Equal(messageAt), "updated_at = %s", messages[0].UpdatedAt) + + events, err := service.SyncCommittedSessionEvents(context.Background(), sessionID) + require.NoError(t, err) + require.Len(t, events, 2) + projectedTimes := make(map[string]map[string]any, len(events)) + for _, event := range events { + projected, projectionErr := ProjectSessionEvent(event) + require.NoError(t, projectionErr) + var payload map[string]any + require.NoError(t, json.Unmarshal(projected, &payload)) + projectedTimes[event.EventType] = payload + } + require.Equal( + t, + startedAt.Format(time.RFC3339Nano), + projectedTimes["session.turn.started"]["started_at"], + ) + require.Equal( + t, + startedAt.Format(time.RFC3339Nano), + projectedTimes["session.turn.started"]["updated_at"], + ) + require.Equal( + t, + messageAt.Format(time.RFC3339Nano), + projectedTimes["message.added"]["created_at"], + ) + require.Equal( + t, + messageAt.Format(time.RFC3339Nano), + projectedTimes["message.added"]["updated_at"], + ) +} +func insertPostgresConversationTurn( + t *testing.T, + database *sqlx.DB, + taskID string, + sessionID string, + turnID string, + startedAt time.Time, +) { + t.Helper() + _, err := database.Exec( + `INSERT INTO tasks (id, workspace_id, title, created_at, updated_at) + VALUES ($1, '', 'test task', $2, $2)`, + taskID, + startedAt, + ) + require.NoError(t, err) + _, err = database.Exec( + `INSERT INTO task_sessions (id, task_id, started_at, updated_at) + VALUES ($1, $2, $3, $3)`, + sessionID, + taskID, + startedAt, + ) + require.NoError(t, err) + _, err = database.Exec( + `INSERT INTO task_session_turns ( + id, task_session_id, task_id, started_at, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $4, $4)`, + turnID, + sessionID, + taskID, + startedAt, + ) + require.NoError(t, err) +} diff --git a/apps/backend/internal/plugins/conversation_journal_test.go b/apps/backend/internal/plugins/conversation_journal_test.go new file mode 100644 index 0000000000..a984d1dd3b --- /dev/null +++ b/apps/backend/internal/plugins/conversation_journal_test.go @@ -0,0 +1,484 @@ +package plugins + +import ( + "context" + "encoding/json" + "path/filepath" + "testing" + "time" + + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/events" + _ "github.com/mattn/go-sqlite3" + "github.com/stretchr/testify/require" +) + +func TestConversationMessagesAtUsesImmutableCutoff(t *testing.T) { + database, err := sqlx.Open("sqlite3", ":memory:") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, database.Close()) }) + _, err = database.Exec(` + CREATE TABLE conversation_message_versions ( + session_id TEXT NOT NULL, message_id TEXT NOT NULL, row_sequence INTEGER NOT NULL, + task_id TEXT, author_type TEXT, created_at TIMESTAMP NOT NULL, + tombstone BOOLEAN NOT NULL, payload TEXT NOT NULL + );`) + require.NoError(t, err) + + createdAt := "2026-09-07T12:00:00Z" + insertJournalMessageVersion(t, database, 1, false, map[string]any{ + "message_id": "message-1", "turn_id": "turn-1", "task_id": "task-1", + "author_type": "agent", "author_id": "agent-1", "content": "before", + "requests_input": 0, "message_type": "message", "created_at": createdAt, + "updated_at": createdAt, "prompt_index": 0, "metadata_json": `{}`, + }) + insertJournalMessageVersion(t, database, 2, false, map[string]any{ + "message_id": "message-1", "turn_id": "turn-1", "task_id": "task-1", + "author_type": "agent", "author_id": "agent-1", "content": "after", + "requests_input": 0, "message_type": "message", "created_at": createdAt, + "updated_at": "2026-09-07T12:01:00Z", "prompt_index": 0, "metadata_json": `{}`, + }) + insertJournalMessageVersion(t, database, 3, true, map[string]any{"message_id": "message-1"}) + + service := NewService(nil, NewRegistry(), nil, testLogger(t)) + service.SetConversationJournalDB(database) + + atInsert, more, err := service.conversationMessagesAt(context.Background(), "session-1", 1, nil, nil, "asc", "", 20) + require.NoError(t, err) + require.False(t, more) + require.Len(t, atInsert, 1) + require.Equal(t, "before", atInsert[0].Content) + require.Equal(t, "session-1", atInsert[0].TaskSessionID) + + atUpdate, _, err := service.conversationMessagesAt(context.Background(), "session-1", 2, nil, nil, "asc", "", 20) + require.NoError(t, err) + require.Len(t, atUpdate, 1) + require.Equal(t, "after", atUpdate[0].Content) + + afterDelete, _, err := service.conversationMessagesAt(context.Background(), "session-1", 3, nil, nil, "asc", "", 20) + require.NoError(t, err) + require.Empty(t, afterDelete) +} + +func TestSessionEventMaintenanceDispatchesUnsignaledCommittedRowsOnce(t *testing.T) { + database, err := sqlx.Open("sqlite3", ":memory:") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, database.Close()) }) + _, err = database.Exec(` + CREATE TABLE conversation_session_streams ( + session_id TEXT PRIMARY KEY, watermark INTEGER NOT NULL + ); + CREATE TABLE conversation_session_events ( + session_id TEXT NOT NULL, sequence INTEGER NOT NULL, event_id TEXT NOT NULL, + protocol_version INTEGER NOT NULL, event_type TEXT NOT NULL, task_id TEXT, + payload TEXT NOT NULL, created_at TIMESTAMP NOT NULL + ); + INSERT INTO conversation_session_streams(session_id, watermark) VALUES ('session-1', 1); + INSERT INTO conversation_session_events( + session_id, sequence, event_id, protocol_version, event_type, task_id, payload, created_at + ) VALUES ( + 'session-1', 1, 'session-1:1', 1, 'message.deleted', 'task-1', + '{"type":"message.deleted","session_id":"session-1","task_id":"task-1","message_id":"message-1"}', + '2026-09-07T12:00:00Z' + );`) + require.NoError(t, err) + + service := NewService(nil, NewRegistry(), nil, testLogger(t)) + service.SetConversationJournalDB(database) + var dispatched []SessionEvent + service.SetSessionEventSink(func(event SessionEvent) { + dispatched = append(dispatched, event) + }) + + require.NoError(t, service.maintainSessionEvents(context.Background(), time.Now().UTC())) + require.Len(t, dispatched, 1) + require.Equal(t, uint64(1), dispatched[0].Sequence) + require.NoError(t, service.maintainSessionEvents(context.Background(), time.Now().UTC())) + require.Len(t, dispatched, 1) +} + +func TestPruneConversationJournalKeepsLatestRowsAndRecentEvents(t *testing.T) { + database, err := sqlx.Open("sqlite3", ":memory:") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, database.Close()) }) + _, err = database.Exec(` + CREATE TABLE conversation_message_versions ( + session_id TEXT NOT NULL, message_id TEXT NOT NULL, row_sequence INTEGER NOT NULL, + task_id TEXT, author_type TEXT, created_at TIMESTAMP NOT NULL, + tombstone BOOLEAN NOT NULL, payload TEXT NOT NULL + ); + CREATE TABLE conversation_turn_versions ( + session_id TEXT NOT NULL, turn_id TEXT NOT NULL, row_sequence INTEGER NOT NULL, + task_id TEXT, started_at TIMESTAMP NOT NULL, tombstone BOOLEAN NOT NULL, payload TEXT NOT NULL + ); + CREATE TABLE conversation_session_events ( + session_id TEXT NOT NULL, sequence INTEGER NOT NULL, event_id TEXT NOT NULL, + protocol_version INTEGER NOT NULL, event_type TEXT NOT NULL, task_id TEXT, + payload TEXT NOT NULL, created_at TIMESTAMP NOT NULL + );`) + require.NoError(t, err) + old := time.Now().UTC().Add(-2 * SessionEventRetention) + recent := time.Now().UTC() + _, err = database.Exec(` + INSERT INTO conversation_message_versions VALUES + ('session-1', 'message-1', 1, 'task-1', 'agent', ?, FALSE, '{}'), + ('session-1', 'message-1', 2, 'task-1', 'agent', ?, FALSE, '{}'); + INSERT INTO conversation_turn_versions VALUES + ('session-1', 'turn-1', 1, 'task-1', ?, FALSE, '{}'), + ('session-1', 'turn-1', 2, 'task-1', ?, FALSE, '{}'); + INSERT INTO conversation_session_events VALUES + ('session-1', 1, 'session-1:1', 1, 'message.added', 'task-1', '{}', ?), + ('session-1', 2, 'session-1:2', 1, 'message.updated', 'task-1', '{}', ?);`, + old, recent, old, recent, old, recent) + require.NoError(t, err) + + service := NewService(nil, NewRegistry(), nil, testLogger(t)) + service.SetConversationJournalDB(database) + require.NoError(t, service.pruneConversationJournal(context.Background(), time.Now().UTC().Add(-SessionEventRetention), nil)) + + var count int + require.NoError(t, database.Get(&count, "SELECT COUNT(*) FROM conversation_message_versions")) + require.Equal(t, 1, count) + require.NoError(t, database.Get(&count, "SELECT COUNT(*) FROM conversation_turn_versions")) + require.Equal(t, 1, count) + require.NoError(t, database.Get(&count, "SELECT COUNT(*) FROM conversation_session_events")) + require.Equal(t, 1, count) +} +func TestPruneConversationJournalPurgestDeadSessionPartitions(t *testing.T) { + database, err := sqlx.Open("sqlite3", ":memory:") + require.NoError(t, err) + database.SetMaxOpenConns(1) + t.Cleanup(func() { require.NoError(t, database.Close()) }) + _, err = database.Exec(` + CREATE TABLE conversation_session_streams ( + session_id TEXT PRIMARY KEY, watermark INTEGER NOT NULL DEFAULT 0, + terminal BOOLEAN NOT NULL DEFAULT FALSE, updated_at TIMESTAMP NOT NULL + ); + CREATE TABLE conversation_message_versions ( + session_id TEXT NOT NULL, message_id TEXT NOT NULL, row_sequence INTEGER NOT NULL, + task_id TEXT, author_type TEXT, created_at TIMESTAMP NOT NULL, + tombstone BOOLEAN NOT NULL, payload TEXT NOT NULL + ); + CREATE TABLE conversation_turn_versions ( + session_id TEXT NOT NULL, turn_id TEXT NOT NULL, row_sequence INTEGER NOT NULL, + task_id TEXT, started_at TIMESTAMP NOT NULL, tombstone BOOLEAN NOT NULL, payload TEXT NOT NULL + ); + CREATE TABLE conversation_session_events ( + session_id TEXT NOT NULL, sequence INTEGER NOT NULL, event_id TEXT NOT NULL, + protocol_version INTEGER NOT NULL, event_type TEXT NOT NULL, task_id TEXT, + payload TEXT NOT NULL, created_at TIMESTAMP NOT NULL + ); + CREATE TABLE task_sessions ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL, started_at TIMESTAMP NOT NULL + );`) + require.NoError(t, err) + old := time.Now().UTC().Add(-2 * SessionEventRetention) + recent := time.Now().UTC() + for _, seed := range []struct { + sql string + args []any + }{ + {`INSERT INTO conversation_session_streams VALUES ('session-dead', 5, TRUE, ?)`, []any{old}}, + {`INSERT INTO conversation_session_streams VALUES ('session-live', 5, TRUE, ?)`, []any{recent}}, + {`INSERT INTO conversation_session_streams VALUES ('session-retained', 5, TRUE, ?)`, []any{recent}}, + {`INSERT INTO conversation_message_versions VALUES ('session-dead', 'm-1', 1, 'task-1', 'agent', ?, FALSE, '{}')`, []any{old}}, + {`INSERT INTO conversation_message_versions VALUES ('session-dead', 'm-1', 2, 'task-1', 'agent', ?, FALSE, '{}')`, []any{recent}}, + {`INSERT INTO conversation_turn_versions VALUES ('session-dead', 'turn-1', 3, 'task-1', ?, FALSE, '{}')`, []any{old}}, + {`INSERT INTO conversation_session_events VALUES ('session-dead', 1, 'session-dead:1', 1, 'message.added', 'task-1', '{}', ?)`, []any{old}}, + {`INSERT INTO conversation_session_events VALUES ('session-dead', 5, 'session-dead:5', 1, 'session.removed', 'task-1', '{}', ?)`, []any{recent}}, + {`INSERT INTO task_sessions VALUES ('session-live', 'task-1', ?)`, []any{recent}}, + } { + if _, err := database.Exec(seed.sql, seed.args...); err != nil { + t.Fatalf("seed: %v\n%s", err, seed.sql) + } + } + + service := NewService(nil, NewRegistry(), nil, testLogger(t)) + service.SetConversationJournalDB(database) + retained := map[string]struct{}{"session-retained": {}} + require.NoError(t, service.pruneConversationJournal(context.Background(), time.Now().UTC(), retained)) + + var streams int + require.NoError(t, database.Get(&streams, "SELECT COUNT(*) FROM conversation_session_streams")) + require.Equal(t, 2, streams) // live + retained survive; dead purged + var versions int + require.NoError(t, database.Get(&versions, "SELECT COUNT(*) FROM conversation_message_versions")) + require.Equal(t, 0, versions) + require.NoError(t, database.Get(&versions, "SELECT COUNT(*) FROM conversation_turn_versions")) + require.Equal(t, 0, versions) + require.NoError(t, database.Get(&versions, "SELECT COUNT(*) FROM conversation_session_events")) + require.Equal(t, 0, versions) + require.NoError(t, database.Get(&streams, "SELECT COUNT(*) FROM conversation_session_streams WHERE session_id = 'session-dead'")) + require.Equal(t, 0, streams) +} + +func insertJournalMessageVersion(t *testing.T, database *sqlx.DB, sequence int, tombstone bool, payload map[string]any) { + + t.Helper() + raw, err := json.Marshal(payload) + require.NoError(t, err) + _, err = database.Exec(` + INSERT INTO conversation_message_versions( + session_id, message_id, row_sequence, task_id, author_type, created_at, tombstone, payload + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + "session-1", "message-1", sequence, "task-1", "agent", time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC), tombstone, string(raw), + ) + require.NoError(t, err) +} +func TestSanitizeConversationTurnEventPreservesPublicMetadata(t *testing.T) { + raw := json.RawMessage(`{ + "type": "session.turn.completed", + "session_id": "session-1", + "task_id": "task-1", + "id": "turn-1", + "started_at": "2026-09-07T12:00:00Z", + "completed_at": "2026-09-07T12:01:00Z", + "metadata": { + "runtime_config_snapshot": { + "model": "mock-fast", + "config_baseline": {"effort": "medium"} + } + }, + "private": "must-not-be-copied" + }`) + + sanitized := sanitizeConversationEventPayload(conversationTurnCompleted, raw) + var payload map[string]any + require.NoError(t, json.Unmarshal(sanitized, &payload)) + require.Equal(t, map[string]any{ + "runtime_config_snapshot": map[string]any{ + "model": "mock-fast", + "config_baseline": map[string]any{"effort": "medium"}, + }, + }, payload["metadata"]) + require.NotContains(t, payload, "private") +} +func TestSanitizeConversationMessageEventPreservesPresentationMetadata(t *testing.T) { + raw := json.RawMessage(`{ + "type": "message.added", + "session_id": "session-1", + "task_id": "task-1", + "message_id": "message-1", + "author_type": "agent", + "message_type": "clarification_request", + "requests_input": true, + "metadata": { + "request_id": "request-1", + "tool_call_id": "tool-1", + "action_type": "execute", + "action_details": {"command": "ls"}, + "options": [ + {"option_id": "allow", "name": "Allow", "kind": "allow_once"}, + {"option_id": "reject", "name": "Reject", "kind": "reject_once"} + ], + "pending_id": "pending-1", + "question": {"id": "question-1", "prompt": "Choose hidden"}, + "secret": "must-not-be-copied", + "raw_content": "must-not-be-copied" + }, + "content": "Question" + }`) + + sanitized := sanitizeConversationEventPayload(events.MessageAdded, raw) + var payload map[string]any + require.NoError(t, json.Unmarshal(sanitized, &payload)) + require.Equal(t, "clarification_request", payload["message_type"]) + require.Equal(t, true, payload["requests_input"]) + require.Equal(t, map[string]any{ + "action_details": map[string]any{"command": "ls"}, + "action_type": "execute", + "options": []any{ + map[string]any{"option_id": "allow", "name": "Allow", "kind": "allow_once"}, + map[string]any{"option_id": "reject", "name": "Reject", "kind": "reject_once"}, + }, + "pending_id": "pending-1", + "question": map[string]any{"id": "question-1", "prompt": "Choose"}, + "request_id": "request-1", + "tool_call_id": "tool-1", + }, payload["metadata"]) +} + +func TestSanitizeConversationMessageEventPreservesAgentBootMetadata(t *testing.T) { + raw := json.RawMessage(`{ + "type": "message.updated", + "session_id": "session-1", + "task_id": "task-1", + "message_id": "message-1", + "author_type": "agent", + "message_type": "script_execution", + "metadata": { + "script_type": "agent_boot", + "agent_name": "Mock", + "command": "/usr/local/bin/mock-agent", + "status": "exited", + "exit_code": 0, + "is_resuming": true, + "started_at": "2026-09-15T09:45:58.245720115Z", + "completed_at": "2026-09-15T09:45:59.774503743Z", + "error": "must-not-be-dropped", + "private": "must-not-be-copied" + }, + "content": "boot output" + }`) + + sanitized := sanitizeConversationEventPayload(events.MessageUpdated, raw) + var payload map[string]any + require.NoError(t, json.Unmarshal(sanitized, &payload)) + require.Equal(t, map[string]any{ + "agent_name": "Mock", + "command": "/usr/local/bin/mock-agent", + "completed_at": "2026-09-15T09:45:59.774503743Z", + "error": "must-not-be-dropped", + "exit_code": float64(0), + "is_resuming": true, + "script_type": "agent_boot", + "started_at": "2026-09-15T09:45:58.245720115Z", + "status": "exited", + }, payload["metadata"]) + require.NotContains(t, payload["metadata"], "private") +} + +func TestSyncCommittedSessionEventsStripsSystemContent(t *testing.T) { + database, err := sqlx.Open("sqlite3", ":memory:") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, database.Close()) }) + _, err = database.Exec(` + CREATE TABLE conversation_session_streams ( + session_id TEXT PRIMARY KEY, watermark INTEGER NOT NULL + ); + CREATE TABLE conversation_session_events ( + session_id TEXT NOT NULL, sequence INTEGER NOT NULL, event_id TEXT NOT NULL, + protocol_version INTEGER NOT NULL, event_type TEXT NOT NULL, task_id TEXT, + payload TEXT NOT NULL, created_at TIMESTAMP NOT NULL + ); + INSERT INTO conversation_session_streams(session_id, watermark) VALUES ('session-1', 1); + INSERT INTO conversation_session_events VALUES ( + 'session-1', 1, 'session-1:1', 1, 'message.added', 'task-1', + '{"type":"message.added","session_id":"session-1","task_id":"task-1","message_id":"message-1","author_type":"user","author_id":"agent-1","metadata":{"sender_task_id":"sender-1","secret":"no"},"content":"visible secret"}', + '2026-09-07T12:00:00Z' + );`) + require.NoError(t, err) + + service := NewService(nil, NewRegistry(), nil, testLogger(t)) + service.SetConversationJournalDB(database) + var payload map[string]any + events, err := service.SyncCommittedSessionEvents(context.Background(), "session-1") + require.NoError(t, err) + require.NoError(t, json.Unmarshal(events[0].Payload, &payload)) + require.Equal(t, "visible", payload["content"]) + require.Equal(t, "sender-1", payload["sender_task_id"]) + require.NotContains(t, payload, "author_id") + require.Equal(t, map[string]any{"sender_task_id": "sender-1"}, payload["metadata"]) +} + +func TestSyncCommittedSessionEventsPoisonsMalformedPayloadWithoutRetainingRawBytes(t *testing.T) { + const sessionID = "malformed-journal-session" + database, err := sqlx.Open("sqlite3", ":memory:") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, database.Close()) }) + _, err = database.Exec(` + CREATE TABLE conversation_session_streams ( + session_id TEXT PRIMARY KEY, watermark INTEGER NOT NULL + ); + CREATE TABLE conversation_session_events ( + session_id TEXT NOT NULL, sequence INTEGER NOT NULL, event_id TEXT NOT NULL, + protocol_version INTEGER NOT NULL, event_type TEXT NOT NULL, task_id TEXT, + payload TEXT NOT NULL, created_at TIMESTAMP NOT NULL + ); + INSERT INTO conversation_session_streams(session_id, watermark) VALUES ('` + sessionID + `', 1); + INSERT INTO conversation_session_events VALUES ( + '` + sessionID + `', 1, '` + sessionID + `:1', 1, 'message.added', 'task-1', + '{"content":"private secret"', + '2026-09-07T12:00:00Z' + );`) + require.NoError(t, err) + + service := NewService(nil, NewRegistry(), nil, testLogger(t)) + service.SetConversationJournalDB(database) + events, err := service.SyncCommittedSessionEvents(context.Background(), sessionID) + require.NoError(t, err) + require.Len(t, events, 1) + require.Equal(t, "{}", string(events[0].Payload)) + require.NotContains(t, string(events[0].Payload), "private") + require.NotContains(t, string(events[0].Payload), "kandev-system") + _, projectionErr := ProjectSessionEvent(events[0]) + require.Error(t, projectionErr) +} + +func TestConversationMessagesAtErrorsWhenPageCursorWasTombstoned(t *testing.T) { + database, err := sqlx.Open("sqlite3", ":memory:") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, database.Close()) }) + _, err = database.Exec(` + CREATE TABLE conversation_message_versions ( + session_id TEXT NOT NULL, message_id TEXT NOT NULL, row_sequence INTEGER NOT NULL, + task_id TEXT, author_type TEXT, created_at TIMESTAMP NOT NULL, + tombstone BOOLEAN NOT NULL, payload TEXT NOT NULL + );`) + require.NoError(t, err) + createdAt := time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) + insertJournalMessageVersion(t, database, 1, false, map[string]any{ + "message_id": "message-1", "turn_id": "turn-1", "task_id": "task-1", + "author_type": "agent", "content": "first", "message_type": "message", + "created_at": createdAt.Format(time.RFC3339Nano), + "updated_at": createdAt.Format(time.RFC3339Nano), + }) + insertJournalMessageVersion(t, database, 2, true, map[string]any{"message_id": "message-1"}) + + service := NewService(nil, NewRegistry(), nil, testLogger(t)) + service.SetConversationJournalDB(database) + + _, _, err = service.conversationMessagesAt( + context.Background(), "session-1", 2, nil, nil, "asc", "message-1", 20, + ) + require.ErrorIs(t, err, errConversationCursorGone) +} + +// TestSyncCommittedSessionEventsPoisonsMalformedPayloadOnDurableLog pins that +// a malformed primary-journal row is mirrored into the FILE-backed durable log +// as a poison record with a non-nil, non-raw payload (the session_events +// payload column is BLOB NOT NULL, so nil would fail the append). +func TestSyncCommittedSessionEventsPoisonsMalformedPayloadOnDurableLog(t *testing.T) { + database, err := sqlx.Open("sqlite3", ":memory:") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, database.Close()) }) + _, err = database.Exec(` + CREATE TABLE conversation_session_streams ( + session_id TEXT PRIMARY KEY, watermark INTEGER NOT NULL + ); + CREATE TABLE conversation_session_events ( + session_id TEXT NOT NULL, sequence INTEGER NOT NULL, event_id TEXT NOT NULL, + protocol_version INTEGER NOT NULL, event_type TEXT NOT NULL, task_id TEXT, + payload TEXT NOT NULL, created_at TIMESTAMP NOT NULL + ); + INSERT INTO conversation_session_streams(session_id, watermark) VALUES ('sess-durable-poison', 1); + INSERT INTO conversation_session_events VALUES ( + 'sess-durable-poison', 1, 'sess-durable-poison:1', 1, 'message.added', 'task-1', + '{"content":"private secret"', + '2026-09-07T12:00:00Z' + );`) + require.NoError(t, err) + + dir := t.TempDir() + service := NewService(nil, NewRegistry(), nil, testLogger(t)) + require.NoError(t, service.SetPluginsDir(dir)) + t.Cleanup(func() { _ = service.Close() }) + service.SetConversationJournalDB(database) + + events, err := service.SyncCommittedSessionEvents(context.Background(), "sess-durable-poison") + require.NoError(t, err) + require.Len(t, events, 1) + require.Equal(t, "{}", string(events[0].Payload)) + log := service.SessionEvents() + record, ok := log.Poison("sess-durable-poison", events[0].ID) + require.True(t, ok) + require.NotEmpty(t, record.LastError) + + // A restart of the durable log keeps the poison and the non-raw payload. + reopened, err := NewSessionEventLog(filepath.Join(dir, ".host", "session-events.sqlite")) + require.NoError(t, err) + t.Cleanup(func() { _ = reopened.Close() }) + restored, ok := reopened.Poison("sess-durable-poison", events[0].ID) + require.True(t, ok) + require.Equal(t, record.Attempts, restored.Attempts) +} diff --git a/apps/backend/internal/plugins/conversation_stream.go b/apps/backend/internal/plugins/conversation_stream.go new file mode 100644 index 0000000000..ae8eae3f82 --- /dev/null +++ b/apps/backend/internal/plugins/conversation_stream.go @@ -0,0 +1,1354 @@ +//nolint:revive // This file groups the durable stream state machine and its protocol validation. +package plugins + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" +) + +const ( + SessionEventProtocolVersion = 1 + SessionPoisonPending = "pending" + SessionPoisonLeased = "leased" + SessionPoisonExhausted = "exhausted" + SessionPoisonRequeued = "requeued" + SessionPoisonMaxAttempts = 5 + SessionPoisonLease = 30 * time.Second + SessionReconnectGrace = 10 * time.Minute + SessionEventRetention = conversationTokenTTL + SessionReconnectGrace + sessionRemovedEventType = "session.removed" +) + +var ( + ErrSessionRemoved = errors.New("session removed") + ErrPoisonEvent = errors.New("poison session event") + ErrForwardGap = errors.New("session event forward gap") + ErrStaleOwnerEpoch = errors.New("stale poison owner epoch") +) + +// SessionDeliveryDisposition is the atomic poison-delivery claim outcome. +type SessionDeliveryDisposition string + +const ( + SessionDeliveryUntracked SessionDeliveryDisposition = "untracked" + SessionDeliveryClaimed SessionDeliveryDisposition = "claimed" + SessionDeliveryLeased SessionDeliveryDisposition = "leased" + SessionDeliveryBackoff SessionDeliveryDisposition = "backoff" + SessionDeliveryExhausted SessionDeliveryDisposition = "exhausted" +) + +// SessionDeliveryClaim is an opaque reservation. Complete must receive the +// exact claim so a stale sender cannot mutate a newer lease or owner epoch. +type SessionDeliveryClaim struct { + Disposition SessionDeliveryDisposition + sessionID string + eventID string + ownerEpoch uint64 + leaseUntil time.Time +} + +// SessionEvent is the immutable Host-only ordered stream envelope. +type SessionEvent struct { + ProtocolVersion int `json:"protocol_version"` + EventType string `json:"event_type"` + SessionID string `json:"session_id"` + TaskID *string `json:"task_id"` + Sequence uint64 `json:"sequence"` + ID string `json:"event_id"` + Payload json.RawMessage `json:"payload"` + CreatedAt time.Time `json:"created_at"` +} + +// SessionDeliveryCursorKey is the complete durable consumer binding. +type SessionDeliveryCursorKey struct { + SessionID string `json:"session_id"` + ConsumerKind string `json:"consumer_kind"` + ConsumerID string `json:"consumer_id"` + WireID string `json:"wire_id"` + PluginID string `json:"plugin_id"` + Generation int64 `json:"generation"` + UserID string `json:"user_id"` +} + +type SessionDeliveryCursor struct { + Key SessionDeliveryCursorKey `json:"key"` + AcknowledgedSequence uint64 `json:"acknowledged_sequence"` + OwnerEpoch uint64 `json:"owner_epoch"` + LeaseUntil time.Time `json:"lease_until,omitempty"` + RetryAt time.Time `json:"retry_at,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +type SessionPoisonRecord struct { + SessionID string `json:"session_id"` + EventID string `json:"event_id"` + Sequence uint64 `json:"sequence"` + ProtocolVersion int `json:"protocol_version"` + LastError string `json:"last_error"` + State string `json:"state"` + Attempts int `json:"attempts"` + OwnerEpoch uint64 `json:"owner_epoch"` + LeaseUntil time.Time `json:"lease_until,omitempty"` + NextRetry time.Time `json:"next_retry,omitempty"` + LastActor string `json:"last_actor,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +type SessionPoisonAudit struct { + SessionID string `json:"session_id"` + EventID string `json:"event_id"` + Actor string `json:"actor"` + PriorState string `json:"prior_state"` + Attempts int `json:"attempts"` + At time.Time `json:"at"` +} + +type sessionEventPartition struct { + Events []SessionEvent `json:"events"` + Watermark uint64 `json:"watermark"` + Terminal bool `json:"terminal"` +} + +type sessionEventLogState struct { + Sessions map[string]*sessionEventPartition `json:"sessions"` + Cursors map[string]*SessionDeliveryCursor `json:"cursors"` + Poison map[string]*SessionPoisonRecord `json:"poison"` + Audits []SessionPoisonAudit `json:"audits"` +} + +func cloneSessionEventLogState(state sessionEventLogState) (sessionEventLogState, error) { + encoded, err := json.Marshal(state) + if err != nil { + return sessionEventLogState{}, fmt.Errorf("clone session event log state: %w", err) + } + var clone sessionEventLogState + if err := json.Unmarshal(encoded, &clone); err != nil { + return sessionEventLogState{}, fmt.Errorf("clone session event log state: %w", err) + } + return clone, nil +} + +// SessionEventLog serializes append, versioning, poison, terminal tombstones, +// and consumer cursor advancement behind one persistence boundary. +type SessionEventLog struct { + mu sync.Mutex + path string + db *sql.DB + state sessionEventLogState + now func() time.Time +} + +// SessionDeliveryDispatcher owns poison delivery transitions and recovery. +type SessionDeliveryDispatcher struct { + events *SessionEventLog +} + +func NewSessionDeliveryDispatcher(events *SessionEventLog) *SessionDeliveryDispatcher { + return &SessionDeliveryDispatcher{events: events} +} + +// Claim atomically reserves a poison delivery. Untracked events need no +// completion; claimed poison must be completed after the queue attempt. +func (d *SessionDeliveryDispatcher) Claim(sessionID, eventID string, now time.Time) (SessionDeliveryClaim, error) { + return d.events.claimPoisonDelivery(sessionID, eventID, now) +} + +// Complete records whether a claimed poison frame reached any client queue. +func (d *SessionDeliveryDispatcher) Complete(claim SessionDeliveryClaim, queued bool, now time.Time) error { + return d.events.completePoisonDelivery(claim, queued, now) +} + +func (d *SessionDeliveryDispatcher) Requeue( + sessionID string, + eventID string, + expectedOwnerEpoch uint64, + actor string, + now time.Time, +) error { + return d.events.requeuePoison(sessionID, eventID, expectedOwnerEpoch, actor, now) +} + +func (d *SessionDeliveryDispatcher) ReclaimExpiredLeases(now time.Time) error { + return d.events.reclaimExpiredLeases(now) +} + +func NewSessionEventLog(path string) (*SessionEventLog, error) { + log := &SessionEventLog{ + state: sessionEventLogState{ + Sessions: make(map[string]*sessionEventPartition), + Cursors: make(map[string]*SessionDeliveryCursor), + Poison: make(map[string]*SessionPoisonRecord), + }, + now: time.Now, + } + if path == "" { + return log, nil + } + log.path = path + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, fmt.Errorf("create session event log directory: %w", err) + } + db, err := sql.Open("sqlite3", path) + if err != nil { + return nil, fmt.Errorf("open session event log: %w", err) + } + db.SetMaxOpenConns(1) + log.db = db + if err := log.initializeDatabase(); err != nil { + _ = db.Close() + return nil, err + } + if err := log.loadDatabase(); err != nil { + _ = db.Close() + return nil, err + } + return log, nil +} + +// Close releases the durable event log database. +func (l *SessionEventLog) Close() error { + l.mu.Lock() + defer l.mu.Unlock() + if l.db == nil { + return nil + } + err := l.db.Close() + l.db = nil + return err +} +func mustSessionEventLog() *SessionEventLog { + log, err := NewSessionEventLog("") + if err != nil { + panic(err) + } + return log +} + +func (l *SessionEventLog) Append( + sessionID string, + taskID *string, + eventType string, + payload json.RawMessage, +) (SessionEvent, error) { + if !json.Valid(payload) { + return SessionEvent{}, fmt.Errorf("%w: malformed payload", ErrPoisonEvent) + } + l.mu.Lock() + defer l.mu.Unlock() + partition := l.state.Sessions[sessionID] + newPartition := partition == nil + if newPartition { + partition = &sessionEventPartition{} + l.state.Sessions[sessionID] = partition + } + previousTerminal := partition.Terminal + previousWatermark := partition.Watermark + if partition.Terminal { + return SessionEvent{}, ErrSessionRemoved + } + event := SessionEvent{ + ProtocolVersion: SessionEventProtocolVersion, + EventType: eventType, + SessionID: sessionID, + TaskID: taskID, + Sequence: partition.Watermark + 1, + ID: uuid.NewString(), + Payload: append(json.RawMessage(nil), payload...), + CreatedAt: l.now().UTC(), + } + partition.Events = append(partition.Events, event) + partition.Watermark = event.Sequence + if eventType == sessionRemovedEventType { + partition.Terminal = true + } + var poison *SessionPoisonRecord + if _, projectionErr := ProjectSessionEvent(event); projectionErr != nil { + poison = &SessionPoisonRecord{ + SessionID: sessionID, EventID: event.ID, Sequence: event.Sequence, + ProtocolVersion: event.ProtocolVersion, LastError: projectionErr.Error(), + State: SessionPoisonPending, OwnerEpoch: 1, UpdatedAt: event.CreatedAt, + } + l.state.Poison[poisonKey(sessionID, event.ID)] = poison + } + if err := l.persistAppendLocked(event, poison); err != nil { + partition.Events = partition.Events[:len(partition.Events)-1] + partition.Terminal = previousTerminal + partition.Watermark = previousWatermark + delete(l.state.Poison, poisonKey(sessionID, event.ID)) + if newPartition { + delete(l.state.Sessions, sessionID) + } + return SessionEvent{}, err + } + return event, nil +} + +// AppendCommitted mirrors an event whose sequence and identity were allocated +// in the source mutation's primary-database transaction. +func (l *SessionEventLog) AppendCommitted(event SessionEvent) (bool, error) { + if !json.Valid(event.Payload) { + return false, fmt.Errorf("%w: malformed payload", ErrPoisonEvent) + } + l.mu.Lock() + defer l.mu.Unlock() + partition := l.state.Sessions[event.SessionID] + newPartition := partition == nil + if newPartition { + partition = &sessionEventPartition{} + l.state.Sessions[event.SessionID] = partition + } + previousWatermark := partition.Watermark + if event.Sequence <= partition.Watermark { + for _, existing := range partition.Events { + if existing.Sequence == event.Sequence { + if existing.ID != event.ID || existing.EventType != event.EventType { + return false, fmt.Errorf("%w: sequence identity mismatch", ErrPoisonEvent) + } + return false, nil + } + } + // The local replay row may already have aged out while its monotonic + // watermark remains. The primary journal is authoritative. + return false, nil + } + if partition.Terminal { + return false, ErrSessionRemoved + } + if event.Sequence != partition.Watermark+1 { + if !mirrorGapHealable(newPartition, partition) { + return false, ErrForwardGap + } + // Collection can drop a partition (terminal, after retention) or + // truncate its rows (idle, non-terminal) once no cursor or poison + // protects the session. The primary journal then prunes the same + // aged rows, so the first surviving row may start past the local + // watermark. The primary is authoritative for mirrored rows: jump + // the replay boundary and accept the row. + partition.Watermark = event.Sequence - 1 + } + previousTerminal := partition.Terminal + partition.Events = append(partition.Events, event) + partition.Watermark = event.Sequence + if event.EventType == sessionRemovedEventType { + partition.Terminal = true + } + var poison *SessionPoisonRecord + if _, projectionErr := ProjectSessionEvent(event); projectionErr != nil { + poison = &SessionPoisonRecord{ + SessionID: event.SessionID, EventID: event.ID, Sequence: event.Sequence, + ProtocolVersion: event.ProtocolVersion, LastError: projectionErr.Error(), + State: SessionPoisonPending, OwnerEpoch: 1, UpdatedAt: event.CreatedAt, + } + l.state.Poison[poisonKey(event.SessionID, event.ID)] = poison + } + if err := l.persistAppendLocked(event, poison); err != nil { + partition.Events = partition.Events[:len(partition.Events)-1] + partition.Watermark = previousWatermark + partition.Terminal = previousTerminal + delete(l.state.Poison, poisonKey(event.SessionID, event.ID)) + if newPartition { + delete(l.state.Sessions, event.SessionID) + } + return false, err + } + return true, nil +} + +// mirrorGapHealable reports whether a mirror row that starts past the local +// watermark can be accepted by jumping the replay boundary. Cursor/poison- +// protected sessions always keep their rows, so a gap into a non-empty (or +// still-terminal) partition means real history is missing and must stay an +// error; an empty partition can only mean both sides already discarded the +// intermediate rows. +func mirrorGapHealable(newPartition bool, partition *sessionEventPartition) bool { + if newPartition { + return true + } + return len(partition.Events) == 0 && !partition.Terminal +} + +func (l *SessionEventLog) EventsAfter(sessionID string, sequence uint64) ([]SessionEvent, bool) { + events, _, terminal := l.ReplayState(sessionID, sequence) + return events, terminal +} + +// ReplayState captures replay rows, the current watermark, and terminal state +// under one lock so subscription registration cannot construct a torn cutoff. +func (l *SessionEventLog) ReplayState( + sessionID string, + sequence uint64, +) ([]SessionEvent, uint64, bool) { + l.mu.Lock() + defer l.mu.Unlock() + partition := l.state.Sessions[sessionID] + if partition == nil { + return nil, 0, false + } + start := len(partition.Events) + for index := range partition.Events { + if partition.Events[index].Sequence > sequence { + start = index + break + } + } + result := append([]SessionEvent(nil), partition.Events[start:]...) + return result, partition.Watermark, partition.Terminal +} + +func (l *SessionEventLog) Watermark(sessionID string) uint64 { + l.mu.Lock() + defer l.mu.Unlock() + partition := l.state.Sessions[sessionID] + if partition == nil { + return 0 + } + return partition.Watermark +} + +func (l *SessionEventLog) RegisterCursor(key SessionDeliveryCursorKey, sequence uint64) error { + l.mu.Lock() + defer l.mu.Unlock() + partition := l.state.Sessions[key.SessionID] + if partition != nil && sequence > partition.Watermark { + return ErrForwardGap + } + encodedKey := deliveryCursorKey(key) + previous := l.state.Cursors[encodedKey] + if previous != nil { + previousCopy := *previous + previous.UpdatedAt = l.now().UTC() + if err := l.persistLocked(); err != nil { + *previous = previousCopy + return err + } + return nil + } + l.state.Cursors[encodedKey] = &SessionDeliveryCursor{ + Key: key, AcknowledgedSequence: sequence, OwnerEpoch: 1, UpdatedAt: l.now().UTC(), + } + if err := l.persistLocked(); err != nil { + delete(l.state.Cursors, encodedKey) + return err + } + return nil +} + +func (l *SessionEventLog) ReleaseCursor(key SessionDeliveryCursorKey) error { + l.mu.Lock() + defer l.mu.Unlock() + encodedKey := deliveryCursorKey(key) + cursor := l.state.Cursors[encodedKey] + if cursor == nil { + return nil + } + delete(l.state.Cursors, encodedKey) + if err := l.persistLocked(); err != nil { + l.state.Cursors[encodedKey] = cursor + return err + } + return nil +} + +// ReplaceCursor atomically advances a consumer to a new replay boundary. +// Rebinds use this after invalid resume or poison recovery; RegisterCursor +// intentionally preserves an existing acknowledgement for ordinary reconnects. +func (l *SessionEventLog) ReplaceCursor(key SessionDeliveryCursorKey, sequence uint64) error { + l.mu.Lock() + defer l.mu.Unlock() + partition := l.state.Sessions[key.SessionID] + if partition != nil && sequence > partition.Watermark { + return ErrForwardGap + } + encodedKey := deliveryCursorKey(key) + previous, err := cloneSessionEventLogState(l.state) + if err != nil { + return err + } + cursor := l.state.Cursors[encodedKey] + if cursor == nil { + cursor = &SessionDeliveryCursor{Key: key} + l.state.Cursors[encodedKey] = cursor + } + cursor.AcknowledgedSequence = sequence + cursor.OwnerEpoch++ + cursor.UpdatedAt = l.now().UTC() + if err := l.persistLocked(); err != nil { + l.state = previous + return err + } + return nil +} +func (l *SessionEventLog) HasCursor(key SessionDeliveryCursorKey) bool { + l.mu.Lock() + defer l.mu.Unlock() + _, exists := l.state.Cursors[deliveryCursorKey(key)] + return exists +} + +func (l *SessionEventLog) Acknowledge(key SessionDeliveryCursorKey, sequence uint64) error { + l.mu.Lock() + defer l.mu.Unlock() + partition := l.state.Sessions[key.SessionID] + watermark := uint64(0) + if partition != nil { + watermark = partition.Watermark + } + if sequence > watermark { + return ErrForwardGap + } + encodedKey := deliveryCursorKey(key) + cursor := l.state.Cursors[encodedKey] + if cursor == nil { + cursor = &SessionDeliveryCursor{Key: key} + l.state.Cursors[encodedKey] = cursor + } + if sequence <= cursor.AcknowledgedSequence { + return nil + } + for _, event := range partition.Events { + if event.Sequence <= cursor.AcknowledgedSequence || event.Sequence > sequence { + continue + } + if _, poisoned := l.state.Poison[poisonKey(key.SessionID, event.ID)]; poisoned { + return ErrPoisonEvent + } + } + previous := cursor.AcknowledgedSequence + previousUpdatedAt := cursor.UpdatedAt + cursor.AcknowledgedSequence = sequence + cursor.UpdatedAt = l.now().UTC() + // ACKs are per contiguous event, so persist only the advanced cursor row + // (one upsert) instead of rewriting every retained partition, event, + // cursor, poison record, and audit with the full-state transaction. + if err := l.persistCursorRowLocked(encodedKey, cursor); err != nil { + cursor.AcknowledgedSequence = previous + cursor.UpdatedAt = previousUpdatedAt + return err + } + return nil +} + +// persistCursorRowLocked writes exactly one session_delivery_cursors row, +// leaving every other row untouched. Called on the hot ACK path; full-state +// rewrites remain for structural changes (collection, poison transitions, +// registration of the first cursor). +func (l *SessionEventLog) persistCursorRowLocked(encodedKey string, cursor *SessionDeliveryCursor) error { + if l.path == "" { + return nil + } + if l.db == nil { + return errors.New("session event log database is closed") + } + tx, err := l.db.Begin() + if err != nil { + return fmt.Errorf("begin session event cursor transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + binding, err := json.Marshal(cursor.Key) + if err != nil { + return fmt.Errorf("encode delivery cursor: %w", err) + } + query := sqlx.Rebind(sqlx.QUESTION, ` + INSERT INTO session_delivery_cursors(cursor_key, binding, acknowledged_sequence, owner_epoch, lease_until, retry_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(cursor_key) DO UPDATE SET + binding = excluded.binding, + acknowledged_sequence = excluded.acknowledged_sequence, + owner_epoch = excluded.owner_epoch, + lease_until = excluded.lease_until, + retry_at = excluded.retry_at, + updated_at = excluded.updated_at`) + if _, err := tx.Exec(query, + encodedKey, binding, cursor.AcknowledgedSequence, cursor.OwnerEpoch, + nullableTime(cursor.LeaseUntil), nullableTime(cursor.RetryAt), + cursor.UpdatedAt.Format(time.RFC3339Nano)); err != nil { + return fmt.Errorf("persist delivery cursor row: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit session event cursor transaction: %w", err) + } + return nil +} + +// CollectExpired removes replay state only after both the binding-token and +// reconnect-grace windows have elapsed. A live cursor retains its complete +// partition so reconnect never observes a partially collected history. +// +//nolint:cyclop // Collection coordinates cursor, poison, tombstone, and partition retention. +func (l *SessionEventLog) CollectExpired(now time.Time) error { + l.mu.Lock() + defer l.mu.Unlock() + previous, err := cloneSessionEventLogState(l.state) + if err != nil { + return err + } + cutoff := now.UTC().Add(-SessionEventRetention) + activeSessions := make(map[string]struct{}) + for encodedKey, cursor := range l.state.Cursors { + if cursor.UpdatedAt.After(cutoff) { + activeSessions[cursor.Key.SessionID] = struct{}{} + continue + } + delete(l.state.Cursors, encodedKey) + } + for key, record := range l.state.Poison { + if !record.UpdatedAt.After(cutoff) { + delete(l.state.Poison, key) + } + } + keptAudits := l.state.Audits[:0] + for _, audit := range l.state.Audits { + if audit.At.After(cutoff) { + keptAudits = append(keptAudits, audit) + } + } + l.state.Audits = keptAudits + for sessionID, partition := range l.state.Sessions { + if _, active := activeSessions[sessionID]; active || l.sessionHasPoisonLocked(sessionID) { + continue + } + kept := partition.Events[:0] + for _, event := range partition.Events { + if event.CreatedAt.After(cutoff) { + kept = append(kept, event) + } + } + partition.Events = kept + if partition.Terminal && len(partition.Events) == 0 { + delete(l.state.Sessions, sessionID) + } + } + if err := l.persistLocked(); err != nil { + l.state = previous + return err + } + return nil +} + +func (l *SessionEventLog) sessionHasPoisonLocked(sessionID string) bool { + for _, record := range l.state.Poison { + if record.SessionID == sessionID { + return true + } + } + return false +} + +// RetainedSessionIDs returns sessions whose sidecar state still protects +// reconnect or poison delivery. Primary journal history may be pruned only +// for sessions outside this set. +func (l *SessionEventLog) RetainedSessionIDs(now time.Time) map[string]struct{} { + l.mu.Lock() + defer l.mu.Unlock() + cutoff := now.UTC().Add(-SessionEventRetention) + retained := make(map[string]struct{}) + for _, cursor := range l.state.Cursors { + if cursor.UpdatedAt.After(cutoff) { + retained[cursor.Key.SessionID] = struct{}{} + } + } + for _, record := range l.state.Poison { + if record.UpdatedAt.After(cutoff) { + retained[record.SessionID] = struct{}{} + } + } + return retained +} + +func (l *SessionEventLog) Poison(sessionID, eventID string) (SessionPoisonRecord, bool) { + l.mu.Lock() + defer l.mu.Unlock() + record, ok := l.state.Poison[poisonKey(sessionID, eventID)] + if !ok { + return SessionPoisonRecord{}, false + } + return *record, true +} + +func (l *SessionEventLog) claimPoisonDelivery( + sessionID string, + eventID string, + now time.Time, +) (SessionDeliveryClaim, error) { + l.mu.Lock() + defer l.mu.Unlock() + record := l.state.Poison[poisonKey(sessionID, eventID)] + if record == nil { + return SessionDeliveryClaim{Disposition: SessionDeliveryUntracked}, nil + } + now = now.UTC() + disposition := SessionDeliveryClaimed + switch { + case record.State == SessionPoisonExhausted: + disposition = SessionDeliveryExhausted + case record.State == SessionPoisonLeased && record.LeaseUntil.After(now): + disposition = SessionDeliveryLeased + case record.NextRetry.After(now): + disposition = SessionDeliveryBackoff + } + if disposition != SessionDeliveryClaimed { + return SessionDeliveryClaim{Disposition: disposition}, nil + } + previous, err := cloneSessionEventLogState(l.state) + if err != nil { + return SessionDeliveryClaim{}, err + } + record.State = SessionPoisonLeased + record.LeaseUntil = now.Add(SessionPoisonLease) + record.UpdatedAt = now + claim := SessionDeliveryClaim{ + Disposition: SessionDeliveryClaimed, + sessionID: sessionID, eventID: eventID, + ownerEpoch: record.OwnerEpoch, leaseUntil: record.LeaseUntil, + } + if err := l.persistLocked(); err != nil { + l.state = previous + return SessionDeliveryClaim{}, err + } + return claim, nil +} + +func (l *SessionEventLog) completePoisonDelivery( + claim SessionDeliveryClaim, + queued bool, + now time.Time, +) error { + if claim.Disposition != SessionDeliveryClaimed { + return nil + } + l.mu.Lock() + defer l.mu.Unlock() + record := l.state.Poison[poisonKey(claim.sessionID, claim.eventID)] + if record == nil { + return ErrPoisonEvent + } + if record.State != SessionPoisonLeased || + record.OwnerEpoch != claim.ownerEpoch || + !record.LeaseUntil.Equal(claim.leaseUntil) { + return ErrStaleOwnerEpoch + } + previous, err := cloneSessionEventLogState(l.state) + if err != nil { + return err + } + now = now.UTC() + if !queued { + record.State = SessionPoisonPending + record.LeaseUntil = time.Time{} + record.NextRetry = time.Time{} + record.UpdatedAt = now + } else { + record.Attempts++ + backoff := time.Second << min(record.Attempts-1, 5) + if backoff > SessionPoisonLease { + backoff = SessionPoisonLease + } + record.NextRetry = record.LeaseUntil.Add(backoff) + if record.Attempts >= SessionPoisonMaxAttempts { + record.State = SessionPoisonExhausted + record.LeaseUntil = time.Time{} + record.NextRetry = time.Time{} + } + record.UpdatedAt = now + } + if err := l.persistLocked(); err != nil { + l.state = previous + return err + } + return nil +} + +func (l *SessionEventLog) PendingPoison(now time.Time) []SessionPoisonRecord { + l.mu.Lock() + defer l.mu.Unlock() + now = now.UTC() + result := make([]SessionPoisonRecord, 0) + for _, record := range l.state.Poison { + if record.State == SessionPoisonPending && !record.NextRetry.After(now) { + result = append(result, *record) + } + } + return result +} + +func (l *SessionEventLog) requeuePoison( + sessionID string, + eventID string, + expectedOwnerEpoch uint64, + actor string, + now time.Time, +) error { + l.mu.Lock() + defer l.mu.Unlock() + record := l.state.Poison[poisonKey(sessionID, eventID)] + if record == nil { + return ErrPoisonEvent + } + if record.State == SessionPoisonPending && record.OwnerEpoch == expectedOwnerEpoch+1 { + return nil + } + if record.OwnerEpoch != expectedOwnerEpoch { + return ErrStaleOwnerEpoch + } + previous, err := cloneSessionEventLogState(l.state) + if err != nil { + return err + } + priorState := record.State + priorAttempts := record.Attempts + record.State = SessionPoisonRequeued + record.OwnerEpoch++ + record.Attempts = 0 + record.LeaseUntil = time.Time{} + record.NextRetry = time.Time{} + record.LastActor = actor + record.UpdatedAt = now.UTC() + l.state.Audits = append(l.state.Audits, SessionPoisonAudit{ + SessionID: sessionID, EventID: eventID, Actor: actor, + PriorState: priorState, Attempts: priorAttempts, At: now.UTC(), + }) + record.State = SessionPoisonPending + if err := l.persistLocked(); err != nil { + l.state = previous + return err + } + return nil +} + +func (l *SessionEventLog) reclaimExpiredLeases(now time.Time) error { + l.mu.Lock() + defer l.mu.Unlock() + previous, err := cloneSessionEventLogState(l.state) + if err != nil { + return err + } + changed := false + for _, record := range l.state.Poison { + if record.State == SessionPoisonLeased && !record.LeaseUntil.After(now) { + record.State = SessionPoisonPending + record.LeaseUntil = time.Time{} + record.UpdatedAt = now.UTC() + changed = true + } + } + if !changed { + return nil + } + if err := l.persistLocked(); err != nil { + l.state = previous + return err + } + return nil +} + +//nolint:goconst // Event names are protocol literals. +func ProjectSessionEvent(event SessionEvent) (json.RawMessage, error) { + if event.ProtocolVersion != SessionEventProtocolVersion { + return nil, fmt.Errorf("%w: unsupported protocol version", ErrPoisonEvent) + } + switch event.EventType { + case "message.added", "message.updated", "message.deleted", + "session.turn.started", "session.turn.completed", "session.turn.removed", "session.removed": + default: + return nil, fmt.Errorf("%w: unsupported event type", ErrPoisonEvent) + } + var payload map[string]any + if err := json.Unmarshal(event.Payload, &payload); err != nil { + return nil, fmt.Errorf("%w: malformed payload", ErrPoisonEvent) + } + if payload["type"] != event.EventType { + return nil, fmt.Errorf("%w: payload event type mismatch", ErrPoisonEvent) + } + if sessionID, ok := payload["session_id"].(string); !ok || sessionID == "" || sessionID != event.SessionID { + return nil, fmt.Errorf("%w: payload session identity mismatch", ErrPoisonEvent) + } + if err := validateSessionEventTask(event, payload); err != nil { + return nil, err + } + if err := validateSessionEventPayload(event.EventType, payload); err != nil { + return nil, err + } + return append(json.RawMessage(nil), event.Payload...), nil +} + +func validateSessionEventTask(event SessionEvent, payload map[string]any) error { + taskID, present := payload["task_id"] + if event.TaskID == nil { + if !present || taskID == nil { + return nil + } + return fmt.Errorf("%w: payload task identity mismatch", ErrPoisonEvent) + } + value, ok := taskID.(string) + if !ok || value == "" || value != *event.TaskID { + return fmt.Errorf("%w: payload task identity mismatch", ErrPoisonEvent) + } + return nil +} + +//nolint:cyclop,goconst // Event validation is intentionally explicit per protocol event type. +func validateSessionEventPayload(eventType string, payload map[string]any) error { + requiredString := func(field string) (string, error) { + value, ok := payload[field].(string) + if !ok || value == "" { + return "", fmt.Errorf("%w: missing %s", ErrPoisonEvent, field) + } + return value, nil + } + requiredTime := func(field string) error { + value, err := requiredString(field) + if err != nil { + return err + } + if _, err := time.Parse(time.RFC3339Nano, value); err != nil { + return fmt.Errorf("%w: invalid %s", ErrPoisonEvent, field) + } + return nil + } + switch eventType { + case "message.added", "message.updated": + if _, err := requiredString("message_id"); err != nil { + return err + } + author, err := requiredString("author_type") + if err != nil { + return err + } + if author != "user" && author != "agent" { + return fmt.Errorf("%w: invalid author_type", ErrPoisonEvent) + } + if _, ok := payload["content"].(string); !ok { + return fmt.Errorf("%w: missing content", ErrPoisonEvent) + } + if err := requiredTime("created_at"); err != nil { + return err + } + if eventType == "message.updated" { + return requiredTime("updated_at") + } + case "message.deleted": + _, err := requiredString("message_id") + return err + case "session.turn.removed": + _, err := requiredString("id") + return err + case "session.turn.started", "session.turn.completed": + if _, err := requiredString("id"); err != nil { + return err + } + if err := requiredTime("started_at"); err != nil { + return err + } + if eventType == "session.turn.completed" { + if err := requiredTime("completed_at"); err != nil { + return err + } + return requiredTime("updated_at") + } + case "session.removed": + return nil + } + return nil +} + +func (l *SessionEventLog) persistAppendLocked(event SessionEvent, poison *SessionPoisonRecord) error { + if l.path == "" { + return nil + } + if l.db == nil { + return errors.New("session event log database is closed") + } + tx, err := l.db.Begin() + if err != nil { + return fmt.Errorf("begin session event append: %w", err) + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.Exec(sqlx.Rebind(sqlx.QUESTION, `INSERT INTO session_event_partitions(session_id, watermark, terminal) VALUES (?, ?, ?) ON CONFLICT(session_id) DO UPDATE SET watermark = excluded.watermark, terminal = excluded.terminal`), + event.SessionID, event.Sequence, event.EventType == sessionRemovedEventType); err != nil { + return fmt.Errorf("persist session partition: %w", err) + } + if _, err := tx.Exec(sqlx.Rebind(sqlx.QUESTION, `INSERT INTO session_events(session_id, sequence, event_id, protocol_version, event_type, task_id, payload, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`), + event.SessionID, event.Sequence, event.ID, event.ProtocolVersion, event.EventType, nullableString(event.TaskID), []byte(event.Payload), event.CreatedAt.Format(time.RFC3339Nano)); err != nil { + return fmt.Errorf("persist session event: %w", err) + } + if poison != nil { + if _, err := tx.Exec(sqlx.Rebind(sqlx.QUESTION, `INSERT INTO session_poison(poison_key, record) VALUES (?, ?)`), poisonKey(event.SessionID, event.ID), mustJSON(poison)); err != nil { + return fmt.Errorf("persist poison record: %w", err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit session event append: %w", err) + } + return nil +} + +func (l *SessionEventLog) persistLocked() error { + if l.path == "" { + return nil + } + if l.db == nil { + return errors.New("session event log database is closed") + } + tx, err := l.db.Begin() + if err != nil { + return fmt.Errorf("begin session event transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + if err := resetSessionEventTables(tx); err != nil { + return err + } + if err := l.persistSessions(tx); err != nil { + return err + } + if err := l.persistConsumerState(tx); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit session event transaction: %w", err) + } + return nil +} + +func resetSessionEventTables(tx *sql.Tx) error { + for _, table := range []string{"session_poison_audits", "session_poison", "session_delivery_cursors", "session_events", "session_event_partitions"} { + if _, err := tx.Exec("DELETE FROM " + table); err != nil { + return fmt.Errorf("reset %s: %w", table, err) + } + } + return nil +} + +func (l *SessionEventLog) persistSessions(tx *sql.Tx) error { + for sessionID, partition := range l.state.Sessions { + if _, err := tx.Exec(sqlx.Rebind(sqlx.QUESTION, `INSERT INTO session_event_partitions(session_id, watermark, terminal) VALUES (?, ?, ?)`), sessionID, partition.Watermark, partition.Terminal); err != nil { + return fmt.Errorf("persist session partition: %w", err) + } + for _, event := range partition.Events { + if _, err := tx.Exec(sqlx.Rebind(sqlx.QUESTION, `INSERT INTO session_events(session_id, sequence, event_id, protocol_version, event_type, task_id, payload, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`), + event.SessionID, event.Sequence, event.ID, event.ProtocolVersion, event.EventType, nullableString(event.TaskID), []byte(event.Payload), event.CreatedAt.Format(time.RFC3339Nano)); err != nil { + return fmt.Errorf("persist session event: %w", err) + } + } + } + return nil +} + +func (l *SessionEventLog) persistConsumerState(tx *sql.Tx) error { + for encodedKey, cursor := range l.state.Cursors { + key, err := json.Marshal(cursor.Key) + if err != nil { + return fmt.Errorf("encode delivery cursor: %w", err) + } + if _, err := tx.Exec(sqlx.Rebind(sqlx.QUESTION, `INSERT INTO session_delivery_cursors(cursor_key, binding, acknowledged_sequence, owner_epoch, lease_until, retry_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`), + encodedKey, key, cursor.AcknowledgedSequence, cursor.OwnerEpoch, nullableTime(cursor.LeaseUntil), nullableTime(cursor.RetryAt), cursor.UpdatedAt.Format(time.RFC3339Nano)); err != nil { + return fmt.Errorf("persist delivery cursor: %w", err) + } + } + for encodedKey, record := range l.state.Poison { + if _, err := tx.Exec(sqlx.Rebind(sqlx.QUESTION, `INSERT INTO session_poison(poison_key, record) VALUES (?, ?)`), encodedKey, mustJSON(record)); err != nil { + return fmt.Errorf("persist poison record: %w", err) + } + } + for _, audit := range l.state.Audits { + if _, err := tx.Exec(sqlx.Rebind(sqlx.QUESTION, `INSERT INTO session_poison_audits(record) VALUES (?)`), mustJSON(audit)); err != nil { + return fmt.Errorf("persist poison audit: %w", err) + } + } + return nil +} + +func (l *SessionEventLog) initializeDatabase() error { + const schema = ` +CREATE TABLE IF NOT EXISTS session_event_partitions ( + session_id TEXT PRIMARY KEY, + watermark INTEGER NOT NULL DEFAULT 0, + terminal INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS session_events ( + session_id TEXT NOT NULL, + sequence INTEGER NOT NULL, + event_id TEXT NOT NULL UNIQUE, + protocol_version INTEGER NOT NULL, + event_type TEXT NOT NULL, + task_id TEXT, + payload BLOB NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY(session_id, sequence) +); +CREATE TABLE IF NOT EXISTS session_delivery_cursors ( + cursor_key TEXT PRIMARY KEY, + binding BLOB NOT NULL, + acknowledged_sequence INTEGER NOT NULL, + owner_epoch INTEGER NOT NULL DEFAULT 1, + lease_until TEXT NOT NULL DEFAULT '', + retry_at TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS session_poison ( + poison_key TEXT PRIMARY KEY, + record BLOB NOT NULL +); +CREATE TABLE IF NOT EXISTS session_poison_audits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + record BLOB NOT NULL +);` + if _, err := l.db.Exec(schema); err != nil { + return fmt.Errorf("initialize session event log: %w", err) + } + if _, err := l.db.Exec(`ALTER TABLE session_event_partitions ADD COLUMN watermark INTEGER NOT NULL DEFAULT 0`); err != nil && + !strings.Contains(strings.ToLower(err.Error()), "duplicate column") { + return fmt.Errorf("migrate session event partition watermark: %w", err) + } + for _, migration := range []struct { + column string + definition string + }{ + {"owner_epoch", "INTEGER NOT NULL DEFAULT 1"}, + {"lease_until", "TEXT NOT NULL DEFAULT ''"}, + {"retry_at", "TEXT NOT NULL DEFAULT ''"}, + } { + query := fmt.Sprintf("ALTER TABLE session_delivery_cursors ADD COLUMN %s %s", migration.column, migration.definition) + if _, err := l.db.Exec(query); err != nil && + !strings.Contains(strings.ToLower(err.Error()), "duplicate column") { + return fmt.Errorf("migrate session delivery cursor %s: %w", migration.column, err) + } + } + return nil +} + +func (l *SessionEventLog) loadDatabase() error { + if err := l.loadPartitions(); err != nil { + return err + } + if err := l.loadEvents(); err != nil { + return err + } + if err := l.loadCursors(); err != nil { + return err + } + if err := l.loadPoisonState(); err != nil { + return err + } + return nil +} + +func (l *SessionEventLog) loadPartitions() error { + rows, err := l.db.Query(`SELECT session_id, watermark, terminal FROM session_event_partitions`) + if err != nil { + return fmt.Errorf("load session partitions: %w", err) + } + for rows.Next() { + var sessionID string + var watermark uint64 + var terminal bool + if err := rows.Scan(&sessionID, &watermark, &terminal); err != nil { + _ = rows.Close() + return fmt.Errorf("scan session partition: %w", err) + } + l.state.Sessions[sessionID] = &sessionEventPartition{Watermark: watermark, Terminal: terminal} + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return fmt.Errorf("iterate session partitions: %w", err) + } + if err := rows.Close(); err != nil { + return fmt.Errorf("close session partitions: %w", err) + } + return nil +} + +func (l *SessionEventLog) loadEvents() error { + rows, err := l.db.Query(`SELECT session_id, sequence, event_id, protocol_version, event_type, task_id, payload, created_at FROM session_events ORDER BY session_id, sequence`) + if err != nil { + return fmt.Errorf("load session events: %w", err) + } + for rows.Next() { + event, err := scanSessionEvent(rows) + if err != nil { + _ = rows.Close() + return err + } + partition := l.state.Sessions[event.SessionID] + if partition == nil { + partition = &sessionEventPartition{} + l.state.Sessions[event.SessionID] = partition + } + partition.Events = append(partition.Events, event) + if event.Sequence > partition.Watermark { + partition.Watermark = event.Sequence + } + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return fmt.Errorf("iterate session events: %w", err) + } + if err := rows.Close(); err != nil { + return fmt.Errorf("close session events: %w", err) + } + return nil +} + +func scanSessionEvent(rows *sql.Rows) (SessionEvent, error) { + var event SessionEvent + var taskID sql.NullString + var createdAt string + if err := rows.Scan(&event.SessionID, &event.Sequence, &event.ID, &event.ProtocolVersion, &event.EventType, &taskID, &event.Payload, &createdAt); err != nil { + return SessionEvent{}, fmt.Errorf("scan session event: %w", err) + } + if taskID.Valid { + event.TaskID = &taskID.String + } + parsedCreatedAt, err := time.Parse(time.RFC3339Nano, createdAt) + if err != nil { + return SessionEvent{}, fmt.Errorf("parse session event time: %w", err) + } + event.CreatedAt = parsedCreatedAt + return event, nil +} + +func (l *SessionEventLog) loadCursors() error { + err := l.loadJSONRows(`SELECT cursor_key, binding, acknowledged_sequence, owner_epoch, lease_until, retry_at, updated_at FROM session_delivery_cursors`, func(columns []any) error { + encodedKey := columns[0].(string) + var key SessionDeliveryCursorKey + if err := json.Unmarshal(columns[1].([]byte), &key); err != nil { + return err + } + leaseUntil, err := parseOptionalTime(columns[4]) + if err != nil { + return err + } + retryAt, err := parseOptionalTime(columns[5]) + if err != nil { + return err + } + updatedAt, err := time.Parse(time.RFC3339Nano, columns[6].(string)) + if err != nil { + return err + } + l.state.Cursors[encodedKey] = &SessionDeliveryCursor{ + Key: key, AcknowledgedSequence: uint64(columns[2].(int64)), OwnerEpoch: uint64(columns[3].(int64)), + LeaseUntil: leaseUntil, RetryAt: retryAt, UpdatedAt: updatedAt, + } + return nil + }) + if err != nil { + return fmt.Errorf("load delivery cursors: %w", err) + } + return nil +} + +func (l *SessionEventLog) loadPoisonState() error { + if err := l.loadJSONRows(`SELECT poison_key, record FROM session_poison`, func(columns []any) error { + var record SessionPoisonRecord + if err := json.Unmarshal(columns[1].([]byte), &record); err != nil { + return err + } + l.state.Poison[columns[0].(string)] = &record + return nil + }); err != nil { + return fmt.Errorf("load poison records: %w", err) + } + if err := l.loadJSONRows(`SELECT record FROM session_poison_audits ORDER BY id`, func(columns []any) error { + var audit SessionPoisonAudit + if err := json.Unmarshal(columns[0].([]byte), &audit); err != nil { + return err + } + l.state.Audits = append(l.state.Audits, audit) + return nil + }); err != nil { + return fmt.Errorf("load poison audits: %w", err) + } + return nil +} + +func (l *SessionEventLog) loadJSONRows(query string, consume func([]any) error) error { + rows, err := l.db.Query(query) + if err != nil { + return err + } + defer func() { _ = rows.Close() }() + columnNames, err := rows.Columns() + if err != nil { + return err + } + for rows.Next() { + values := make([]any, len(columnNames)) + destinations := make([]any, len(columnNames)) + for index := range values { + destinations[index] = &values[index] + } + if err := rows.Scan(destinations...); err != nil { + return err + } + if err := consume(values); err != nil { + return err + } + } + return rows.Err() +} +func nullableTime(value time.Time) any { + if value.IsZero() { + return "" + } + return value.UTC().Format(time.RFC3339Nano) +} + +func parseOptionalTime(value any) (time.Time, error) { + var raw string + switch typed := value.(type) { + case string: + raw = typed + case []byte: + raw = string(typed) + case nil: + return time.Time{}, nil + default: + return time.Time{}, fmt.Errorf("unexpected optional time type %T", value) + } + if raw == "" { + return time.Time{}, nil + } + return time.Parse(time.RFC3339Nano, raw) +} + +func nullableString(value *string) any { + if value == nil { + return nil + } + return *value +} + +func mustJSON(value any) []byte { + encoded, err := json.Marshal(value) + if err != nil { + panic(err) + } + return encoded +} + +func poisonKey(sessionID, eventID string) string { + return sessionID + "\x00" + eventID +} + +func deliveryCursorKey(key SessionDeliveryCursorKey) string { + return strings.Join([]string{ + key.SessionID, + key.ConsumerKind, + key.ConsumerID, + key.WireID, + key.PluginID, + fmt.Sprintf("%d", key.Generation), + key.UserID, + }, "\x00") +} diff --git a/apps/backend/internal/plugins/conversation_stream_service.go b/apps/backend/internal/plugins/conversation_stream_service.go new file mode 100644 index 0000000000..cd4cdeceff --- /dev/null +++ b/apps/backend/internal/plugins/conversation_stream_service.go @@ -0,0 +1,125 @@ +package plugins + +import ( + "errors" + "time" +) + +var ( + ErrConversationBindingInvalid = errors.New("invalid conversation binding") + ErrConversationGenerationSuperseded = errors.New("conversation generation superseded") +) + +// SessionEvents returns the service-owned durable stream store used by the +// production WebSocket gateway. +func (s *Service) SessionEvents() *SessionEventLog { + return s.sessionEvents +} + +// isSessionRemoved reports whether the durable stream has a terminal removal +// marker for sessionID. It is the only journal fallback authorization after +// the task-service session lookup no longer succeeds. +func (s *Service) isSessionRemoved(sessionID string) bool { + if s.sessionEvents == nil { + return false + } + _, _, terminal := s.sessionEvents.ReplayState(sessionID, 0) + return terminal +} + +// SessionDelivery returns the service-owned poison delivery dispatcher. +func (s *Service) SessionDelivery() *SessionDeliveryDispatcher { + return s.sessionDelivery +} + +// AuthorizeConversationConsumer validates an active capability declaration and +// the loader-issued user/plugin/generation binding without exposing token data. +func (s *Service) AuthorizeConversationConsumer( + pluginID string, + userID string, + generation int64, + bindingToken string, +) error { + record, err := s.Get(pluginID) + if err != nil || record.Status != StatusActive || !record.Capabilities.CanRead("messages") { + return ErrConversationBindingInvalid + } + if conversationGeneration(record.InstalledAt) != generation { + return ErrConversationGenerationSuperseded + } + claims, err := s.conversationTokens.parse(bindingToken) + if err != nil || + claims.Kind != tokenKindBinding || + claims.PluginID != pluginID || + claims.UserID != userID || + claims.Generation != generation { + return ErrConversationBindingInvalid + } + return nil +} + +func (s *Service) MintSessionStreamGrant( + pluginID string, + userID string, + generation int64, + sessionID string, + consumerID string, + wireID string, + snapshotCutoff uint64, + resumeCursor uint64, +) (snapshotToken string, resumeToken string, expiresAt time.Time, err error) { + fingerprint := consumerID + if fingerprint == "" { + fingerprint = wireID + } + snapshotToken, err = s.conversationTokens.mintSnapshot( + pluginID, + userID, + generation, + sessionID, + nil, + "", + nil, + int64(snapshotCutoff), + fingerprint, + ) + if err != nil { + return "", "", time.Time{}, err + } + resumeToken, err = s.conversationTokens.mintResume( + pluginID, + userID, + generation, + sessionID, + consumerID, + wireID, + resumeCursor, + ) + if err != nil { + return "", "", time.Time{}, err + } + return snapshotToken, resumeToken, time.Now().UTC().Add(conversationTokenTTL), nil +} + +func (s *Service) ValidateSessionResume( + token string, + key SessionDeliveryCursorKey, + sequence uint64, +) error { + if token == "" { + return ErrConversationBindingInvalid + } + claims, err := s.conversationTokens.parse(token) + if err != nil || + claims.Kind != tokenKindResume || + claims.PluginID != key.PluginID || + claims.UserID != key.UserID || + claims.Generation != key.Generation || + claims.SessionID != key.SessionID || + claims.ConsumerID != key.ConsumerID || + claims.WireID != key.WireID || + claims.Sequence > sequence { + return ErrConversationBindingInvalid + } + return nil +} diff --git a/apps/backend/internal/plugins/conversation_stream_test.go b/apps/backend/internal/plugins/conversation_stream_test.go new file mode 100644 index 0000000000..e7f3f1e578 --- /dev/null +++ b/apps/backend/internal/plugins/conversation_stream_test.go @@ -0,0 +1,439 @@ +package plugins + +import ( + "context" + "encoding/json" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestSessionEventLogPersistsSequenceAndTerminalRemoval(t *testing.T) { + path := filepath.Join(t.TempDir(), "session-events.db") + log, err := NewSessionEventLog(path) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, log.Close()) }) + first, err := log.Append("session-1", stringPtr("task-1"), "message.added", validMessageAddedPayload("m1")) + require.NoError(t, err) + removed, err := log.Append("session-1", stringPtr("task-1"), "session.removed", validSessionRemovedPayload()) + require.NoError(t, err) + require.Equal(t, uint64(1), first.Sequence) + require.Equal(t, uint64(2), removed.Sequence) + require.NoError(t, log.Close()) + + reopened, err := NewSessionEventLog(path) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, reopened.Close()) }) + events, terminal := reopened.EventsAfter("session-1", 0) + require.True(t, terminal) + require.Equal(t, []uint64{1, 2}, []uint64{events[0].Sequence, events[1].Sequence}) + _, err = reopened.Append("session-1", stringPtr("task-1"), "message.updated", json.RawMessage(`{"type":"message.updated","id":"m1"}`)) + require.ErrorIs(t, err, ErrSessionRemoved) +} + +func TestSessionEventLogPoisonBlocksAckUntilAuditedRequeue(t *testing.T) { + log, err := NewSessionEventLog("") + require.NoError(t, err) + dispatcher := NewSessionDeliveryDispatcher(log) + _, err = log.Append("session-1", stringPtr("task-1"), "message.added", validMessageAddedPayload("m1")) + require.NoError(t, err) + poison, err := log.Append("session-1", stringPtr("task-1"), "unknown.event", json.RawMessage(`{"type":"unknown.event","session_id":"session-1","task_id":"task-1"}`)) + require.NoError(t, err) + cursor := testCursor() + require.NoError(t, log.RegisterCursor(cursor, 0)) + require.NoError(t, log.Acknowledge(cursor, 1)) + require.ErrorIs(t, log.Acknowledge(cursor, 2), ErrPoisonEvent) + base := time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) + for attempt := range SessionPoisonMaxAttempts { + claimAndCompletePoison(t, dispatcher, "session-1", poison.ID, base.Add(time.Duration(attempt)*time.Minute), true) + } + record, ok := log.Poison("session-1", poison.ID) + require.True(t, ok) + require.Equal(t, SessionPoisonExhausted, record.State) + require.Equal(t, SessionPoisonMaxAttempts, record.Attempts) + require.ErrorIs(t, dispatcher.Requeue("session-1", poison.ID, record.OwnerEpoch+1, "admin-1", base), ErrStaleOwnerEpoch) + require.NoError(t, dispatcher.Requeue("session-1", poison.ID, record.OwnerEpoch, "admin-1", base)) +} + +func TestSessionEventPoisonDeliveryClaimControlsLeaseBackoffExhaustionAndRequeue(t *testing.T) { + log, err := NewSessionEventLog("") + require.NoError(t, err) + dispatcher := NewSessionDeliveryDispatcher(log) + plain, err := log.Append("session-1", stringPtr("task-1"), "message.added", validMessageAddedPayload("plain")) + require.NoError(t, err) + plainClaim, err := dispatcher.Claim("session-1", plain.ID, time.Now()) + require.NoError(t, err) + require.Equal(t, SessionDeliveryUntracked, plainClaim.Disposition) + + poison, err := log.Append("session-1", stringPtr("task-1"), "unknown.event", json.RawMessage(`{"type":"unknown.event","session_id":"session-1","task_id":"task-1"}`)) + require.NoError(t, err) + base := time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) + first := claimAndCompletePoison(t, dispatcher, "session-1", poison.ID, base, true) + require.Equal(t, SessionDeliveryClaimed, first.Disposition) + leased, err := dispatcher.Claim("session-1", poison.ID, base.Add(time.Second)) + require.NoError(t, err) + require.Equal(t, SessionDeliveryLeased, leased.Disposition) + backoff, err := dispatcher.Claim("session-1", poison.ID, base.Add(SessionPoisonLease)) + require.NoError(t, err) + require.Equal(t, SessionDeliveryBackoff, backoff.Disposition) + + for attempt := 1; attempt < SessionPoisonMaxAttempts; attempt++ { + claimAndCompletePoison(t, dispatcher, "session-1", poison.ID, base.Add(time.Duration(attempt)*time.Minute), true) + } + exhausted, err := dispatcher.Claim("session-1", poison.ID, base.Add(10*time.Minute)) + require.NoError(t, err) + require.Equal(t, SessionDeliveryExhausted, exhausted.Disposition) + record, ok := log.Poison("session-1", poison.ID) + require.True(t, ok) + require.Equal(t, SessionPoisonMaxAttempts, record.Attempts) + + require.NoError(t, dispatcher.Requeue("session-1", poison.ID, record.OwnerEpoch, "admin-1", base.Add(11*time.Minute))) + requeued := claimAndCompletePoison(t, dispatcher, "session-1", poison.ID, base.Add(11*time.Minute), true) + require.Equal(t, SessionDeliveryClaimed, requeued.Disposition) + record, ok = log.Poison("session-1", poison.ID) + require.True(t, ok) + require.Equal(t, 1, record.Attempts) +} + +func claimAndCompletePoison( + t *testing.T, + dispatcher *SessionDeliveryDispatcher, + sessionID string, + eventID string, + at time.Time, + queued bool, +) SessionDeliveryClaim { + t.Helper() + claim, err := dispatcher.Claim(sessionID, eventID, at) + require.NoError(t, err) + require.Equal(t, SessionDeliveryClaimed, claim.Disposition) + require.NoError(t, dispatcher.Complete(claim, queued, at)) + return claim +} + +func TestSessionEventRetentionKeepsTerminalTombstoneThroughBothBounds(t *testing.T) { + log, err := NewSessionEventLog("") + require.NoError(t, err) + start := time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) + log.now = func() time.Time { return start } + _, err = log.Append("session-1", stringPtr("task-1"), "session.removed", validSessionRemovedPayload()) + require.NoError(t, err) + require.NoError(t, log.CollectExpired(start.Add(SessionEventRetention-time.Second))) + _, terminal := log.EventsAfter("session-1", 0) + require.True(t, terminal) + require.NoError(t, log.CollectExpired(start.Add(SessionEventRetention))) + events, terminal := log.EventsAfter("session-1", 0) + require.False(t, terminal) + require.Empty(t, events) +} + +func TestSessionEventResyncAfterTerminalCollectionHealsForwardGap(t *testing.T) { + path := filepath.Join(t.TempDir(), "session-events.db") + log, err := NewSessionEventLog(path) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, log.Close()) }) + start := time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) + log.now = func() time.Time { return start } + removed, err := log.AppendCommitted(SessionEvent{ + SessionID: "session-1", TaskID: stringPtr("task-1"), Sequence: 3, + ID: "session-1:3", ProtocolVersion: SessionEventProtocolVersion, EventType: "session.removed", + Payload: validSessionRemovedPayload(), CreatedAt: start, + }) + require.NoError(t, err) + require.True(t, removed) + + // After retention both bounds lapse the local partition is dropped: the + // terminal tombstone is gone from the durable log. + require.NoError(t, log.CollectExpired(start.Add(SessionEventRetention+time.Hour))) + _, terminal := log.EventsAfter("session-1", 0) + require.False(t, terminal) + + // The primary journal keeps only its session.removed row (all other rows + // are pruned), which is not at sequence 1. Re-mirroring it must heal the + // gap instead of failing forever. + removed, err = log.AppendCommitted(SessionEvent{ + SessionID: "session-1", TaskID: stringPtr("task-1"), Sequence: 3, + ID: "session-1:3", ProtocolVersion: SessionEventProtocolVersion, EventType: "session.removed", + Payload: validSessionRemovedPayload(), CreatedAt: start.Add(time.Hour), + }) + require.NoError(t, err) + require.True(t, removed) + events, terminal := log.EventsAfter("session-1", 0) + require.True(t, terminal) + require.Len(t, events, 1) + require.Equal(t, uint64(3), events[0].Sequence) +} + +func TestSessionEventResyncAfterTruncationHealsForwardGap(t *testing.T) { + log, err := NewSessionEventLog("") + require.NoError(t, err) + start := time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) + log.now = func() time.Time { return start } + // seq 1 and 2 mirror fine; then the idle partition's rows age out and are + // truncated while the primary keeps a newer row at seq 4 (seq 3 pruned). + for _, sequence := range []uint64{1, 2} { + _, err := log.AppendCommitted(SessionEvent{ + SessionID: "session-1", TaskID: stringPtr("task-1"), Sequence: sequence, + ID: "session-1:" + strconv.FormatUint(sequence, 10), ProtocolVersion: SessionEventProtocolVersion, EventType: "message.added", + Payload: validMessageAddedPayload("m" + strconv.FormatUint(sequence, 10)), CreatedAt: start, + }) + require.NoError(t, err) + } + require.NoError(t, log.CollectExpired(start.Add(SessionEventRetention+time.Hour))) + _, terminal := log.EventsAfter("session-1", 0) + require.False(t, terminal) + require.Equal(t, uint64(2), log.Watermark("session-1")) + + appended, err := log.AppendCommitted(SessionEvent{ + SessionID: "session-1", TaskID: stringPtr("task-1"), Sequence: 4, + ID: "session-1:4", ProtocolVersion: SessionEventProtocolVersion, EventType: "message.added", + Payload: validMessageAddedPayload("m4"), CreatedAt: start.Add(time.Hour), + }) + require.NoError(t, err) + require.True(t, appended) + require.Equal(t, uint64(4), log.Watermark("session-1")) +} + +func TestSessionEventRetentionExpiresExhaustedPoisonState(t *testing.T) { + log, err := NewSessionEventLog("") + require.NoError(t, err) + start := time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) + log.now = func() time.Time { return start } + event, err := log.Append("session-1", stringPtr("task-1"), "unknown.event", json.RawMessage(`{"type":"unknown.event","session_id":"session-1","task_id":"task-1"}`)) + require.NoError(t, err) + dispatcher := NewSessionDeliveryDispatcher(log) + for attempt := range SessionPoisonMaxAttempts { + claimAndCompletePoison(t, dispatcher, "session-1", event.ID, start.Add(time.Duration(attempt)*time.Minute), true) + } + require.NoError(t, log.CollectExpired(start.Add(SessionEventRetention+5*time.Minute))) + _, ok := log.Poison("session-1", event.ID) + require.False(t, ok) + events, terminal := log.EventsAfter("session-1", 0) + require.False(t, terminal) + require.Empty(t, events) +} + +func TestSessionEventCursorRegistrationAndExplicitRelease(t *testing.T) { + log, err := NewSessionEventLog("") + require.NoError(t, err) + event, err := log.Append("session-1", stringPtr("task-1"), "message.added", validMessageAddedPayload("m1")) + require.NoError(t, err) + key := testCursor() + require.NoError(t, log.RegisterCursor(key, event.Sequence)) + require.Contains(t, log.state.Cursors, deliveryCursorKey(key)) + require.NoError(t, log.ReleaseCursor(key)) + require.NotContains(t, log.state.Cursors, deliveryCursorKey(key)) +} + +func TestSessionEventCursorReplacementAdvancesExistingCursor(t *testing.T) { + log, err := NewSessionEventLog("") + require.NoError(t, err) + event, err := log.Append("session-1", stringPtr("task-1"), "unknown.event", json.RawMessage(`{"type":"unknown.event","session_id":"session-1","task_id":"task-1"}`)) + require.NoError(t, err) + key := testCursor() + require.NoError(t, log.RegisterCursor(key, 0)) + require.NoError(t, log.ReplaceCursor(key, event.Sequence)) + require.NoError(t, log.Acknowledge(key, event.Sequence)) +} + +func TestSessionEventCursorLifecycleFieldsSurviveRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "session-events.db") + log, err := NewSessionEventLog(path) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, log.Close()) }) + key := testCursor() + key.Generation = 4 + require.NoError(t, log.RegisterCursor(key, 2)) + leaseUntil := time.Date(2026, 9, 7, 12, 5, 0, 0, time.UTC) + retryAt := time.Date(2026, 9, 7, 12, 6, 0, 0, time.UTC) + log.mu.Lock() + cursor := log.state.Cursors[deliveryCursorKey(key)] + cursor.LeaseUntil = leaseUntil + cursor.RetryAt = retryAt + cursor.OwnerEpoch = 9 + err = log.persistLocked() + log.mu.Unlock() + require.NoError(t, err) + + require.NoError(t, log.Close()) + + reopened, err := NewSessionEventLog(path) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, reopened.Close()) }) + restored := reopened.state.Cursors[deliveryCursorKey(key)] + require.Equal(t, uint64(9), restored.OwnerEpoch) + require.Equal(t, leaseUntil, restored.LeaseUntil) + require.Equal(t, retryAt, restored.RetryAt) +} + +func TestSessionEventMaintenanceDoesNotCountUndeliveredPoisonAttempts(t *testing.T) { + service := NewService(nil, NewRegistry(), nil, testLogger(t)) + start := time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) + poison, err := service.SessionEvents().Append("session-1", stringPtr("task-1"), "unknown.event", json.RawMessage(`{"type":"unknown.event","session_id":"session-1","task_id":"task-1"}`)) + require.NoError(t, err) + // Maintenance runs many times; without any actual delivery attempt the + // poison must stay pending at zero attempts instead of being exhausted. + for _, at := range []time.Time{start, start.Add(SessionPoisonLease), start.Add(2 * SessionPoisonLease)} { + require.NoError(t, service.maintainSessionEvents(context.Background(), at)) + record, ok := service.SessionEvents().Poison("session-1", poison.ID) + require.True(t, ok) + require.Equal(t, 0, record.Attempts) + require.Equal(t, SessionPoisonPending, record.State) + } + // A real queued delivery advances the attempt bookkeeping. + claimAndCompletePoison(t, service.SessionDelivery(), "session-1", poison.ID, start.Add(3*SessionPoisonLease), true) + record, ok := service.SessionEvents().Poison("session-1", poison.ID) + require.True(t, ok) + require.Equal(t, 1, record.Attempts) + require.Equal(t, SessionPoisonLeased, record.State) +} + +func TestSessionEventCursorReplacementRestoresMemoryOnPersistFailure(t *testing.T) { + path := filepath.Join(t.TempDir(), "session-events.db") + log, err := NewSessionEventLog(path) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, log.Close()) }) + key := testCursor() + require.NoError(t, log.RegisterCursor(key, 0)) + event, err := log.Append("session-1", stringPtr("task-1"), "message.added", validMessageAddedPayload("m1")) + require.NoError(t, err) + // Close the held database before redirecting the path so persistence + // failures exercise the closed-log guard. + require.NoError(t, log.Close()) + log.path = filepath.Join(t.TempDir(), "missing", "session-events.db") + + require.Error(t, log.ReplaceCursor(key, event.Sequence)) + + // The in-memory mutation rolled back, so a restart sees the committed + // acknowledgement, not the failed replacement. + restored := log.state.Cursors[deliveryCursorKey(key)] + require.Equal(t, uint64(0), restored.AcknowledgedSequence) + require.Equal(t, uint64(1), restored.OwnerEpoch) + require.NoError(t, log.Close()) + + reopened, err := NewSessionEventLog(path) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, reopened.Close()) }) + require.Equal(t, uint64(0), reopened.state.Cursors[deliveryCursorKey(key)].AcknowledgedSequence) +} + +func TestSessionEventPoisonAttemptRestoresMemoryOnPersistFailure(t *testing.T) { + path := filepath.Join(t.TempDir(), "session-events.db") + log, err := NewSessionEventLog(path) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, log.Close()) }) + dispatcher := NewSessionDeliveryDispatcher(log) + poison, err := log.Append("session-1", stringPtr("task-1"), "unknown.event", json.RawMessage(`{"type":"unknown.event","session_id":"session-1","task_id":"task-1"}`)) + require.NoError(t, err) + require.NoError(t, log.Close()) + log.path = filepath.Join(t.TempDir(), "missing", "session-events.db") + + base := time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) + _, err = dispatcher.Claim("session-1", poison.ID, base) + require.Error(t, err) + + record, ok := log.Poison("session-1", poison.ID) + require.True(t, ok) + require.Equal(t, 0, record.Attempts) + require.Equal(t, SessionPoisonPending, record.State) +} + +func TestSessionEventCollectionRestoresMemoryOnPersistFailure(t *testing.T) { + path := filepath.Join(t.TempDir(), "session-events.db") + log, err := NewSessionEventLog(path) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, log.Close()) }) + start := time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) + log.now = func() time.Time { return start } + _, err = log.Append("session-1", stringPtr("task-1"), "message.added", validMessageAddedPayload("m1")) + require.NoError(t, err) + _, err = log.Append("session-1", stringPtr("task-1"), "session.removed", validSessionRemovedPayload()) + require.NoError(t, err) + require.NoError(t, log.RegisterCursor(testCursor(), 0)) + require.NoError(t, log.Close()) + log.path = filepath.Join(t.TempDir(), "missing", "session-events.db") + + require.Error(t, log.CollectExpired(start.Add(SessionEventRetention+time.Hour))) + + // Nothing was collected in memory: the cursor, poison, and partition + // survive until persistence actually commits. + require.True(t, log.HasCursor(testCursor())) + events, terminal := log.EventsAfter("session-1", 0) + require.True(t, terminal) + require.Len(t, events, 2) +} + +func testCursor() SessionDeliveryCursorKey { + return SessionDeliveryCursorKey{SessionID: "session-1", ConsumerKind: "plugin", ConsumerID: "consumer-1", PluginID: "plugin-1", Generation: 1, UserID: "user-1"} +} + +func stringPtr(value string) *string { return &value } + +func validMessageAddedPayload(messageID string) json.RawMessage { + return json.RawMessage(`{"type":"message.added","session_id":"session-1","task_id":"task-1","message_id":"` + messageID + `","author_type":"user","content":"hello","message_type":"message","created_at":"2026-09-07T12:00:00Z"}`) +} + +func validSessionRemovedPayload() json.RawMessage { + return json.RawMessage(`{"type":"session.removed","session_id":"session-1","task_id":"task-1"}`) +} + +func TestSessionEventAcknowledgePersistsOnlyTheCursorRow(t *testing.T) { + path := filepath.Join(t.TempDir(), "session-events.db") + log, err := NewSessionEventLog(path) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, log.Close()) }) + event, err := log.Append("session-1", stringPtr("task-1"), "message.added", validMessageAddedPayload("m1")) + require.NoError(t, err) + _, err = log.AppendCommitted(SessionEvent{ + SessionID: "session-1", TaskID: stringPtr("task-1"), Sequence: event.Sequence + 1, + ID: "session-1:2", EventType: "message.added", ProtocolVersion: 1, + Payload: validMessageAddedPayload("m2"), CreatedAt: event.CreatedAt, + }) + require.NoError(t, err) + cursorA := testCursor() + cursorA.ConsumerID = "consumer-a" + cursorB := testCursor() + cursorB.ConsumerID = "consumer-b" + require.NoError(t, log.RegisterCursor(cursorA, 0)) + require.NoError(t, log.RegisterCursor(cursorB, 0)) + require.NoError(t, log.Acknowledge(cursorA, 2)) + + require.NoError(t, log.Close()) + reopened, err := NewSessionEventLog(path) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, reopened.Close()) }) + restoredA := reopened.state.Cursors[deliveryCursorKey(cursorA)] + require.Equal(t, uint64(2), restoredA.AcknowledgedSequence) + restoredB := reopened.state.Cursors[deliveryCursorKey(cursorB)] + require.Equal(t, uint64(0), restoredB.AcknowledgedSequence) + // The partition and both event rows survive the delta write untouched. + events, terminal := reopened.EventsAfter("session-1", 0) + require.False(t, terminal) + require.Len(t, events, 2) + require.Equal(t, uint64(2), reopened.Watermark("session-1")) +} + +func TestSessionEventAcknowledgeRestoresTimestampOnPersistFailure(t *testing.T) { + path := filepath.Join(t.TempDir(), "session-events.db") + log, err := NewSessionEventLog(path) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, log.Close()) }) + base := time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) + log.now = func() time.Time { return base } + event, err := log.Append("session-1", stringPtr("task-1"), "message.added", validMessageAddedPayload("m1")) + require.NoError(t, err) + key := testCursor() + require.NoError(t, log.RegisterCursor(key, 0)) + previous := log.state.Cursors[deliveryCursorKey(key)].UpdatedAt + log.now = func() time.Time { return base.Add(time.Minute) } + + require.NoError(t, log.Close()) + require.Error(t, log.Acknowledge(key, event.Sequence)) + + restored := log.state.Cursors[deliveryCursorKey(key)] + require.Equal(t, uint64(0), restored.AcknowledgedSequence) + require.Equal(t, previous, restored.UpdatedAt) +} diff --git a/apps/backend/internal/plugins/conversation_tokens.go b/apps/backend/internal/plugins/conversation_tokens.go new file mode 100644 index 0000000000..1b4ccc1ee1 --- /dev/null +++ b/apps/backend/internal/plugins/conversation_tokens.go @@ -0,0 +1,230 @@ +package plugins + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +const ( + conversationTokenTTL = 10 * time.Minute + tokenKindBinding = "binding" + tokenKindCursor = "cursor" + tokenKindSnapshot = "snapshot" + tokenKindResume = "resume" +) + +// conversationGeneration remains exactly representable by JavaScript while +// retaining microsecond separation between plugin installations. +func conversationGeneration(installedAt time.Time) int64 { + return installedAt.UnixMicro() +} + +type conversationTokenClaims struct { + Version int `json:"v"` + Kind string `json:"kind"` + PluginID string `json:"plugin_id"` + UserID string `json:"user_id"` + Generation int64 `json:"generation"` + ExpiresAt int64 `json:"expires_at"` + SessionID string `json:"session_id,omitempty"` + TaskID *string `json:"task_id,omitempty"` + Sort string `json:"sort,omitempty"` + Authors []string `json:"authors,omitempty"` + LastID string `json:"last_id,omitempty"` + Cutoff int64 `json:"cutoff,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + ConsumerID string `json:"consumer_id,omitempty"` + WireID string `json:"wire_id,omitempty"` + Sequence uint64 `json:"sequence,omitempty"` + Nonce string `json:"nonce"` +} + +type conversationTokenManager struct { + key []byte + now func() time.Time +} + +func newConversationTokenManager() *conversationTokenManager { + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + panic("plugins: cannot initialize conversation token signer: " + err.Error()) + } + return &conversationTokenManager{key: key, now: time.Now} +} + +func loadOrCreateConversationTokenManager(path string) (*conversationTokenManager, error) { + key, err := os.ReadFile(path) + if err == nil { + return conversationTokenManagerFromKey(key) + } + if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("read conversation token signing key: %w", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, fmt.Errorf("create conversation token key directory: %w", err) + } + key = make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return nil, fmt.Errorf("generate conversation token signing key: %w", err) + } + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if errors.Is(err, os.ErrExist) { + key, err = os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read concurrently created conversation token signing key: %w", err) + } + return conversationTokenManagerFromKey(key) + } + if err != nil { + return nil, fmt.Errorf("create conversation token signing key: %w", err) + } + if _, err := file.Write(key); err != nil { + _ = file.Close() + return nil, fmt.Errorf("write conversation token signing key: %w", err) + } + if err := file.Sync(); err != nil { + _ = file.Close() + return nil, fmt.Errorf("sync conversation token signing key: %w", err) + } + if err := file.Close(); err != nil { + return nil, fmt.Errorf("close conversation token signing key: %w", err) + } + return conversationTokenManagerFromKey(key) +} + +func conversationTokenManagerFromKey(key []byte) (*conversationTokenManager, error) { + if len(key) != 32 { + return nil, fmt.Errorf("conversation token signing key must be 32 bytes, got %d", len(key)) + } + return &conversationTokenManager{key: append([]byte(nil), key...), now: time.Now}, nil +} + +func (m *conversationTokenManager) mintBinding( + pluginID string, + userID string, + generation int64, +) (string, time.Time, error) { + expiresAt := m.now().UTC().Add(conversationTokenTTL) + claims := conversationTokenClaims{ + Version: 1, Kind: tokenKindBinding, PluginID: pluginID, UserID: userID, + Generation: generation, ExpiresAt: expiresAt.Unix(), + } + token, err := m.seal(claims) + return token, expiresAt, err +} + +func (m *conversationTokenManager) mintCursor( + pluginID string, + userID string, + generation int64, + sessionID string, + taskID *string, + sortOrder string, + authors []string, + lastID string, + cutoff int64, + fingerprint string, +) (string, error) { + return m.seal(conversationTokenClaims{ + Version: 1, Kind: tokenKindCursor, PluginID: pluginID, UserID: userID, + Generation: generation, ExpiresAt: m.now().UTC().Add(conversationTokenTTL).Unix(), + SessionID: sessionID, TaskID: taskID, Sort: sortOrder, Authors: authors, LastID: lastID, + Cutoff: cutoff, Fingerprint: fingerprint, + }) +} + +func (m *conversationTokenManager) mintSnapshot( + pluginID string, + userID string, + generation int64, + sessionID string, + taskID *string, + sortOrder string, + authors []string, + cutoff int64, + fingerprint string, +) (string, error) { + return m.seal(conversationTokenClaims{ + Version: 1, Kind: tokenKindSnapshot, PluginID: pluginID, UserID: userID, + Generation: generation, ExpiresAt: m.now().UTC().Add(conversationTokenTTL).Unix(), + SessionID: sessionID, TaskID: taskID, Sort: sortOrder, Authors: authors, + Cutoff: cutoff, Fingerprint: fingerprint, + }) +} + +func (m *conversationTokenManager) renew(claims conversationTokenClaims) (string, time.Time, error) { + expiresAt := m.now().UTC().Add(conversationTokenTTL) + claims.ExpiresAt = expiresAt.Unix() + claims.Nonce = "" + token, err := m.seal(claims) + return token, expiresAt, err +} + +func (m *conversationTokenManager) mintResume( + pluginID string, + userID string, + generation int64, + sessionID string, + consumerID string, + wireID string, + sequence uint64, +) (string, error) { + return m.seal(conversationTokenClaims{ + Version: 1, Kind: tokenKindResume, PluginID: pluginID, UserID: userID, + Generation: generation, ExpiresAt: m.now().UTC().Add(conversationTokenTTL).Unix(), + SessionID: sessionID, ConsumerID: consumerID, WireID: wireID, Sequence: sequence, + }) +} + +func (m *conversationTokenManager) seal(claims conversationTokenClaims) (string, error) { + if claims.Nonce == "" { + nonce := make([]byte, 16) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + claims.Nonce = base64.RawURLEncoding.EncodeToString(nonce) + } + payload, err := json.Marshal(claims) + if err != nil { + return "", err + } + encoded := base64.RawURLEncoding.EncodeToString(payload) + mac := hmac.New(sha256.New, m.key) + _, _ = mac.Write([]byte(encoded)) + signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + return encoded + "." + signature, nil +} + +func (m *conversationTokenManager) parse(token string) (conversationTokenClaims, error) { + var claims conversationTokenClaims + payloadPart, signaturePart, ok := strings.Cut(token, ".") + if !ok || payloadPart == "" || signaturePart == "" { + return claims, errors.New("invalid token") + } + signature, err := base64.RawURLEncoding.DecodeString(signaturePart) + if err != nil { + return claims, errors.New("invalid token") + } + mac := hmac.New(sha256.New, m.key) + _, _ = mac.Write([]byte(payloadPart)) + if !hmac.Equal(signature, mac.Sum(nil)) { + return claims, errors.New("invalid token") + } + payload, err := base64.RawURLEncoding.DecodeString(payloadPart) + if err != nil || json.Unmarshal(payload, &claims) != nil || claims.Version != 1 { + return conversationTokenClaims{}, errors.New("invalid token") + } + if !m.now().UTC().Before(time.Unix(claims.ExpiresAt, 0)) { + return conversationTokenClaims{}, errors.New("expired token") + } + return claims, nil +} diff --git a/apps/backend/internal/plugins/handlers.go b/apps/backend/internal/plugins/handlers.go index 5c5043fc7b..26f2940bca 100644 --- a/apps/backend/internal/plugins/handlers.go +++ b/apps/backend/internal/plugins/handlers.go @@ -62,18 +62,37 @@ type webhookInvoker interface { // static-file serving (from the extracted package on disk), and the // external webhook relay (HTTP -> Host RPC over the live subprocess). type Controller struct { - svc *Service - log *logger.Logger - actionInvoker actionInvoker - webhookInvoker webhookInvoker + svc *Service + log *logger.Logger + actionInvoker actionInvoker + webhookInvoker webhookInvoker + conversationTokens *conversationTokenManager + conversationReader ConversationReader } // RegisterRoutes wires the plugin HTTP surface. deliverer is accepted for // parity with the backendapp wiring (svc.SetDeliverer(deliverer) happens // alongside this call) — no handler in this file calls it directly, since // Service already notifies it on every install/status change. -func RegisterRoutes(router *gin.Engine, svc *Service, _ Deliverer, log *logger.Logger) { - ctrl := &Controller{svc: svc, log: log, actionInvoker: svc, webhookInvoker: svc} +func RegisterRoutes( + router *gin.Engine, + svc *Service, + _ Deliverer, + log *logger.Logger, + conversationReaders ...ConversationReader, +) { + var conversationReader ConversationReader + if len(conversationReaders) > 0 { + conversationReader = conversationReaders[0] + } + ctrl := &Controller{ + svc: svc, + log: log, + actionInvoker: svc, + webhookInvoker: svc, + conversationTokens: svc.conversationTokens, + conversationReader: conversationReader, + } api := router.Group("/api/plugins") // Instance admin, not an org scope: plugins load into the shared host @@ -106,6 +125,7 @@ func RegisterRoutes(router *gin.Engine, svc *Service, _ Deliverer, log *logger.L // /settings is registered before /:id above: some gin/httprouter tree // versions reject a static-ish sibling added after an existing wildcard. registerUserStateRoutes(api, ctrl) + registerConversationRoutes(api, ctrl) api.POST("/:id/webhooks/:key", ctrl.webhook) api.GET("/:id/webhooks/:key", ctrl.webhook) } diff --git a/apps/backend/internal/plugins/handlers_management_auth_test.go b/apps/backend/internal/plugins/handlers_management_auth_test.go index bafc5b5268..2caa9d7c69 100644 --- a/apps/backend/internal/plugins/handlers_management_auth_test.go +++ b/apps/backend/internal/plugins/handlers_management_auth_test.go @@ -12,7 +12,12 @@ import ( "github.com/kandev/kandev/internal/plugins/store" ) -func registerPluginRoutesWithIdentity(t *testing.T, svc *Service, identity authn.Identity) *gin.Engine { +func registerPluginRoutesWithIdentity( + t *testing.T, + svc *Service, + identity authn.Identity, + conversationReaders ...ConversationReader, +) *gin.Engine { t.Helper() gin.SetMode(gin.TestMode) router := gin.New() @@ -20,7 +25,7 @@ func registerPluginRoutesWithIdentity(t *testing.T, svc *Service, identity authn authn.SetOnGin(ctx, identity) ctx.Next() }) - RegisterRoutes(router, svc, nil, testLogger(t)) + RegisterRoutes(router, svc, nil, testLogger(t), conversationReaders...) return router } diff --git a/apps/backend/internal/plugins/manifest/clone.go b/apps/backend/internal/plugins/manifest/clone.go new file mode 100644 index 0000000000..56794bc272 --- /dev/null +++ b/apps/backend/internal/plugins/manifest/clone.go @@ -0,0 +1,145 @@ +package manifest + +// Clone returns an independent copy of the manifest and all nested mutable +// values. Registry callers can mutate the result without changing stored state. +func (m Manifest) Clone() Manifest { + clone := m + clone.Categories = cloneStrings(m.Categories) + clone.Webhooks = cloneWebhooks(m.Webhooks) + clone.Actions = cloneActions(m.Actions) + clone.RepositoryProviders = cloneStrings(m.RepositoryProviders) + clone.ReferenceSources = cloneReferenceSources(m.ReferenceSources) + clone.AuthProviders = cloneAuthProviders(m.AuthProviders) + clone.ConfigSchema = cloneMap(m.ConfigSchema) + clone.AgentTools = cloneAgentTools(m.AgentTools) + clone.Capabilities.Events = cloneStrings(m.Capabilities.Events) + clone.Capabilities.APIRead = cloneStrings(m.Capabilities.APIRead) + clone.Capabilities.APIWrite = cloneStrings(m.Capabilities.APIWrite) + clone.UI.Pages = append([]UIPage(nil), m.UI.Pages...) + clone.UI.Styles = cloneStrings(m.UI.Styles) + clone.UI.Keybindings = append([]UIKeybinding(nil), m.UI.Keybindings...) + clone.UI.WebApps = cloneWebApps(m.UI.WebApps) + clone.Runtime.Executables = cloneStringMap(m.Runtime.Executables) + return clone +} + +func cloneStrings(values []string) []string { + if values == nil { + return nil + } + clone := make([]string, len(values)) + copy(clone, values) + return clone +} + +func cloneStringMap(values map[string]string) map[string]string { + if values == nil { + return nil + } + clone := make(map[string]string, len(values)) + for key, value := range values { + clone[key] = value + } + return clone +} + +func cloneWebhooks(values []Webhook) []Webhook { + if values == nil { + return nil + } + return append([]Webhook(nil), values...) +} + +func cloneActions(values []Action) []Action { + if values == nil { + return nil + } + return append([]Action(nil), values...) +} + +func cloneReferenceSources(values []ReferenceSource) []ReferenceSource { + if values == nil { + return nil + } + return append([]ReferenceSource(nil), values...) +} + +func cloneAuthProviders(values []AuthProvider) []AuthProvider { + if values == nil { + return nil + } + return append([]AuthProvider(nil), values...) +} + +func cloneWebApps(values []WebApp) []WebApp { + if values == nil { + return nil + } + clone := make([]WebApp, len(values)) + for index, value := range values { + clone[index] = value + clone[index].Placements = cloneStrings(value.Placements) + clone[index].NetworkOrigins = cloneStrings(value.NetworkOrigins) + } + return clone +} + +func cloneAgentTools(values []AgentTool) []AgentTool { + if values == nil { + return nil + } + clone := make([]AgentTool, len(values)) + for index, value := range values { + clone[index] = value + clone[index].Surfaces = cloneStrings(value.Surfaces) + clone[index].InputSchema = cloneMap(value.InputSchema) + clone[index].OutputSchema = cloneMap(value.OutputSchema) + clone[index].Annotations.ReadOnlyHint = cloneBool(value.Annotations.ReadOnlyHint) + clone[index].Annotations.DestructiveHint = cloneBool(value.Annotations.DestructiveHint) + clone[index].Annotations.IdempotentHint = cloneBool(value.Annotations.IdempotentHint) + clone[index].Annotations.OpenWorldHint = cloneBool(value.Annotations.OpenWorldHint) + } + return clone +} + +func cloneBool(value *bool) *bool { + if value == nil { + return nil + } + clone := *value + return &clone +} + +func cloneMap(values map[string]any) map[string]any { + if values == nil { + return nil + } + clone := make(map[string]any, len(values)) + for key, value := range values { + clone[key] = cloneValue(value) + } + return clone +} + +func cloneValue(value any) any { + switch typed := value.(type) { + case map[string]any: + return cloneMap(typed) + case map[any]any: + clone := make(map[any]any, len(typed)) + for key, nested := range typed { + clone[key] = cloneValue(nested) + } + return clone + case []any: + clone := make([]any, len(typed)) + for index, nested := range typed { + clone[index] = cloneValue(nested) + } + return clone + case []string: + return cloneStrings(typed) + default: + return value + } +} diff --git a/apps/backend/internal/plugins/manifest/clone_test.go b/apps/backend/internal/plugins/manifest/clone_test.go new file mode 100644 index 0000000000..c0e7c61ceb --- /dev/null +++ b/apps/backend/internal/plugins/manifest/clone_test.go @@ -0,0 +1,40 @@ +package manifest + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestManifestCloneDoesNotAliasNestedValues(t *testing.T) { + readOnly := true + manifest := Manifest{ + Categories: []string{"tools"}, + RepositoryProviders: []string{"github"}, + ConfigSchema: map[string]any{ + "nested": map[string]any{"values": []any{"one", map[string]any{"enabled": true}}}, + }, + AgentTools: []AgentTool{{ + Surfaces: []string{"kanban-task"}, + InputSchema: map[string]any{"type": "object"}, + Annotations: AgentToolAnnotations{ReadOnlyHint: &readOnly}, + }}, + UI: UISection{WebApps: []WebApp{{Placements: []string{"task-canvas"}}}}, + Runtime: Runtime{Executables: map[string]string{"linux-amd64": "server/plugin"}}, + } + + clone := manifest.Clone() + clone.Categories[0] = "changed" + clone.ConfigSchema["nested"].(map[string]any)["values"].([]any)[1].(map[string]any)["enabled"] = false + clone.AgentTools[0].Surfaces[0] = "office-task" + *clone.AgentTools[0].Annotations.ReadOnlyHint = false + clone.UI.WebApps[0].Placements[0] = "workspace-canvas" + clone.Runtime.Executables["linux-amd64"] = "changed" + + require.Equal(t, "tools", manifest.Categories[0]) + require.Equal(t, true, manifest.ConfigSchema["nested"].(map[string]any)["values"].([]any)[1].(map[string]any)["enabled"]) + require.Equal(t, "kanban-task", manifest.AgentTools[0].Surfaces[0]) + require.True(t, *manifest.AgentTools[0].Annotations.ReadOnlyHint) + require.Equal(t, "task-canvas", manifest.UI.WebApps[0].Placements[0]) + require.Equal(t, "server/plugin", manifest.Runtime.Executables["linux-amd64"]) +} diff --git a/apps/backend/internal/plugins/manifest/min_version_policy.go b/apps/backend/internal/plugins/manifest/min_version_policy.go new file mode 100644 index 0000000000..421c127041 --- /dev/null +++ b/apps/backend/internal/plugins/manifest/min_version_policy.go @@ -0,0 +1,28 @@ +package manifest + +import "fmt" + +// MinimumMessagesCapabilityVersion is the first Host release that supports +// both Go Messages().List and the browser conversation facade safely. +const MinimumMessagesCapabilityVersion = "0.91.1" + +// RequiresMessagesCapabilityMinimum reports whether a manifest opts into the +// messages read resource whose Host contract requires a minimum release. +func RequiresMessagesCapabilityMinimum(manifest *Manifest) bool { + return manifest != nil && manifest.Capabilities.CanRead("messages") +} + +func (m *Manifest) validateCapabilityMinimumVersions() []error { + if !RequiresMessagesCapabilityMinimum(m) { + return nil + } + minimum, valid := NormalizeReleaseVersion(m.MinKandevVersion) + if !valid || CompareVersions(minimum, MinimumMessagesCapabilityVersion) < 0 { + return []error{fmt.Errorf( + "api_read resource %q requires min_kandev_version >= %s", + "messages", + MinimumMessagesCapabilityVersion, + )} + } + return nil +} diff --git a/apps/backend/internal/plugins/manifest/min_version_policy_test.go b/apps/backend/internal/plugins/manifest/min_version_policy_test.go new file mode 100644 index 0000000000..e60b3e56dd --- /dev/null +++ b/apps/backend/internal/plugins/manifest/min_version_policy_test.go @@ -0,0 +1,51 @@ +package manifest + +import ( + "strings" + "testing" +) + +func TestMessagesReadCapabilityRequiresSupportedMinimumVersion(t *testing.T) { + tests := []struct { + name string + minimum string + wantReject bool + }{ + {name: "missing", wantReject: true}, + {name: "malformed", minimum: "next", wantReject: true}, + {name: "lower", minimum: "0.91.0", wantReject: true}, + {name: "equal", minimum: MinimumMessagesCapabilityVersion}, + {name: "higher", minimum: "0.92.0"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + manifest := validManifest(t) + manifest.Capabilities.APIRead = append(manifest.Capabilities.APIRead, "messages") + manifest.MinKandevVersion = test.minimum + + err := manifest.Validate() + if test.wantReject { + if err == nil || !strings.Contains(err.Error(), "api_read resource \"messages\" requires min_kandev_version >= 0.91.1") { + t.Fatalf("Validate() error = %v, want messages capability minimum-version rejection", err) + } + return + } + if err != nil { + t.Fatalf("Validate() unexpected error: %v", err) + } + }) + } +} + +func TestMessagesMinimumVersionPolicyDoesNotAffectOtherResources(t *testing.T) { + manifest := validManifest(t) + manifest.Capabilities.APIRead = []string{"tasks"} + manifest.MinKandevVersion = "" + + if err := manifest.Validate(); err != nil { + t.Fatalf("Validate() unexpected error: %v", err) + } + if RequiresMessagesCapabilityMinimum(manifest) { + t.Fatal("tasks-only manifest unexpectedly requires the messages capability minimum") + } +} diff --git a/apps/backend/internal/plugins/manifest/validate.go b/apps/backend/internal/plugins/manifest/validate.go index ca4c28c6ea..c597a741ab 100644 --- a/apps/backend/internal/plugins/manifest/validate.go +++ b/apps/backend/internal/plugins/manifest/validate.go @@ -142,6 +142,7 @@ func (m *Manifest) Validate() error { errs = append(errs, m.validateUIKeybindings()...) errs = append(errs, m.validateWebhooks()...) errs = append(errs, m.validateActions()...) + errs = append(errs, m.validateCapabilityMinimumVersions()...) errs = append(errs, m.validateRepositoryProviders()...) errs = append(errs, m.validateReferenceSources()...) errs = append(errs, m.validateAgentTools()...) diff --git a/apps/backend/internal/plugins/provider.go b/apps/backend/internal/plugins/provider.go index 73d5c8bd41..bbf7d74d21 100644 --- a/apps/backend/internal/plugins/provider.go +++ b/apps/backend/internal/plugins/provider.go @@ -118,7 +118,14 @@ func ProvideWithStoreErrors(cfg *config.Config, dbPool *db.Pool, secrets SecretV configureWebAppStorage(cfg, svc, instanceStore, instanceState, log) svc.SetSecrets(secrets) - svc.SetPluginsDir(dir) + if dbPool != nil && dbPool.Writer() != nil { + svc.SetConversationJournalDB(dbPool.Writer()) + } + if err := svc.SetPluginsDir(dir); err != nil { + warnProvider(log, "Plugins durable conversation state initialization failed; continuing with degraded conversation capabilities", err) + } else if _, err := svc.syncAllCommittedSessionEvents(context.Background()); err != nil { + warnProvider(log, "Plugins committed conversation journal synchronization failed; continuing with degraded conversation capabilities", err) + } seedMarketplace(svc, sourceStore, storeErrors, log) if settingsStore != nil { @@ -131,12 +138,13 @@ func ProvideWithStoreErrors(cfg *config.Config, dbPool *db.Pool, secrets SecretV if cfg.Features.Canvases { stopArtifactCleanup = svc.StartWebAppArtifactCleanupWorker(context.Background()) } + stopSessionEventMaintenance := svc.StartSessionEventMaintenanceWorker(context.Background()) cleanup := func() error { stopArtifactCleanup() + stopSessionEventMaintenance() svc.closeWebAppEvents() - rt.StopAll() - return nil + return svc.Close() } return svc, cleanup, storeErrors } diff --git a/apps/backend/internal/plugins/provider_test.go b/apps/backend/internal/plugins/provider_test.go index c366739026..bf80c0e6cc 100644 --- a/apps/backend/internal/plugins/provider_test.go +++ b/apps/backend/internal/plugins/provider_test.go @@ -21,6 +21,13 @@ func newTestPool(t *testing.T) *db.Pool { t.Fatalf("open sqlite: %v", err) } conn.SetMaxOpenConns(1) + if _, err := conn.Exec(` + CREATE TABLE conversation_session_streams ( + session_id TEXT PRIMARY KEY, + watermark INTEGER NOT NULL + )`); err != nil { + t.Fatalf("create conversation journal schema: %v", err) + } t.Cleanup(func() { _ = conn.Close() }) return db.NewPool(conn, conn) } @@ -54,6 +61,27 @@ func TestProvideConstructsServiceUsingHomeDirPluginsSubdir(t *testing.T) { } } +func TestSetPluginsDirKeepsInstallRootWhenConversationStateFails(t *testing.T) { + dir := t.TempDir() + hostDir := filepath.Join(dir, ".host") + if err := os.MkdirAll(hostDir, 0o700); err != nil { + t.Fatalf("create host directory: %v", err) + } + if err := os.WriteFile(filepath.Join(hostDir, "conversation-token.key"), []byte("corrupt"), 0o600); err != nil { + t.Fatalf("write corrupt signing key: %v", err) + } + + svc := NewService(store.NewFSStore(filepath.Join(dir, "store")), NewRegistry(), nil, testLogger(t)) + t.Cleanup(func() { _ = svc.Close() }) + + if err := svc.SetPluginsDir(dir); err == nil { + t.Fatal("SetPluginsDir() expected corrupt signing key error") + } + if svc.pluginsDir != dir { + t.Fatalf("pluginsDir = %q, want %q after initialization failure", svc.pluginsDir, dir) + } +} + func TestProvideLoadsExistingInstallationsFromDisk(t *testing.T) { homeDir := t.TempDir() cfg := &config.Config{HomeDir: homeDir} diff --git a/apps/backend/internal/plugins/registry.go b/apps/backend/internal/plugins/registry.go index a5ece833a5..5e4a9de758 100644 --- a/apps/backend/internal/plugins/registry.go +++ b/apps/backend/internal/plugins/registry.go @@ -10,12 +10,6 @@ import ( "github.com/kandev/kandev/internal/plugins/store" ) -// Registry is an in-memory, mutex-guarded index of installed plugins, keyed -// by plugin id. It is loaded from the filesystem store at startup (Load) -// and kept in sync as Service mutates installations, so read paths (List, -// Get) never hit disk. -// -// Get and List return copies of the stored *store.Record so callers cannot // mutate registry state by holding onto a returned pointer; all writes go // through Add / Remove / SetStatus / SetRuntimeState / SetAutoUpdate / // SetRestartCount. @@ -156,6 +150,7 @@ func (r *Registry) SetRuntimeState(id string, status Status, lastError string, l func cloneRecord(rec *store.Record) *store.Record { clone := *rec + clone.Manifest = rec.Clone() if rec.AutoUpdate != nil { autoUpdate := *rec.AutoUpdate clone.AutoUpdate = &autoUpdate diff --git a/apps/backend/internal/plugins/registry_test.go b/apps/backend/internal/plugins/registry_test.go index 980106c912..1522fa881c 100644 --- a/apps/backend/internal/plugins/registry_test.go +++ b/apps/backend/internal/plugins/registry_test.go @@ -106,8 +106,13 @@ func TestRegistrySetStatusMissingReturnsNotOK(t *testing.T) { func TestRegistryGetReturnsIndependentCopy(t *testing.T) { reg := NewRegistry() autoUpdate := true + mf := testManifest("kandev-plugin-slack") + mf.Categories = []string{"chat"} + mf.RepositoryProviders = []string{"github"} + mf.ConfigSchema = map[string]any{"nested": map[string]any{"deep": "value"}} + mf.AgentTools = []manifest.AgentTool{{Name: "tool", Surfaces: []string{"kanban-task"}}} reg.Add(&store.Record{ - Manifest: *testManifest("kandev-plugin-slack"), + Manifest: *mf, Status: store.StatusRegistered, AutoUpdate: &autoUpdate, }) @@ -118,6 +123,11 @@ func TestRegistryGetReturnsIndependentCopy(t *testing.T) { } rec.Status = store.StatusActive // mutate the returned copy *rec.AutoUpdate = false // mutate the returned pointer copy + rec.Categories[0] = "mutated" // mutate a returned nested slice element + rec.RepositoryProviders = append(rec.RepositoryProviders, "gitlab") + rec.ConfigSchema["nested"].(map[string]any)["deep"] = "mutated" + rec.AgentTools[0].Surfaces[0] = "office-task" + rec.Runtime.Executables["linux-amd64"] = "server/mutated" fresh, ok := reg.Get("kandev-plugin-slack") if !ok { @@ -129,6 +139,18 @@ func TestRegistryGetReturnsIndependentCopy(t *testing.T) { if fresh.AutoUpdate == nil || !*fresh.AutoUpdate { t.Fatalf("mutating a Get() result leaked into the registry: AutoUpdate = %v, want true", fresh.AutoUpdate) } + if fresh.Categories[0] != "chat" || len(fresh.RepositoryProviders) != 1 || fresh.RepositoryProviders[0] != "github" { + t.Fatalf("mutating a Get() result leaked into the registry: Categories=%v RepositoryProviders=%v", fresh.Categories, fresh.RepositoryProviders) + } + if fresh.ConfigSchema["nested"].(map[string]any)["deep"] != "value" { + t.Fatalf("mutating a Get() result leaked into the registry: ConfigSchema=%v", fresh.ConfigSchema) + } + if fresh.AgentTools[0].Surfaces[0] != "kanban-task" { + t.Fatalf("mutating a Get() result leaked into the registry: AgentTools=%v", fresh.AgentTools) + } + if fresh.Runtime.Executables["linux-amd64"] != "server/plugin-linux-amd64" { + t.Fatalf("mutating a Get() result leaked into the registry: Executables=%v", fresh.Runtime.Executables) + } } func TestRegistrySetRestartCountUpdatesRecord(t *testing.T) { diff --git a/apps/backend/internal/plugins/service.go b/apps/backend/internal/plugins/service.go index 3d9dabf017..abdeeea8fa 100644 --- a/apps/backend/internal/plugins/service.go +++ b/apps/backend/internal/plugins/service.go @@ -3,10 +3,14 @@ package plugins import ( "context" "errors" + "fmt" "net/http" + "path/filepath" "strings" "sync" + "time" + "github.com/jmoiron/sqlx" "go.uber.org/zap" "github.com/google/uuid" @@ -89,20 +93,25 @@ type Service struct { // directory belongs to an install still waiting for it. extractingPaths map[string]int - pluginsDir string - store store.Store - registry *Registry - state *state.Store - userState *state.UserStore - instances *instances.Store - instanceState *state.InstanceStore - webArtifacts *webapp.ArtifactStore - webRuntime *webapp.Runtime - eventHub *webapp.EventHub - eventSubscription bus.Subscription - userStateCleanup userStateCleanupStore - eventBus bus.EventBus - log *logger.Logger + pluginsDir string + store store.Store + registry *Registry + state *state.Store + userState *state.UserStore + instances *instances.Store + instanceState *state.InstanceStore + webArtifacts *webapp.ArtifactStore + webRuntime *webapp.Runtime + eventHub *webapp.EventHub + eventSubscription bus.Subscription + userStateCleanup userStateCleanupStore + eventBus bus.EventBus + conversationTokens *conversationTokenManager + sessionEvents *SessionEventLog + sessionDelivery *SessionDeliveryDispatcher + conversationJournal *sqlx.DB + sessionEventSink func(SessionEvent) + log *logger.Logger deliverer Deliverer agentToolCatalogListener AgentToolCatalogListener @@ -183,6 +192,8 @@ type Service struct { reservedReferenceProviderKinds map[string]struct{} } +const sessionEventMaintenanceInterval = time.Minute + // ReferenceIdentity reserves a host-owned composer source and its canonical // provider/kind pair so a plugin cannot shadow a built-in integration. type ReferenceIdentity struct { @@ -195,7 +206,8 @@ type ReferenceIdentity struct { // Provide is the usual entry point in production; NewService is exposed // directly for tests that want a fake store.Store/PluginRuntime. func NewService(pluginStore store.Store, registry *Registry, eventBus bus.EventBus, log *logger.Logger) *Service { - return &Service{ + sessionEvents := mustSessionEventLog() + service := &Service{ store: pluginStore, registry: registry, eventBus: eventBus, @@ -205,7 +217,11 @@ func NewService(pluginStore store.Store, registry *Registry, eventBus bus.EventB dispatchLocks: newKeyedRWMutex(), agentToolGeneration: uuid.NewString(), eventHub: webapp.NewEventHub(), + conversationTokens: newConversationTokenManager(), + sessionEvents: sessionEvents, } + service.sessionDelivery = NewSessionDeliveryDispatcher(sessionEvents) + return service } // SetGitCredentialLeaseRevoker wires immediate provider-lease revocation for @@ -667,10 +683,113 @@ func (s *Service) Shutdown() { } } +// Close releases durable plugin conversation state after workers stop. +func (s *Service) Close() error { + s.Shutdown() + if s.sessionEvents == nil { + return nil + } + return s.sessionEvents.Close() +} + // SetPluginsDir wires the root directory pkgtar.Install/pkgtar.Remove -// operate under (the same directory store.FSStore persists records in). -func (s *Service) SetPluginsDir(dir string) { +// operate under and initializes mandatory durable conversation state. +func (s *Service) SetPluginsDir(dir string) error { + // Keep package installation rooted correctly even when durable conversation + // state initialization fails and the caller continues in degraded mode. s.pluginsDir = dir + hostDir := filepath.Join(dir, ".host") + conversationTokens, err := loadOrCreateConversationTokenManager( + filepath.Join(hostDir, "conversation-token.key"), + ) + if err != nil { + return err + } + sessionEvents, err := NewSessionEventLog(filepath.Join(hostDir, "session-events.sqlite")) + if err != nil { + return err + } + sessionDelivery := NewSessionDeliveryDispatcher(sessionEvents) + now := time.Now().UTC() + if err := sessionDelivery.ReclaimExpiredLeases(now); err != nil { + _ = sessionEvents.Close() + return fmt.Errorf("reclaim plugin session event leases: %w", err) + } + if err := sessionEvents.CollectExpired(now); err != nil { + _ = sessionEvents.Close() + return fmt.Errorf("collect expired plugin session events: %w", err) + } + previousSessionEvents := s.sessionEvents + s.pluginsDir = dir + s.conversationTokens = conversationTokens + s.sessionEvents = sessionEvents + s.sessionDelivery = sessionDelivery + if previousSessionEvents != nil { + _ = previousSessionEvents.Close() + } + return nil +} + +func (s *Service) maintainSessionEvents(ctx context.Context, now time.Time) error { + // A failing partition must not freeze the rest of maintenance: mirror + // errors are isolated per session (syncAll collects them), and the + // remaining passes still run so healthy sessions are collected and the + // primary journal is pruned every tick. + events, syncErr := s.syncAllCommittedSessionEvents(ctx) + s.mu.Lock() + sink := s.sessionEventSink + s.mu.Unlock() + if sink != nil { + for _, event := range events { + sink(event) + } + } + if err := s.sessionDelivery.ReclaimExpiredLeases(now); err != nil { + return fmt.Errorf("reclaim plugin session event leases: %w", err) + } + // Poison attempts are counted only when delivery is actually observed + // (a hub fanout or replay attempt), never by the maintenance ticker. + // A poison no subscriber ever attempted to consume stays pending until + // retention reaps it instead of being exhaustively cycled blind. + if err := s.sessionEvents.CollectExpired(now); err != nil { + return fmt.Errorf("collect expired plugin session events: %w", err) + } + retained := s.sessionEvents.RetainedSessionIDs(now) + if err := s.pruneConversationJournal( + ctx, now.UTC().Add(-SessionEventRetention), retained, + ); err != nil { + return fmt.Errorf("prune primary conversation journal: %w", err) + } + if syncErr != nil { + return fmt.Errorf("synchronize committed conversation journal: %w", syncErr) + } + return nil +} + +// StartSessionEventMaintenanceWorker keeps cursor retention and expired poison +// leases bounded for the lifetime of the backend, not only during startup. +func (s *Service) StartSessionEventMaintenanceWorker(ctx context.Context) func() { + workerContext, cancel := context.WithCancel(ctx) + done := make(chan struct{}) + go func() { + defer close(done) + ticker := time.NewTicker(sessionEventMaintenanceInterval) + defer ticker.Stop() + for { + select { + case <-workerContext.Done(): + return + case now := <-ticker.C: + if err := s.maintainSessionEvents(workerContext, now.UTC()); err != nil && s.log != nil { + s.log.Error("plugins: maintain session event stream", zap.Error(err)) + } + } + } + }() + return func() { + cancel() + <-done + } } // RevealSecret resolves the cleartext value of the secret reference ref via diff --git a/apps/backend/internal/plugins/service_install.go b/apps/backend/internal/plugins/service_install.go index f441e71a3a..4be006d95b 100644 --- a/apps/backend/internal/plugins/service_install.go +++ b/apps/backend/internal/plugins/service_install.go @@ -206,7 +206,10 @@ func previousVersion(oldRec *store.Record, hadOldRec bool) string { // (cmd/kandev's `Version` default, mirrored by internal/system/updates' // devVersion). It sorts meaninglessly against real semver, and a developer // running from source must still be able to install a package that declares -// a min_kandev_version, so it disables the check entirely. +// a min_kandev_version, so it disables the release-boundary comparison only. +// Manifest-level capability floors (e.g. api_read:messages >= 0.91.1) are +// validated independently of the running version and remain enforced on dev +// and unwired builds. const DevKandevVersion = "dev" // checkMinKandevVersion rejects a package whose manifest declares a @@ -214,6 +217,8 @@ const DevKandevVersion = "dev" // tags may carry a leading `v`; development and git-describe build strings do // not provide a trustworthy release boundary, so they deliberately skip this // release-only compatibility gate. An invalid manifest minimum is rejected. +// The capability floor above is enforced earlier, in Manifest validation, +// regardless of what running version (if any) is wired here. func (s *Service) checkMinKandevVersion(minVersion string) error { return manifest.CheckMinimumKandevVersion(minVersion, s.kandevVersion) } diff --git a/apps/backend/internal/plugins/service_install_test.go b/apps/backend/internal/plugins/service_install_test.go index f832156124..a5438ed3fd 100644 --- a/apps/backend/internal/plugins/service_install_test.go +++ b/apps/backend/internal/plugins/service_install_test.go @@ -150,6 +150,7 @@ func TestServiceInstallUpgradeSaveFailurePreservesOldVersionAndData(t *testing.T svc.SetPluginsDir(dir) rt := newFakeRuntime() svc.SetRuntime(rt) + t.Cleanup(func() { _ = svc.Close() }) rec1 := installTestPlugin(t, svc, "kandev-plugin-slack") // v1.0.0, active + running if !rt.Running("kandev-plugin-slack") { @@ -371,3 +372,76 @@ func TestServiceInstallFromURL_RejectsNonHTTPSchemeBeforeAnyRequest(t *testing.T t.Fatal("InstallFromURL() expected error for file:// scheme, got nil") } } + +// messagesMinKandevVersionPackage is like minKandevVersionPackage but +// declares capabilities.api_read: messages, which subjects the manifest to +// the capability minimum floor (MinimumMessagesCapabilityVersion). +func messagesMinKandevVersionPackage(t *testing.T, id, version, minKandevVersion string) *bytes.Buffer { + t.Helper() + platformKey := goruntime.GOOS + "-" + goruntime.GOARCH + manifestYAML := fmt.Sprintf(` +id: %s +api_version: 1 +version: %s +display_name: Test Plugin +min_kandev_version: %q +capabilities: + api_read: + - messages +runtime: + type: binary + executables: + %s: server/plugin +`, id, version, minKandevVersion, platformKey) + + var buf bytes.Buffer + files := map[string][]byte{ + "manifest.yaml": []byte(manifestYAML), + "server/plugin": []byte("#!/bin/sh\necho fake\n"), + } + if err := pkgtartest.WritePackage(&buf, files); err != nil { + t.Fatalf("WritePackage: %v", err) + } + return &buf +} + +// TestServiceInstall_EnforcesMessagesCapabilityFloorOnDevAndUnwiredBuilds +// pins the design contract that the api_read:messages minimum (0.91.1) is a +// manifest-level floor enforced in every build - including unstamped dev +// builds where the newer-than-running release gate is intentionally a no-op. +// The dev escape hatch applies only to the release-boundary comparison, never +// to the capability floor. +func TestServiceInstall_EnforcesMessagesCapabilityFloorOnDevAndUnwiredBuilds(t *testing.T) { + for _, tc := range []struct { + name string + want string + }{ + {name: "below floor is rejected", want: "0.9.0"}, + {name: "malformed minimum is rejected", want: "not-a-release"}, + } { + t.Run("dev/"+tc.name, func(t *testing.T) { + svc, _, _ := newTestService(t) + svc.SetKandevVersion(DevKandevVersion) + if _, err := svc.Install(context.Background(), messagesMinKandevVersionPackage(t, "kandev-plugin-messages", "1.0.0", tc.want)); err == nil { + t.Fatalf("Install() on a dev build accepted a messages manifest with min %q", tc.want) + } + }) + t.Run("unwired/"+tc.name, func(t *testing.T) { + svc, _, _ := newTestService(t) + if _, err := svc.Install(context.Background(), messagesMinKandevVersionPackage(t, "kandev-plugin-messages", "1.0.0", tc.want)); err == nil { + t.Fatalf("Install() with no running version accepted a messages manifest with min %q", tc.want) + } + }) + } + t.Run("dev/floor allowed", func(t *testing.T) { + svc, _, _ := newTestService(t) + svc.SetKandevVersion(DevKandevVersion) + rec, err := svc.Install(context.Background(), messagesMinKandevVersionPackage(t, "kandev-plugin-messages", "1.0.0", "0.91.1")) + if err != nil { + t.Fatalf("Install() on a dev build rejected the floor minimum: %v", err) + } + if rec.Status != StatusActive { + t.Fatalf("Install() Status = %q, want %q", rec.Status, StatusActive) + } + }) +} diff --git a/apps/backend/internal/plugins/service_prune_test.go b/apps/backend/internal/plugins/service_prune_test.go index d03dd8a4fd..8bb1aead7a 100644 --- a/apps/backend/internal/plugins/service_prune_test.go +++ b/apps/backend/internal/plugins/service_prune_test.go @@ -248,6 +248,7 @@ func TestServiceInstallSaveFailurePrunesNothingAndRestartsPrevious(t *testing.T) svc.SetPluginsDir(dir) rt := newFakeRuntime() svc.SetRuntime(rt) + t.Cleanup(func() { _ = svc.Close() }) installTestPlugin(t, svc, prunePluginID) // 1.0.0, active + running seedVersionDir(t, dir, prunePluginID, "0.9.0") @@ -320,6 +321,7 @@ func TestServiceInstallWithoutRuntimePrunesNothing(t *testing.T) { dir := t.TempDir() svc := NewService(store.NewFSStore(dir), NewRegistry(), nil, testLogger(t)) svc.SetPluginsDir(dir) + t.Cleanup(func() { _ = svc.Close() }) for _, v := range []string{"1.0.0", "1.1.0", "1.2.0"} { if _, err := svc.Install(context.Background(), testPackage(t, prunePluginID, v, false)); err != nil { @@ -367,6 +369,7 @@ func TestConcurrentInstallsKeepBothExtractions(t *testing.T) { svc := NewService(barrier, NewRegistry(), nil, testLogger(t)) svc.SetPluginsDir(dir) svc.SetRuntime(newFakeRuntime()) + t.Cleanup(func() { _ = svc.Close() }) // 1.0.0 must be a real installed record, so the first racing install // treats it as its rollback target and 1.2.0 becomes a prune candidate. diff --git a/apps/backend/internal/plugins/service_test.go b/apps/backend/internal/plugins/service_test.go index 7a334df564..bb14b9e8dc 100644 --- a/apps/backend/internal/plugins/service_test.go +++ b/apps/backend/internal/plugins/service_test.go @@ -360,6 +360,7 @@ func TestServiceInstallSerializesAgentToolCollisionValidation(t *testing.T) { svc := NewService(barrier, NewRegistry(), nil, testLogger(t)) svc.SetPluginsDir(dir) svc.SetRuntime(newFakeRuntime()) + t.Cleanup(func() { _ = svc.Close() }) firstPackage := testAgentToolPackage(t, "plugin-a", "echo") secondPackage := testAgentToolPackage(t, "plugin_a", "echo") @@ -409,6 +410,7 @@ func newTestServiceWithDir(t *testing.T) (*Service, string, *store.FSStore, *fak svc.SetPluginsDir(dir) rt := newFakeRuntime() svc.SetRuntime(rt) + t.Cleanup(func() { _ = svc.Close() }) return svc, dir, fsStore, rt } @@ -1338,6 +1340,7 @@ func TestServiceStartActivePluginsSpawnsOnlyActiveManagedNotAlreadyRunning(t *te svc2.SetPluginsDir(dir) rt2 := newFakeRuntime() svc2.SetRuntime(rt2) + t.Cleanup(func() { _ = svc2.Close() }) svc2.StartActivePlugins(context.Background()) @@ -1359,6 +1362,7 @@ func TestServiceStartActivePluginsFailurePersistsDiagnosticAndRefreshesDeliverer rt2 := newFakeRuntime() rt2.setStartErr("kandev-plugin-slack", errors.New("boot handshake failed")) svc2.SetRuntime(rt2) + t.Cleanup(func() { _ = svc2.Close() }) deliverer := &fakeDeliverer{} svc2.SetDeliverer(deliverer) diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index f5c832c114..ff3692a672 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -64,7 +64,12 @@ type ListMessagesOptions struct { After string Sort string AuthorType string - Around string + // AuthorTypes narrows by any of the listed authors (IN); when non-empty + // it takes precedence over AuthorType. TaskID, when non-empty, narrows + // to rows for that task. + AuthorTypes []string + TaskID string + Around string } // SearchMessagesOptions defines options for searching a session's messages. diff --git a/apps/backend/internal/task/repository/sqlite/base_schema.go b/apps/backend/internal/task/repository/sqlite/base_schema.go index b6c001914b..0863e6d5b8 100644 --- a/apps/backend/internal/task/repository/sqlite/base_schema.go +++ b/apps/backend/internal/task/repository/sqlite/base_schema.go @@ -60,6 +60,7 @@ func (r *Repository) initSchemaContext(ctx context.Context) error { r.ensureWorkspaceIndexes, r.ensureMessageMetadataIndexes, r.ensurePromptOrderIndex, + r.initConversationJournalSchema, } // Every boundary is checked before and after its step. The task repository // passes the same context to startup SQL through migrationContext, so a diff --git a/apps/backend/internal/task/repository/sqlite/conversation_journal.go b/apps/backend/internal/task/repository/sqlite/conversation_journal.go new file mode 100644 index 0000000000..869837c592 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/conversation_journal.go @@ -0,0 +1,945 @@ +//nolint:revive // This file keeps the dialect-specific journal trigger lifecycle atomic. +package sqlite + +import ( + "database/sql" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/db/dialect" + "github.com/kandev/kandev/internal/sysprompt" +) + +const conversationJournalTables = ` +CREATE TABLE IF NOT EXISTS conversation_session_streams ( + session_id TEXT PRIMARY KEY, + watermark BIGINT NOT NULL DEFAULT 0, + terminal BOOLEAN NOT NULL DEFAULT FALSE, + updated_at TIMESTAMP NOT NULL +); +CREATE TABLE IF NOT EXISTS conversation_session_events ( + session_id TEXT NOT NULL, + sequence BIGINT NOT NULL, + event_id TEXT NOT NULL UNIQUE, + protocol_version INTEGER NOT NULL DEFAULT 1, + event_type TEXT NOT NULL, + task_id TEXT, + payload TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + PRIMARY KEY (session_id, sequence) +); +CREATE INDEX IF NOT EXISTS idx_conversation_events_created + ON conversation_session_events(created_at); +CREATE TABLE IF NOT EXISTS conversation_message_versions ( + session_id TEXT NOT NULL, + message_id TEXT NOT NULL, + row_sequence BIGINT NOT NULL, + task_id TEXT, + author_type TEXT, + created_at TIMESTAMP NOT NULL, + tombstone BOOLEAN NOT NULL DEFAULT FALSE, + payload TEXT NOT NULL, + PRIMARY KEY (session_id, message_id, row_sequence) +); +CREATE INDEX IF NOT EXISTS idx_conversation_message_snapshot + ON conversation_message_versions(session_id, row_sequence, created_at, message_id); +CREATE TABLE IF NOT EXISTS conversation_turn_versions ( + session_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + row_sequence BIGINT NOT NULL, + task_id TEXT, + started_at TIMESTAMP NOT NULL, + tombstone BOOLEAN NOT NULL DEFAULT FALSE, + payload TEXT NOT NULL, + PRIMARY KEY (session_id, turn_id, row_sequence) +); +CREATE INDEX IF NOT EXISTS idx_conversation_turn_snapshot + ON conversation_turn_versions(session_id, row_sequence, started_at, turn_id); +CREATE TABLE IF NOT EXISTS conversation_journal_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +` + +// journalSchemaVersion is the current conversation journal schema version. +// Boot runs the trigger (re)creation plus its one-time sanitize migration only +// while the stored version is below this constant, then records it, so the +// per-boot full-corpus payload rewrite never repeats. +const journalSchemaVersion = 4 + +// journalBackfillKey records that the one-time backfill of pre-trigger source +// rows completed, so boot does not re-scan the whole message/turn corpus. +const ( + // Repeated payload/column keys kept as constants for goconst. + jKeySessionID = "session_id" + jKeyTaskID = "task_id" +) + +const journalBackfillKey = "backfill.complete" + +func (r *Repository) initConversationJournalSchema() error { + if _, err := r.db.Exec(conversationJournalTables); err != nil { + return fmt.Errorf("create conversation journal tables: %w", err) + } + version, err := r.readConversationJournalMetaInt("schema.version") + if err != nil { + return err + } + if version < journalSchemaVersion { + var triggerErr error + if dialect.IsPostgres(r.db.DriverName()) { + triggerErr = r.initPostgresConversationJournalTriggers() + } else { + triggerErr = r.initSQLiteConversationJournalTriggers() + } + if triggerErr != nil { + return triggerErr + } + if err := r.writeConversationJournalMeta("schema.version", journalSchemaVersion); err != nil { + return err + } + } + backfilled, err := r.readConversationJournalMetaFlag(journalBackfillKey) + if err != nil { + return err + } + if backfilled { + return nil + } + if err := r.backfillConversationJournal(); err != nil { + return err + } + return r.writeConversationJournalMeta(journalBackfillKey, 1) +} + +func (r *Repository) readConversationJournalMetaInt(key string) (int, error) { + var value string + err := r.db.Get(&value, r.db.Rebind(`SELECT value FROM conversation_journal_meta WHERE key = ?`), key) + if err == sql.ErrNoRows { + return 0, nil + } + if err != nil { + return 0, fmt.Errorf("read conversation journal meta %s: %w", key, err) + } + parsed, err := strconv.Atoi(value) + if err != nil { + return 0, fmt.Errorf("parse conversation journal meta %s: %w", key, err) + } + return parsed, nil +} + +func (r *Repository) readConversationJournalMetaFlag(key string) (bool, error) { + value, err := r.readConversationJournalMetaInt(key) + if err != nil { + return false, err + } + return value > 0, nil +} + +func (r *Repository) writeConversationJournalMeta(key string, value int) error { + _, err := r.db.Exec(r.db.Rebind(` + INSERT INTO conversation_journal_meta(key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`), key, strconv.Itoa(value)) + if err != nil { + return fmt.Errorf("write conversation journal meta %s: %w", key, err) + } + return nil +} + +type conversationJournalMessageSeed struct { + ID string `db:"id"` + TaskSessionID string `db:"task_session_id"` + TaskID string `db:"task_id"` + TurnID string `db:"turn_id"` + AuthorType string `db:"author_type"` + AuthorID string `db:"author_id"` + Content string `db:"content"` + RequestsInput bool `db:"requests_input"` + MessageType string `db:"message_type"` + Metadata string `db:"metadata"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + PromptIndex int `db:"prompt_index"` +} + +type conversationJournalTurnSeed struct { + ID string `db:"id"` + TaskSessionID string `db:"task_session_id"` + TaskID string `db:"task_id"` + ExecutionProfileID string `db:"execution_profile_id"` + RouteGeneration int64 `db:"route_generation"` + Metadata string `db:"metadata"` + StartedAt time.Time `db:"started_at"` + CompletedAt *time.Time `db:"completed_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +const conversationJournalBackfillBatchSize = 100 + +//nolint:cyclop,dupl,gocognit,funlen // Turn and message backfills intentionally share bounded keyset control flow. +func (r *Repository) backfillConversationJournal() error { + var lastSessionID string + var lastStartedAt time.Time + var lastTurnID string + //nolint:dupl // Turn and message batches use the same transaction boundaries. + for { + tx, err := r.db.Beginx() + if err != nil { + return fmt.Errorf("begin conversation turn journal backfill: %w", err) + } + var turns []conversationJournalTurnSeed + query := ` + SELECT t.id, t.task_session_id, t.task_id, t.execution_profile_id, t.route_generation, + t.metadata, t.started_at, t.completed_at, t.created_at, t.updated_at + FROM task_session_turns t + WHERE NOT EXISTS ( + SELECT 1 FROM conversation_turn_versions v + WHERE v.session_id = t.task_session_id AND v.turn_id = t.id + )` + args := []any{} + if lastTurnID != "" { + query += ` + AND (t.task_session_id > ? OR + (t.task_session_id = ? AND + (t.started_at > ? OR (t.started_at = ? AND t.id > ?))))` + args = append(args, lastSessionID, lastSessionID, lastStartedAt, lastStartedAt, lastTurnID) + } + query += ` ORDER BY t.task_session_id, t.started_at, t.id LIMIT ?` + args = append(args, conversationJournalBackfillBatchSize) + if err := tx.Select(&turns, r.db.Rebind(query), args...); err != nil { + _ = tx.Rollback() + return fmt.Errorf("list conversation turn backfill: %w", err) + } + if len(turns) == 0 { + _ = tx.Rollback() + break + } + for _, turn := range turns { + if err := r.backfillConversationTurn(tx, turn); err != nil { + _ = tx.Rollback() + return err + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit conversation turn journal backfill: %w", err) + } + lastTurn := turns[len(turns)-1] + lastSessionID, lastStartedAt, lastTurnID = lastTurn.TaskSessionID, lastTurn.StartedAt, lastTurn.ID + } + + lastSessionID = "" + var lastCreatedAt time.Time + var lastMessageID string + for { + tx, err := r.db.Beginx() + if err != nil { + return fmt.Errorf("begin conversation message journal backfill: %w", err) + } + var messages []conversationJournalMessageSeed + query := ` + SELECT m.id, m.task_session_id, m.task_id, m.turn_id, m.author_type, m.author_id, + m.content, m.requests_input, m.type AS message_type, m.metadata, + m.created_at, m.updated_at, m.prompt_seq AS prompt_index + FROM task_session_messages m + WHERE NOT EXISTS ( + SELECT 1 FROM conversation_message_versions v + WHERE v.session_id = m.task_session_id AND v.message_id = m.id + )` + args := []any{} + if lastMessageID != "" { + query += ` + AND (m.task_session_id > ? OR + (m.task_session_id = ? AND + (m.created_at > ? OR (m.created_at = ? AND m.id > ?))))` + args = append(args, lastSessionID, lastSessionID, lastCreatedAt, lastCreatedAt, lastMessageID) + } + query += ` ORDER BY m.task_session_id, m.created_at, m.id LIMIT ?` + args = append(args, conversationJournalBackfillBatchSize) + if err := tx.Select(&messages, r.db.Rebind(query), args...); err != nil { + _ = tx.Rollback() + return fmt.Errorf("list conversation message backfill: %w", err) + } + if len(messages) == 0 { + _ = tx.Rollback() + break + } + for _, message := range messages { + if err := r.backfillConversationMessage(tx, message); err != nil { + _ = tx.Rollback() + return err + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit conversation message journal backfill: %w", err) + } + lastMessage := messages[len(messages)-1] + lastSessionID, lastCreatedAt, lastMessageID = lastMessage.TaskSessionID, lastMessage.CreatedAt, lastMessage.ID + } + return nil +} + +func (r *Repository) nextConversationJournalSequence(tx *sqlx.Tx, sessionID string) (uint64, error) { + var sequence uint64 + query := r.db.Rebind(`UPDATE conversation_session_streams + SET watermark = watermark + 1, updated_at = CURRENT_TIMESTAMP + WHERE session_id = ? RETURNING watermark`) + err := tx.Get(&sequence, query, sessionID) + if err == nil { + return sequence, nil + } + if err != sql.ErrNoRows { + return 0, fmt.Errorf("advance conversation journal sequence: %w", err) + } + query = r.db.Rebind(`INSERT INTO conversation_session_streams(session_id, watermark, terminal, updated_at) + VALUES (?, 1, FALSE, CURRENT_TIMESTAMP)`) + if _, err := tx.Exec(query, sessionID); err != nil { + return 0, fmt.Errorf("initialize conversation journal sequence: %w", err) + } + return 1, nil +} + +// sameTurnSourceImage reports whether the freshly read source row still +// matches the backfill seed for every payload-bound field. +// +//nolint:goconst // Backfill payload values mirror the public event contract. +func sameTurnSourceImage(current struct { + TaskID string `db:"task_id"` + ExecutionProfileID string `db:"execution_profile_id"` + RouteGeneration int64 `db:"route_generation"` + Metadata string `db:"metadata"` + StartedAt time.Time `db:"started_at"` + CompletedAt *time.Time `db:"completed_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +}, turn conversationJournalTurnSeed) bool { + completedAtEqual := (current.CompletedAt == nil) == (turn.CompletedAt == nil) + if current.CompletedAt != nil && turn.CompletedAt != nil && !current.CompletedAt.Equal(*turn.CompletedAt) { + return false + } + return completedAtEqual && + current.TaskID == turn.TaskID && + current.ExecutionProfileID == turn.ExecutionProfileID && + current.RouteGeneration == turn.RouteGeneration && + current.Metadata == turn.Metadata && + current.StartedAt.Equal(turn.StartedAt) && + current.CreatedAt.Equal(turn.CreatedAt) && + current.UpdatedAt.Equal(turn.UpdatedAt) +} + +func (r *Repository) backfillConversationTurn(tx *sqlx.Tx, turn conversationJournalTurnSeed) error { + // Optimistic re-check of the complete source image: if any payload-bound + // field changed since the backfill SELECT, its trigger already journaled + // the current image, and a stale pre-update image must not win the + // snapshot with a later sequence. Comparing completion state alone would + // let a same-state update (metadata, profile, timestamps) pass. + var current struct { + TaskID string `db:"task_id"` + ExecutionProfileID string `db:"execution_profile_id"` + RouteGeneration int64 `db:"route_generation"` + Metadata string `db:"metadata"` + StartedAt time.Time `db:"started_at"` + CompletedAt *time.Time `db:"completed_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + err := tx.Get(¤t, r.db.Rebind(` + SELECT task_id, execution_profile_id, route_generation, metadata, + started_at, completed_at, created_at, updated_at + FROM task_session_turns WHERE id = ?`), turn.ID) + if err == sql.ErrNoRows { + return nil // deleted concurrently; its history is gone + } + if err != nil { + return fmt.Errorf("re-check conversation turn backfill %s: %w", turn.ID, err) + } + if !sameTurnSourceImage(current, turn) { + return nil // source image changed concurrently; trigger journaled it + } + sequence, err := r.nextConversationJournalSequence(tx, turn.TaskSessionID) + if err != nil { + return err + } + eventType := "session.turn.started" + if turn.CompletedAt != nil { + eventType = "session.turn.completed" + } + payload, err := json.Marshal(map[string]any{ + "type": eventType, jKeySessionID: turn.TaskSessionID, jKeyTaskID: journalTaskID(turn.TaskID), + "id": turn.ID, "execution_profile_id": turn.ExecutionProfileID, "route_generation": turn.RouteGeneration, + "started_at": turn.StartedAt.UTC().Format(time.RFC3339Nano), "completed_at": journalTime(turn.CompletedAt), + "metadata_json": turn.Metadata, columnCreatedAt: turn.CreatedAt.UTC().Format(time.RFC3339Nano), + columnUpdatedAt: turn.UpdatedAt.UTC().Format(time.RFC3339Nano), + }) + if err != nil { + return fmt.Errorf("encode conversation turn backfill: %w", err) + } + query := r.db.Rebind(`INSERT INTO conversation_turn_versions( + session_id, turn_id, row_sequence, task_id, started_at, tombstone, payload + ) VALUES (?, ?, ?, ?, ?, FALSE, ?)`) + if _, err := tx.Exec(query, turn.TaskSessionID, turn.ID, sequence, journalTaskID(turn.TaskID), turn.StartedAt, payload); err != nil { + return fmt.Errorf("insert conversation turn backfill version: %w", err) + } + return r.insertConversationBackfillEvent(tx, turn.TaskSessionID, turn.TaskID, sequence, eventType, payload) +} + +//nolint:goconst // Backfill payload values mirror the public event contract. +func (r *Repository) backfillConversationMessage(tx *sqlx.Tx, message conversationJournalMessageSeed) error { + // Optimistic re-check of the complete source image: if any payload-bound + // field changed since the backfill SELECT, its trigger already journaled + // the current image, and a stale pre-update image must not win the + // snapshot with a later sequence. Comparing content alone would let a + // same-content update (metadata, task/turn/type/author, timestamps) pass. + var current struct { + TaskID string `db:"task_id"` + TurnID string `db:"turn_id"` + AuthorType string `db:"author_type"` + Content string `db:"content"` + Type string `db:"type"` + Metadata string `db:"metadata"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + err := tx.Get(¤t, r.db.Rebind(` + SELECT task_id, turn_id, author_type, content, type, metadata, created_at, updated_at + FROM task_session_messages WHERE id = ?`), message.ID) + if err == sql.ErrNoRows { + return nil // deleted concurrently; its tombstone was journaled + } + if err != nil { + return fmt.Errorf("re-check conversation message backfill %s: %w", message.ID, err) + } + if current.TaskID != message.TaskID || + current.TurnID != message.TurnID || + current.AuthorType != message.AuthorType || + current.Content != message.Content || + current.Type != message.MessageType || + current.Metadata != message.Metadata || + !current.CreatedAt.Equal(message.CreatedAt) || + !current.UpdatedAt.Equal(message.UpdatedAt) { + return nil // source image changed concurrently; trigger journaled it + } + sequence, err := r.nextConversationJournalSequence(tx, message.TaskSessionID) + if err != nil { + return err + } + var metadata map[string]any + _ = json.Unmarshal([]byte(message.Metadata), &metadata) + senderTaskID, _ := metadata["sender_task_id"].(string) + payload, err := json.Marshal(map[string]any{ + "type": "message.added", jKeySessionID: message.TaskSessionID, jKeyTaskID: journalTaskID(message.TaskID), + "message_id": message.ID, "turn_id": message.TurnID, "author_type": message.AuthorType, + "content": sysprompt.StripSystemContent(message.Content), + "message_type": message.MessageType, columnCreatedAt: message.CreatedAt.UTC().Format(time.RFC3339Nano), + columnUpdatedAt: message.UpdatedAt.UTC().Format(time.RFC3339Nano), "prompt_index": message.PromptIndex, + "sender_task_id": senderTaskID, + }) + if err != nil { + return fmt.Errorf("encode conversation message backfill: %w", err) + } + query := r.db.Rebind(`INSERT INTO conversation_message_versions( + session_id, message_id, row_sequence, task_id, author_type, created_at, tombstone, payload + ) VALUES (?, ?, ?, ?, ?, ?, FALSE, ?)`) + if _, err := tx.Exec(query, message.TaskSessionID, message.ID, sequence, journalTaskID(message.TaskID), message.AuthorType, message.CreatedAt, payload); err != nil { + return fmt.Errorf("insert conversation message backfill version: %w", err) + } + return r.insertConversationBackfillEvent(tx, message.TaskSessionID, message.TaskID, sequence, "message.added", payload) +} + +func (r *Repository) insertConversationBackfillEvent( + tx *sqlx.Tx, + sessionID string, + taskID string, + sequence uint64, + eventType string, + payload []byte, +) error { + query := r.db.Rebind(`INSERT INTO conversation_session_events( + session_id, sequence, event_id, protocol_version, event_type, task_id, payload, created_at + ) VALUES (?, ?, ?, 1, ?, ?, ?, CURRENT_TIMESTAMP)`) + if _, err := tx.Exec(query, sessionID, sequence, sessionID+":"+strconv.FormatUint(sequence, 10), eventType, journalTaskID(taskID), payload); err != nil { + return fmt.Errorf("insert conversation journal backfill event: %w", err) + } + return nil +} + +// sqliteStripSystemContentExpr returns a scalar subquery that removes every +// well-formed ... block from expr - including +// multi-line blocks, multiple blocks, and nested evasion - and trims the +// result, mirroring sysprompt.StripSystemContent. SQLite has no scalar regex +// replace, so a bounded recursive CTE reapplies first-block removal until no +// well-formed block remains. Depth 64 caps adversarial input. If another full +// block remains at that bound, the expression keeps only the prefix before it; +// dropping the unexamined suffix fails closed rather than persisting system +// content. The final row is always non-NULL for text input. +func sqliteStripSystemContentExpr(expr string) string { + // Go's helper also consumes the whitespace run after each closing tag + // (`\s*` in the pattern); ltrim the tail so junction spacing matches. + whitespace := "char(32) || char(9) || char(10) || char(13)" + return `(SELECT trim(x) FROM ( + WITH RECURSIVE strip(x, depth) AS ( + SELECT ` + expr + `, 0 + UNION ALL + SELECT substr(x, 1, instr(x, '') - 1) + || ltrim(substr(x, + instr(x, '') + length('') + + instr(substr(x, instr(x, '') + length('')), '') - 1 + + length('')), + ` + whitespace + `), depth + 1 + FROM strip + WHERE depth < 64 + AND instr(x, '') > 0 + AND instr(substr(x, instr(x, '') + length('')), '') > 0 + ) + SELECT CASE WHEN depth = 64 AND instr(x, '') > 0 AND instr(substr(x, instr(x, '') + length('')), '') > 0 THEN substr(x, 1, instr(x, '') - 1) ELSE x END AS x FROM strip ORDER BY depth DESC LIMIT 1 + ))` +} + +// sqliteStripSystemToken marks where a trigger or migration must insert the +// strip expression; the raw SQL literal keeps strftime %-formats untouched. +const sqliteStripSystemToken = "__SQLITE_STRIP_SYSTEM__" +const sqliteConversationMetadataToken = "__SQLITE_CONVERSATION_METADATA__" +const postgresConversationMetadataToken = "__POSTGRES_CONVERSATION_METADATA__" + +//nolint:goconst // These keys are an explicit privacy allowlist. +var conversationMessageMetadataKeys = []string{ + "action_details", "action_type", "action_visibility", "actions", "agent_disconnected", "attempt", "attachments", + "auth_methods", "auto_start", "base_branch", "context", "context_files", + "decision_id", "effective_model", "entity_references", "error_output", + "failure_code", "failure_details", "failure_kind", "fallback_model", + "has_hidden_prompts", "has_resume_token", "has_review_comments", "is_auth_error", + "kind", "max_attempts", "message", "missing_branch", "model_id", "new_branch", + "original_branch", "options", "pending_id", "plan_mode", "progress", "provider_name", + "question", "question_id", "question_index", "question_total", "recovery_actions", + "remediation", "remediation_url", "requested_model", "request_id", "response", "reset_at", + "retry_at", "retry_in_seconds", "retrying", "sender_session_id", + "sender_session_name", "sender_task_id", "sender_task_title", "stage", "status", + "script_type", "agent_name", "command", "exit_code", "is_resuming", "started_at", + "completed_at", "error", + "task_id", "text", "tool_call_id", "variant", "workflow_message", "workflow_step_color", + "workflow_step_id", "workflow_step_name", +} + +func sqliteConversationMetadataExpr(metadataExpr string) string { + arguments := make([]string, 0, len(conversationMessageMetadataKeys)*2) + for _, key := range conversationMessageMetadataKeys { + arguments = append(arguments, "'"+key+"'", sqliteConversationMetadataValueExpr(metadataExpr, key)) + } + return "CASE WHEN json_valid(" + metadataExpr + ") THEN json_object(" + strings.Join(arguments, ",") + ") ELSE json_object() END" +} + +func sqliteConversationMetadataValueExpr(metadataExpr, key string) string { + path := "'$." + key + "'" + value := "json_extract(" + metadataExpr + ", " + path + ")" + return "CASE json_type(" + metadataExpr + ", " + path + ") " + + "WHEN 'true' THEN json('true') " + + "WHEN 'false' THEN json('false') " + + "ELSE " + value + " END" +} + +func postgresConversationMetadataExpr(metadataExpr string) string { + source := "conversation_safe_jsonb(" + metadataExpr + ")" + const maxKeysPerJSONBObject = 40 // PostgreSQL limits function calls to 100 arguments. + objects := make([]string, 0, (len(conversationMessageMetadataKeys)+maxKeysPerJSONBObject-1)/maxKeysPerJSONBObject) + for start := 0; start < len(conversationMessageMetadataKeys); start += maxKeysPerJSONBObject { + end := min(start+maxKeysPerJSONBObject, len(conversationMessageMetadataKeys)) + arguments := make([]string, 0, (end-start)*2) + for _, key := range conversationMessageMetadataKeys[start:end] { + arguments = append(arguments, "'"+key+"'", source+" -> '"+key+"'") + } + objects = append(objects, "jsonb_build_object("+strings.Join(arguments, ",")+")") + } + return "jsonb_strip_nulls(" + strings.Join(objects, " || ") + ")" +} + +func journalTaskID(taskID string) any { + if taskID == "" { + return nil + } + return taskID +} +func journalTime(value *time.Time) any { + if value == nil { + return nil + } + return value.UTC().Format(time.RFC3339Nano) +} + +//nolint:funlen // Trigger definitions are kept together so schema initialization is atomic. +func (r *Repository) initSQLiteConversationJournalTriggers() error { + triggerSQL := strings.ReplaceAll(` +DROP TRIGGER IF EXISTS conversation_message_insert; +CREATE TRIGGER IF NOT EXISTS conversation_message_insert +AFTER INSERT ON task_session_messages +BEGIN + INSERT INTO conversation_session_streams(session_id, watermark, terminal, updated_at) + VALUES (NEW.task_session_id, 1, FALSE, CURRENT_TIMESTAMP) + ON CONFLICT(session_id) DO UPDATE SET watermark = watermark + 1, updated_at = CURRENT_TIMESTAMP; + INSERT INTO conversation_message_versions(session_id, message_id, row_sequence, task_id, author_type, created_at, tombstone, payload) + SELECT NEW.task_session_id, NEW.id, watermark, NULLIF(NEW.task_id, ''), NEW.author_type, NEW.created_at, FALSE, + json_object('type','message.added','session_id',NEW.task_session_id,'task_id',NULLIF(NEW.task_id,''), + 'message_id',NEW.id,'turn_id',NULLIF(NEW.turn_id,''),'author_type',NEW.author_type, + 'content',__SQLITE_STRIP_SYSTEM__,'message_type',NEW.type,'requests_input',NEW.requests_input, + 'created_at',__SQLITE_RFC3339_MILLIS__(NEW.created_at), + 'updated_at',__SQLITE_RFC3339_MILLIS__(COALESCE(NEW.updated_at,NEW.created_at)),'prompt_index',NEW.prompt_seq, + 'metadata',__SQLITE_CONVERSATION_METADATA__(NEW.metadata), + 'sender_task_id',CASE WHEN json_valid(NEW.metadata) AND typeof(json_extract(NEW.metadata,'$.sender_task_id')) = 'text' THEN json_extract(NEW.metadata,'$.sender_task_id') END) + FROM conversation_session_streams WHERE session_id = NEW.task_session_id; + INSERT INTO conversation_session_events(session_id, sequence, event_id, event_type, task_id, payload, created_at) + SELECT NEW.task_session_id, watermark, NEW.task_session_id || ':' || watermark, 'message.added', NULLIF(NEW.task_id,''), payload, CURRENT_TIMESTAMP + FROM conversation_session_streams JOIN conversation_message_versions + ON conversation_message_versions.session_id = conversation_session_streams.session_id + AND conversation_message_versions.message_id = NEW.id + AND conversation_message_versions.row_sequence = conversation_session_streams.watermark + WHERE conversation_session_streams.session_id = NEW.task_session_id; +END; + +DROP TRIGGER IF EXISTS conversation_message_update; +CREATE TRIGGER IF NOT EXISTS conversation_message_update +AFTER UPDATE ON task_session_messages +WHEN OLD.id IS NOT NEW.id + OR OLD.task_session_id IS NOT NEW.task_session_id + OR OLD.task_id IS NOT NEW.task_id + OR OLD.turn_id IS NOT NEW.turn_id + OR OLD.author_type IS NOT NEW.author_type + OR OLD.author_id IS NOT NEW.author_id + OR OLD.content IS NOT NEW.content + OR OLD.requests_input IS NOT NEW.requests_input + OR OLD.type IS NOT NEW.type + OR OLD.metadata IS NOT NEW.metadata + OR OLD.created_at IS NOT NEW.created_at + OR OLD.updated_at IS NOT NEW.updated_at + OR OLD.prompt_seq IS NOT NEW.prompt_seq +BEGIN + INSERT INTO conversation_session_streams(session_id, watermark, terminal, updated_at) + VALUES (NEW.task_session_id, 1, FALSE, CURRENT_TIMESTAMP) + ON CONFLICT(session_id) DO UPDATE SET watermark = watermark + 1, updated_at = CURRENT_TIMESTAMP; + INSERT INTO conversation_message_versions(session_id, message_id, row_sequence, task_id, author_type, created_at, tombstone, payload) + SELECT NEW.task_session_id, NEW.id, watermark, NULLIF(NEW.task_id, ''), NEW.author_type, NEW.created_at, FALSE, + json_object('type','message.updated','session_id',NEW.task_session_id,'task_id',NULLIF(NEW.task_id,''), + 'message_id',NEW.id,'turn_id',NULLIF(NEW.turn_id,''),'author_type',NEW.author_type, + 'content',__SQLITE_STRIP_SYSTEM__,'message_type',NEW.type,'requests_input',NEW.requests_input, + 'created_at',__SQLITE_RFC3339_MILLIS__(NEW.created_at), + 'updated_at',__SQLITE_RFC3339_MILLIS__(COALESCE(NEW.updated_at,NEW.created_at)),'prompt_index',NEW.prompt_seq, + 'metadata',__SQLITE_CONVERSATION_METADATA__(NEW.metadata), + 'sender_task_id',CASE WHEN json_valid(NEW.metadata) AND typeof(json_extract(NEW.metadata,'$.sender_task_id')) = 'text' THEN json_extract(NEW.metadata,'$.sender_task_id') END) + FROM conversation_session_streams WHERE session_id = NEW.task_session_id; + INSERT INTO conversation_session_events(session_id, sequence, event_id, event_type, task_id, payload, created_at) + SELECT NEW.task_session_id, watermark, NEW.task_session_id || ':' || watermark, 'message.updated', NULLIF(NEW.task_id,''), payload, CURRENT_TIMESTAMP + FROM conversation_session_streams JOIN conversation_message_versions + ON conversation_message_versions.session_id = conversation_session_streams.session_id + AND conversation_message_versions.message_id = NEW.id + AND conversation_message_versions.row_sequence = conversation_session_streams.watermark + WHERE conversation_session_streams.session_id = NEW.task_session_id; +END; +DROP TRIGGER IF EXISTS conversation_message_delete; + +CREATE TRIGGER IF NOT EXISTS conversation_message_delete +BEFORE DELETE ON task_session_messages +BEGIN + INSERT INTO conversation_session_streams(session_id, watermark, terminal, updated_at) + VALUES (OLD.task_session_id, 1, FALSE, CURRENT_TIMESTAMP) + ON CONFLICT(session_id) DO UPDATE SET watermark = watermark + 1, updated_at = CURRENT_TIMESTAMP; + INSERT INTO conversation_message_versions(session_id, message_id, row_sequence, task_id, author_type, created_at, tombstone, payload) + SELECT OLD.task_session_id, OLD.id, watermark, NULLIF(OLD.task_id, ''), OLD.author_type, OLD.created_at, TRUE, + json_object('type','message.deleted','session_id',OLD.task_session_id,'task_id',NULLIF(OLD.task_id,''),'message_id',OLD.id) + FROM conversation_session_streams WHERE session_id = OLD.task_session_id; + INSERT INTO conversation_session_events(session_id, sequence, event_id, event_type, task_id, payload, created_at) + SELECT OLD.task_session_id, watermark, OLD.task_session_id || ':' || watermark, 'message.deleted', NULLIF(OLD.task_id,''), payload, CURRENT_TIMESTAMP + FROM conversation_session_streams JOIN conversation_message_versions + ON conversation_message_versions.session_id = conversation_session_streams.session_id + AND conversation_message_versions.message_id = OLD.id + AND conversation_message_versions.row_sequence = conversation_session_streams.watermark + WHERE conversation_session_streams.session_id = OLD.task_session_id; +END; +DROP TRIGGER IF EXISTS conversation_turn_insert; + +CREATE TRIGGER IF NOT EXISTS conversation_turn_insert +AFTER INSERT ON task_session_turns +BEGIN + INSERT INTO conversation_session_streams(session_id, watermark, terminal, updated_at) + VALUES (NEW.task_session_id, 1, FALSE, CURRENT_TIMESTAMP) + ON CONFLICT(session_id) DO UPDATE SET watermark = watermark + 1, updated_at = CURRENT_TIMESTAMP; + INSERT INTO conversation_turn_versions(session_id, turn_id, row_sequence, task_id, started_at, tombstone, payload) + SELECT NEW.task_session_id, NEW.id, watermark, NULLIF(NEW.task_id, ''), NEW.started_at, FALSE, + json_object('type','session.turn.started','session_id',NEW.task_session_id,'task_id',NULLIF(NEW.task_id,''), + 'id',NEW.id,'started_at',__SQLITE_RFC3339_MILLIS__(NEW.started_at), + 'completed_at',__SQLITE_RFC3339_MILLIS__(NEW.completed_at), + 'created_at',__SQLITE_RFC3339_MILLIS__(NEW.created_at), + 'updated_at',__SQLITE_RFC3339_MILLIS__(COALESCE(NEW.updated_at,NEW.started_at))) + FROM conversation_session_streams WHERE session_id = NEW.task_session_id; + INSERT INTO conversation_session_events(session_id, sequence, event_id, event_type, task_id, payload, created_at) + SELECT NEW.task_session_id, watermark, NEW.task_session_id || ':' || watermark, 'session.turn.started', NULLIF(NEW.task_id,''), payload, CURRENT_TIMESTAMP + FROM conversation_session_streams JOIN conversation_turn_versions + ON conversation_turn_versions.session_id = conversation_session_streams.session_id + AND conversation_turn_versions.turn_id = NEW.id + AND conversation_turn_versions.row_sequence = conversation_session_streams.watermark + WHERE conversation_session_streams.session_id = NEW.task_session_id; +END; +DROP TRIGGER IF EXISTS conversation_turn_complete; + +CREATE TRIGGER IF NOT EXISTS conversation_turn_complete +AFTER UPDATE OF completed_at ON task_session_turns +WHEN OLD.completed_at IS NULL AND NEW.completed_at IS NOT NULL +BEGIN + INSERT INTO conversation_session_streams(session_id, watermark, terminal, updated_at) + VALUES (NEW.task_session_id, 1, FALSE, CURRENT_TIMESTAMP) + ON CONFLICT(session_id) DO UPDATE SET watermark = watermark + 1, updated_at = CURRENT_TIMESTAMP; + INSERT INTO conversation_turn_versions(session_id, turn_id, row_sequence, task_id, started_at, tombstone, payload) + SELECT NEW.task_session_id, NEW.id, watermark, NULLIF(NEW.task_id, ''), NEW.started_at, FALSE, + json_object('type','session.turn.completed','session_id',NEW.task_session_id,'task_id',NULLIF(NEW.task_id,''), + 'id',NEW.id,'started_at',__SQLITE_RFC3339_MILLIS__(NEW.started_at), + 'completed_at',__SQLITE_RFC3339_MILLIS__(NEW.completed_at), + 'created_at',__SQLITE_RFC3339_MILLIS__(NEW.created_at), + 'updated_at',__SQLITE_RFC3339_MILLIS__(COALESCE(NEW.updated_at,NEW.completed_at,NEW.started_at)), + 'metadata',CASE WHEN json_valid(NEW.metadata) THEN json(NEW.metadata) ELSE json('{}') END, + 'had_output',json(CASE WHEN EXISTS ( + SELECT 1 FROM task_session_messages output + WHERE output.turn_id = NEW.id AND output.author_type = 'agent' + AND ( + output.type IN ('tool_call','tool_edit','tool_read','tool_search','tool_execute', + 'agent_plan','todo','permission_request','clarification_request') + OR (output.type IN ('message','content','') AND trim(output.content) <> '') + )) THEN 'true' ELSE 'false' END)) + FROM conversation_session_streams WHERE session_id = NEW.task_session_id; + INSERT INTO conversation_session_events(session_id, sequence, event_id, event_type, task_id, payload, created_at) + SELECT NEW.task_session_id, watermark, NEW.task_session_id || ':' || watermark, 'session.turn.completed', NULLIF(NEW.task_id,''), payload, CURRENT_TIMESTAMP + FROM conversation_session_streams JOIN conversation_turn_versions + ON conversation_turn_versions.session_id = conversation_session_streams.session_id + AND conversation_turn_versions.turn_id = NEW.id + AND conversation_turn_versions.row_sequence = conversation_session_streams.watermark + WHERE conversation_session_streams.session_id = NEW.task_session_id; +END; + +DROP TRIGGER IF EXISTS conversation_turn_delete; +CREATE TRIGGER IF NOT EXISTS conversation_turn_delete +AFTER DELETE ON task_session_turns +BEGIN + INSERT INTO conversation_session_streams(session_id, watermark, terminal, updated_at) + VALUES (OLD.task_session_id, 1, FALSE, CURRENT_TIMESTAMP) + ON CONFLICT(session_id) DO UPDATE SET watermark = watermark + 1, updated_at = CURRENT_TIMESTAMP; + INSERT INTO conversation_turn_versions(session_id, turn_id, row_sequence, task_id, started_at, tombstone, payload) + SELECT OLD.task_session_id, OLD.id, watermark, NULLIF(OLD.task_id, ''), OLD.started_at, TRUE, + json_object('type','session.turn.removed','session_id',OLD.task_session_id,'task_id',NULLIF(OLD.task_id,''),'id',OLD.id) + FROM conversation_session_streams WHERE session_id = OLD.task_session_id; + INSERT INTO conversation_session_events(session_id, sequence, event_id, event_type, task_id, payload, created_at) + SELECT OLD.task_session_id, watermark, OLD.task_session_id || ':' || watermark, 'session.turn.removed', NULLIF(OLD.task_id,''), + payload, CURRENT_TIMESTAMP + FROM conversation_session_streams JOIN conversation_turn_versions + ON conversation_turn_versions.session_id = conversation_session_streams.session_id + AND conversation_turn_versions.turn_id = OLD.id + AND conversation_turn_versions.row_sequence = conversation_session_streams.watermark + WHERE conversation_session_streams.session_id = OLD.task_session_id; +END; + +DROP TRIGGER IF EXISTS conversation_session_delete; +CREATE TRIGGER IF NOT EXISTS conversation_session_delete +AFTER DELETE ON task_sessions +BEGIN + INSERT INTO conversation_session_streams(session_id, watermark, terminal, updated_at) + VALUES (OLD.id, 1, TRUE, CURRENT_TIMESTAMP) + ON CONFLICT(session_id) DO UPDATE SET watermark = watermark + 1, terminal = TRUE, updated_at = CURRENT_TIMESTAMP; + INSERT INTO conversation_session_events(session_id, sequence, event_id, event_type, task_id, payload, created_at) + SELECT OLD.id, watermark, OLD.id || ':' || watermark, 'session.removed', NULLIF(OLD.task_id,''), + json_object('type','session.removed','session_id',OLD.id,'task_id',NULLIF(OLD.task_id,'')), CURRENT_TIMESTAMP + FROM conversation_session_streams WHERE session_id = OLD.id; +END; +`, sqliteStripSystemToken, sqliteStripSystemContentExpr("NEW.content")) + triggerSQL = strings.ReplaceAll(triggerSQL, + sqliteConversationMetadataToken+"(NEW.metadata)", sqliteConversationMetadataExpr("NEW.metadata")) + triggerSQL = strings.NewReplacer( + "__SQLITE_RFC3339_MILLIS__(NEW.created_at)", dialect.RFC3339Millis(dialect.SQLite3, "NEW.created_at"), + "__SQLITE_RFC3339_MILLIS__(COALESCE(NEW.updated_at,NEW.created_at))", dialect.RFC3339Millis(dialect.SQLite3, "COALESCE(NEW.updated_at,NEW.created_at)"), + "__SQLITE_RFC3339_MILLIS__(NEW.started_at)", dialect.RFC3339Millis(dialect.SQLite3, "NEW.started_at"), + "__SQLITE_RFC3339_MILLIS__(NEW.completed_at)", dialect.RFC3339Millis(dialect.SQLite3, "NEW.completed_at"), + "__SQLITE_RFC3339_MILLIS__(COALESCE(NEW.updated_at,NEW.started_at))", dialect.RFC3339Millis(dialect.SQLite3, "COALESCE(NEW.updated_at,NEW.started_at)"), + "__SQLITE_RFC3339_MILLIS__(COALESCE(NEW.updated_at,NEW.completed_at,NEW.started_at))", dialect.RFC3339Millis(dialect.SQLite3, "COALESCE(NEW.updated_at,NEW.completed_at,NEW.started_at)"), + ).Replace(triggerSQL) + if _, err := r.db.Exec(triggerSQL); err != nil { + return fmt.Errorf("create SQLite conversation journal triggers: %w", err) + } + messageMigrate := ` + UPDATE conversation_message_versions + SET payload = json_set(payload, '$.content', __SQLITE_STRIP_SYSTEM__) + WHERE json_valid(payload) + AND json_extract(payload, '$.content') LIKE '%%' + ` + contentExpr := sqliteStripSystemContentExpr("json_extract(payload, '$.content')") + if _, err := r.db.Exec(strings.ReplaceAll(messageMigrate, sqliteStripSystemToken, contentExpr)); err != nil { + return fmt.Errorf("sanitize SQLite conversation message journal: %w", err) + } + eventMigrate := ` + UPDATE conversation_session_events + SET payload = json_set(payload, '$.content', __SQLITE_STRIP_SYSTEM__) + WHERE json_valid(payload) + AND json_extract(payload, '$.content') LIKE '%%' + ` + if _, err := r.db.Exec(strings.ReplaceAll(eventMigrate, sqliteStripSystemToken, contentExpr)); err != nil { + return fmt.Errorf("sanitize SQLite conversation event journal: %w", err) + } + return nil +} + +//nolint:funlen // Trigger definitions are kept together so schema initialization is atomic. +func (r *Repository) initPostgresConversationJournalTriggers() error { + triggerSQL := strings.ReplaceAll(` +CREATE OR REPLACE FUNCTION conversation_next_sequence(p_session_id TEXT, p_terminal BOOLEAN DEFAULT FALSE) +RETURNS BIGINT AS $$ +DECLARE next_value BIGINT; +BEGIN + INSERT INTO conversation_session_streams(session_id, watermark, terminal, updated_at) + VALUES (p_session_id, 1, p_terminal, CURRENT_TIMESTAMP) + ON CONFLICT(session_id) DO UPDATE SET + watermark = conversation_session_streams.watermark + 1, + terminal = conversation_session_streams.terminal OR EXCLUDED.terminal, + updated_at = CURRENT_TIMESTAMP + RETURNING watermark INTO next_value; + RETURN next_value; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION conversation_safe_jsonb(value TEXT) RETURNS JSONB AS $$ +BEGIN + RETURN COALESCE(NULLIF(value, ''), '{}')::jsonb; +EXCEPTION WHEN others THEN + RETURN '{}'::jsonb; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION conversation_visible_content(value TEXT) RETURNS TEXT AS $$ +DECLARE + result TEXT := value; + open_pos INTEGER; + close_offset INTEGER; + close_pos INTEGER; +BEGIN + LOOP + open_pos := strpos(result, ''); + IF open_pos = 0 THEN + EXIT; + END IF; + close_offset := strpos( + substr(result, open_pos + length('')), + '' + ); + IF close_offset = 0 THEN + EXIT; + END IF; + close_pos := open_pos + length('') + close_offset - 1; + result := substr(result, 1, open_pos - 1) + || ltrim(substr(result, close_pos + length('')), E' \t\n\r'); + END LOOP; + RETURN btrim(result); +END; +$$ LANGUAGE plpgsql; +CREATE OR REPLACE FUNCTION conversation_message_journal() RETURNS TRIGGER AS $$ +DECLARE seq BIGINT; event_name TEXT; source_row task_session_messages%ROWTYPE; deleted BOOLEAN; +BEGIN + IF TG_OP = 'UPDATE' AND OLD IS NOT DISTINCT FROM NEW THEN RETURN NEW; END IF; + deleted := TG_OP = 'DELETE'; + IF deleted THEN source_row := OLD; event_name := 'message.deleted'; + ELSIF TG_OP = 'INSERT' THEN source_row := NEW; event_name := 'message.added'; + ELSE source_row := NEW; event_name := 'message.updated'; END IF; + seq := conversation_next_sequence(source_row.task_session_id); + INSERT INTO conversation_message_versions(session_id,message_id,row_sequence,task_id,author_type,created_at,tombstone,payload) + VALUES (source_row.task_session_id,source_row.id,seq,NULLIF(source_row.task_id,''),source_row.author_type,source_row.created_at,deleted, + CASE WHEN deleted THEN json_build_object('type',event_name,'session_id',source_row.task_session_id,'task_id',NULLIF(source_row.task_id,''),'message_id',source_row.id)::text + ELSE json_build_object('type',event_name,'session_id',source_row.task_session_id,'task_id',NULLIF(source_row.task_id,''), + 'message_id',source_row.id,'turn_id',NULLIF(source_row.turn_id,''),'author_type',source_row.author_type, + 'content',conversation_visible_content(source_row.content),'message_type',source_row.type, + 'requests_input',source_row.requests_input, + 'created_at',to_char(source_row.created_at,'YYYY-MM-DD"T"HH24:MI:SS.US"Z"'), + 'updated_at',to_char(COALESCE(source_row.updated_at,source_row.created_at),'YYYY-MM-DD"T"HH24:MI:SS.US"Z"'),'prompt_index',source_row.prompt_seq, + 'metadata',__POSTGRES_CONVERSATION_METADATA__(source_row.metadata), + 'sender_task_id',CASE WHEN jsonb_typeof(conversation_safe_jsonb(source_row.metadata) -> 'sender_task_id') = 'string' THEN conversation_safe_jsonb(source_row.metadata) ->> 'sender_task_id' END)::text END); + INSERT INTO conversation_session_events(session_id,sequence,event_id,event_type,task_id,payload,created_at) + SELECT source_row.task_session_id,seq,source_row.task_session_id || ':' || seq,event_name,NULLIF(source_row.task_id,''),payload,CURRENT_TIMESTAMP + FROM conversation_message_versions WHERE session_id=source_row.task_session_id AND message_id=source_row.id AND row_sequence=seq; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; +DROP TRIGGER IF EXISTS conversation_message_journal_trigger ON task_session_messages; +CREATE TRIGGER conversation_message_journal_trigger AFTER INSERT OR UPDATE OR DELETE ON task_session_messages +FOR EACH ROW EXECUTE FUNCTION conversation_message_journal(); + +CREATE OR REPLACE FUNCTION conversation_turn_journal() RETURNS TRIGGER AS $$ +DECLARE seq BIGINT; event_name TEXT; source_row task_session_turns%ROWTYPE; deleted BOOLEAN; +BEGIN + deleted := TG_OP = 'DELETE'; + IF deleted THEN source_row := OLD; event_name := 'session.turn.removed'; + ELSIF TG_OP = 'UPDATE' AND NOT (OLD.completed_at IS NULL AND NEW.completed_at IS NOT NULL) THEN RETURN NEW; + ELSIF TG_OP = 'INSERT' THEN source_row := NEW; event_name := 'session.turn.started'; + ELSE source_row := NEW; event_name := 'session.turn.completed'; END IF; + seq := conversation_next_sequence(source_row.task_session_id); + INSERT INTO conversation_turn_versions(session_id,turn_id,row_sequence,task_id,started_at,tombstone,payload) + VALUES (source_row.task_session_id,source_row.id,seq,NULLIF(source_row.task_id,''),source_row.started_at,deleted, + CASE WHEN deleted THEN + json_build_object('type',event_name,'session_id',source_row.task_session_id,'task_id',NULLIF(source_row.task_id,''),'id',source_row.id)::text + ELSE + json_build_object('type',event_name,'session_id',source_row.task_session_id,'task_id',NULLIF(source_row.task_id,''), + 'id',source_row.id,'started_at',to_char(source_row.started_at,'YYYY-MM-DD"T"HH24:MI:SS.US"Z"'), + 'completed_at',CASE WHEN source_row.completed_at IS NULL THEN NULL ELSE to_char(source_row.completed_at,'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') END, + 'created_at',to_char(source_row.created_at,'YYYY-MM-DD"T"HH24:MI:SS.US"Z"'), + 'updated_at',to_char(COALESCE(source_row.updated_at,source_row.completed_at,source_row.started_at),'YYYY-MM-DD"T"HH24:MI:SS.US"Z"'), + 'metadata',CASE WHEN source_row.completed_at IS NULL THEN NULL ELSE conversation_safe_jsonb(source_row.metadata) END, + 'had_output',CASE WHEN source_row.completed_at IS NULL THEN NULL ELSE EXISTS ( + SELECT 1 FROM task_session_messages output + WHERE output.turn_id = source_row.id AND output.author_type = 'agent' + AND ( + output.type IN ('tool_call','tool_edit','tool_read','tool_search','tool_execute', + 'agent_plan','todo','permission_request','clarification_request') + OR (output.type IN ('message','content','') AND btrim(output.content) <> '') + )) END)::text + END); + INSERT INTO conversation_session_events(session_id,sequence,event_id,event_type,task_id,payload,created_at) + SELECT source_row.task_session_id,seq,source_row.task_session_id || ':' || seq,event_name,NULLIF(source_row.task_id,''),payload,CURRENT_TIMESTAMP + FROM conversation_turn_versions + WHERE session_id=source_row.task_session_id AND turn_id=source_row.id AND row_sequence=seq; + IF deleted THEN RETURN OLD; END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +DROP TRIGGER IF EXISTS conversation_turn_journal_trigger ON task_session_turns; +CREATE TRIGGER conversation_turn_journal_trigger AFTER INSERT OR UPDATE OR DELETE ON task_session_turns +FOR EACH ROW EXECUTE FUNCTION conversation_turn_journal(); + +CREATE OR REPLACE FUNCTION conversation_session_delete_journal() RETURNS TRIGGER AS $$ +DECLARE seq BIGINT; +BEGIN + seq := conversation_next_sequence(OLD.id, TRUE); + INSERT INTO conversation_session_events(session_id,sequence,event_id,event_type,task_id,payload,created_at) + VALUES (OLD.id,seq,OLD.id || ':' || seq,'session.removed',NULLIF(OLD.task_id,''), + json_build_object('type','session.removed','session_id',OLD.id,'task_id',NULLIF(OLD.task_id,''))::text,CURRENT_TIMESTAMP); + RETURN OLD; +END; +$$ LANGUAGE plpgsql; +DROP TRIGGER IF EXISTS conversation_session_delete_trigger ON task_sessions; +CREATE TRIGGER conversation_session_delete_trigger AFTER DELETE ON task_sessions +FOR EACH ROW EXECUTE FUNCTION conversation_session_delete_journal(); + `, postgresConversationMetadataToken+"(source_row.metadata)", postgresConversationMetadataExpr("source_row.metadata")) + _, err := r.db.Exec(r.db.Rebind(triggerSQL)) + if err != nil { + return fmt.Errorf("create PostgreSQL conversation journal triggers: %w", err) + } + if _, err := r.db.Exec(` + UPDATE conversation_message_versions + SET payload = jsonb_set(payload::jsonb, '{content}', + to_jsonb(conversation_visible_content(payload::jsonb ->> 'content')))::text + WHERE conversation_safe_jsonb(payload) ->> 'content' LIKE '%%' + `); err != nil { + return fmt.Errorf("sanitize PostgreSQL conversation message journal: %w", err) + } + if _, err := r.db.Exec(` + UPDATE conversation_session_events + SET payload = jsonb_set(payload::jsonb, '{content}', + to_jsonb(conversation_visible_content(payload::jsonb ->> 'content')))::text + WHERE conversation_safe_jsonb(payload) ->> 'content' LIKE '%%' + `); err != nil { + return fmt.Errorf("sanitize PostgreSQL conversation event journal: %w", err) + } + return nil +} diff --git a/apps/backend/internal/task/repository/sqlite/conversation_journal_postgres_test.go b/apps/backend/internal/task/repository/sqlite/conversation_journal_postgres_test.go new file mode 100644 index 0000000000..16a1d9d396 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/conversation_journal_postgres_test.go @@ -0,0 +1,166 @@ +package sqlite + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/kandev/kandev/internal/sysprompt" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/testutil" +) + +// TestPostgresConversationJournalRoundTrip exercises the PostgreSQL journal +// triggers end to end: message insert/update sanitization through +// conversation_visible_content, the meta schema-version marker, and the +// version-guarded one-time migration. Skips unless KANDEV_TEST_POSTGRES_DSN is +// set (CI runs it in postgres-boot). +func TestPostgresConversationJournalRoundTrip(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("new repo: %v", err) + } + ctx := context.Background() + seedPostgresConversationJournal(t, repo, "task-pg-journal", "session-pg-journal", "turn-pg-journal") + + content := "pre hidden one\nline two mid two post" + message := &models.Message{ + ID: "message-pg-journal", TaskSessionID: "session-pg-journal", TaskID: "task-pg-journal", + TurnID: "turn-pg-journal", AuthorType: models.MessageAuthorUser, + Type: models.MessageTypeMessage, Content: content, + Metadata: map[string]interface{}{ + "pending_id": "pending-pg", "status": "pending", "raw_content": "secret", + }, + } + if err := repo.CreateMessage(ctx, message); err != nil { + t.Fatalf("create message: %v", err) + } + want := sysprompt.StripSystemContent(content) + + var eventPayload string + if err := repo.db.Get(&eventPayload, `SELECT payload FROM conversation_session_events WHERE session_id = 'session-pg-journal' AND event_type = 'message.added'`); err != nil { + t.Fatalf("read event payload: %v", err) + } + var payload map[string]any + if err := json.Unmarshal([]byte(eventPayload), &payload); err != nil { + t.Fatalf("decode event payload: %v", err) + } + metadata, ok := payload["metadata"].(map[string]interface{}) + if !ok || metadata["pending_id"] != "pending-pg" || metadata["status"] != "pending" { + t.Fatalf("postgres journal metadata = %#v, want allowlisted presentation metadata", payload["metadata"]) + } + if _, ok := metadata["raw_content"]; ok { + t.Fatalf("postgres journal retained internal raw_content metadata: %#v", metadata) + } + got, ok := payload["content"].(string) + if !ok || got != want || strings.Contains(got, "hidden") { + t.Fatalf("postgres journal content = %q, want %q", got, want) + } + + // The schema marker must be recorded so the one-time migration is not + // re-run (and the full-corpus rewrite never repeats on later boots). + version, err := repo.readConversationJournalMetaInt("schema.version") + if err != nil { + t.Fatalf("read schema version: %v", err) + } + if version < journalSchemaVersion { + t.Fatalf("schema version = %d, want >= %d", version, journalSchemaVersion) + } + backfilled, err := repo.readConversationJournalMetaFlag(journalBackfillKey) + if err != nil { + t.Fatalf("read backfill flag: %v", err) + } + if !backfilled { + t.Fatal("backfill flag not recorded after init") + } +} + +func TestPostgresConversationJournalStripsPastSQLiteDepthLimit(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("new repo: %v", err) + } + seedPostgresConversationJournal(t, repo, "task-pg-strip-deep", "session-pg-strip-deep", "turn-pg-strip-deep") + content := strings.Repeat("secret", 65) + "visible" + message := &models.Message{ + ID: "message-pg-strip-deep", TaskSessionID: "session-pg-strip-deep", TaskID: "task-pg-strip-deep", + TurnID: "turn-pg-strip-deep", AuthorType: models.MessageAuthorUser, + Type: models.MessageTypeMessage, Content: content, + } + if err := repo.CreateMessage(context.Background(), message); err != nil { + t.Fatalf("create message: %v", err) + } + + for _, table := range []string{"conversation_message_versions", "conversation_session_events"} { + var payload string + orderColumn := "row_sequence" + if table == "conversation_session_events" { + orderColumn = "sequence" + } + query := "SELECT payload FROM " + table + " WHERE session_id = $1 ORDER BY " + orderColumn + " DESC LIMIT 1" + if err := repo.db.Get(&payload, query, "session-pg-strip-deep"); err != nil { + t.Fatalf("read %s payload: %v", table, err) + } + if strings.Contains(payload, "secret") || strings.Contains(payload, "") { + t.Fatalf("%s persisted system content: %s", table, payload) + } + } +} + +func TestPostgresConversationJournalTurnDeletePreservesCutoffHistory(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("new repo: %v", err) + } + const ( + taskID = "task-pg-turn-delete" + sessionID = "session-pg-turn-delete" + turnID = "turn-pg-turn-delete" + ) + seedPostgresConversationJournal(t, repo, taskID, sessionID, turnID) + now := time.Now().UTC() + if _, err := repo.db.Exec(repo.db.Rebind(`UPDATE task_session_turns SET completed_at = ?, updated_at = ? WHERE id = ?`), now, now, turnID); err != nil { + t.Fatalf("complete turn: %v", err) + } + var cutoffBefore int64 + if err := repo.db.Get(&cutoffBefore, repo.db.Rebind(`SELECT watermark FROM conversation_session_streams WHERE session_id = ?`), sessionID); err != nil { + t.Fatalf("read pre-delete cutoff: %v", err) + } + if _, err := repo.db.Exec(repo.db.Rebind(`DELETE FROM task_session_turns WHERE id = ?`), turnID); err != nil { + t.Fatalf("delete turn: %v", err) + } + var cutoffAfter int64 + if err := repo.db.Get(&cutoffAfter, repo.db.Rebind(`SELECT watermark FROM conversation_session_streams WHERE session_id = ?`), sessionID); err != nil { + t.Fatalf("read deletion cutoff: %v", err) + } + assertTurnLiveAtCutoff(t, repo, sessionID, turnID, cutoffBefore, true) + assertTurnLiveAtCutoff(t, repo, sessionID, turnID, cutoffAfter, false) +} + +func seedPostgresConversationJournal(t *testing.T, repo *Repository, taskID, sessionID, turnID string) { + t.Helper() + now := time.Now().UTC() + if _, err := repo.db.Exec(repo.db.Rebind(` + INSERT INTO tasks (id, workspace_id, title, created_at, updated_at) + VALUES (?, '', 'test task', ?, ?) + `), taskID, now, now); err != nil { + t.Fatalf("seed task: %v", err) + } + if _, err := repo.db.Exec(repo.db.Rebind(` + INSERT INTO task_sessions (id, task_id, started_at, updated_at) + VALUES (?, ?, ?, ?) + `), sessionID, taskID, now, now); err != nil { + t.Fatalf("seed session: %v", err) + } + if _, err := repo.db.Exec(repo.db.Rebind(` + INSERT INTO task_session_turns (id, task_session_id, task_id, started_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + `), turnID, sessionID, taskID, now, now, now); err != nil { + t.Fatalf("seed turn: %v", err) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/conversation_journal_test.go b/apps/backend/internal/task/repository/sqlite/conversation_journal_test.go new file mode 100644 index 0000000000..5afb07ca5c --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/conversation_journal_test.go @@ -0,0 +1,652 @@ +package sqlite + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/kandev/kandev/internal/sysprompt" + "github.com/kandev/kandev/internal/task/models" +) + +func TestConversationJournalVersionsMutationsAndTerminalDeletion(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedForMsgTest(t, repo, "task-journal", "session-journal", "turn-journal") + message := &models.Message{ + ID: "message-journal", TaskSessionID: "session-journal", TaskID: "task-journal", + TurnID: "turn-journal", AuthorType: models.MessageAuthorAgent, + Type: models.MessageTypeMessage, Content: "hiddenfirst", + } + if err := repo.CreateMessage(ctx, message); err != nil { + t.Fatalf("create message: %v", err) + } + message.Content = "hidden-updateupdated" + if err := repo.UpdateMessage(ctx, message); err != nil { + t.Fatalf("update message: %v", err) + } + if err := repo.DeleteMessage(ctx, message.ID); err != nil { + t.Fatalf("delete message: %v", err) + } + session, err := repo.GetTaskSession(ctx, "session-journal") + if err != nil { + t.Fatalf("load session: %v", err) + } + if err := repo.DeleteTaskSession(ctx, session); err != nil { + t.Fatalf("delete session: %v", err) + } + + var terminal bool + var watermark int64 + if err := repo.db.QueryRow(`SELECT watermark, terminal FROM conversation_session_streams WHERE session_id = 'session-journal'`).Scan(&watermark, &terminal); err != nil { + t.Fatalf("read stream: %v", err) + } + if !terminal || watermark != 6 { + t.Fatalf("stream = watermark %d terminal %v, want 6 true", watermark, terminal) + } + rows, err := repo.db.Query(`SELECT event_type FROM conversation_session_events WHERE session_id = 'session-journal' ORDER BY sequence`) + if err != nil { + t.Fatalf("list events: %v", err) + } + defer func() { _ = rows.Close() }() + var eventTypes []string + for rows.Next() { + var eventType string + if err := rows.Scan(&eventType); err != nil { + t.Fatal(err) + } + eventTypes = append(eventTypes, eventType) + } + want := []string{"session.turn.started", "message.added", "message.updated", "message.deleted", "session.turn.removed", "session.removed"} + if len(eventTypes) != len(want) { + t.Fatalf("event types = %v, want %v", eventTypes, want) + } + for index := range want { + if eventTypes[index] != want[index] { + t.Fatalf("event types = %v, want %v", eventTypes, want) + } + } + var payloadBytes []byte + if err := repo.db.Get(&payloadBytes, `SELECT payload FROM conversation_session_events WHERE session_id = 'session-journal' AND event_type = 'message.added'`); err != nil { + t.Fatalf("read message event payload: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(payloadBytes, &payload); err != nil { + t.Fatalf("decode message event payload: %v", err) + } + if payload["type"] != "message.added" || payload["session_id"] != "session-journal" { + t.Fatalf("message event identity = %#v", payload) + } + if content, ok := payload["content"].(string); !ok || content != "first" || strings.Contains(content, "hidden") { + t.Fatalf("journal leaked system content: %#v", payload["content"]) + } + createdAt, ok := payload["created_at"].(string) + if !ok { + t.Fatalf("message created_at = %#v", payload["created_at"]) + } + if _, err := time.Parse(time.RFC3339Nano, createdAt); err != nil { + t.Fatalf("message created_at is not RFC3339: %q: %v", createdAt, err) + } + var tombstone bool + if err := repo.db.QueryRow(`SELECT tombstone FROM conversation_message_versions WHERE session_id = 'session-journal' AND message_id = 'message-journal' ORDER BY row_sequence DESC LIMIT 1`).Scan(&tombstone); err != nil { + t.Fatalf("read message tombstone: %v", err) + } + if !tombstone { + t.Fatal("latest message version is not a tombstone") + } +} + +func TestConversationJournalRollsBackWithSourceMutation(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedForMsgTest(t, repo, "task-journal-rollback", "session-journal-rollback", "turn-journal-rollback") + tx, err := repo.db.BeginTxx(ctx, nil) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + _, err = tx.ExecContext(ctx, repo.db.Rebind(` + INSERT INTO task_session_messages + (id, task_session_id, task_id, turn_id, author_type, content, type, metadata, created_at, updated_at) + VALUES (?, ?, ?, ?, 'agent', 'rolled back', 'message', '{}', ?, ?) + `), "message-rollback", "session-journal-rollback", "task-journal-rollback", "turn-journal-rollback", now, now) + if err != nil { + _ = tx.Rollback() + t.Fatalf("insert source row: %v", err) + } + if err := tx.Rollback(); err != nil { + t.Fatalf("rollback: %v", err) + } + + var count int + if err := repo.db.Get(&count, `SELECT COUNT(*) FROM conversation_session_events WHERE session_id = 'session-journal-rollback' AND event_type = 'message.added'`); err != nil { + t.Fatalf("count rolled-back event: %v", err) + } + if count != 0 { + t.Fatalf("rolled-back mutation left %d event rows", count) + } +} + +func TestConversationJournalStripsMultiLineAndMultiBlockSystemContent(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedForMsgTest(t, repo, "task-journal-strip", "session-journal-strip", "turn-journal-strip") + content := "pre hidden one\nline two mid\nhidden2 post" + message := &models.Message{ + ID: "message-strip", TaskSessionID: "session-journal-strip", TaskID: "task-journal-strip", + TurnID: "turn-journal-strip", AuthorType: models.MessageAuthorUser, + Type: models.MessageTypeMessage, Content: content, + } + if err := repo.CreateMessage(ctx, message); err != nil { + t.Fatalf("create message: %v", err) + } + want := sysprompt.StripSystemContent(content) + + var versionContent string + if err := repo.db.Get(&versionContent, `SELECT json_extract(payload, '$.content') FROM conversation_message_versions WHERE message_id = 'message-strip'`); err != nil { + t.Fatalf("read message version content: %v", err) + } + if versionContent != want { + t.Fatalf("version content = %q, want %q", versionContent, want) + } + var eventContent string + if err := repo.db.Get(&eventContent, `SELECT json_extract(payload, '$.content') FROM conversation_session_events WHERE session_id = 'session-journal-strip' AND event_type = 'message.added'`); err != nil { + t.Fatalf("read message event content: %v", err) + } + if eventContent != want { + t.Fatalf("event content = %q, want %q", eventContent, want) + } + + // The update trigger strips the same way. + message.Content = "a\nupdated bc end" + if err := repo.UpdateMessage(ctx, message); err != nil { + t.Fatalf("update message: %v", err) + } + wantUpdated := sysprompt.StripSystemContent(message.Content) + if err := repo.db.Get(&versionContent, `SELECT json_extract(payload, '$.content') FROM conversation_message_versions WHERE message_id = 'message-strip' ORDER BY row_sequence DESC LIMIT 1`); err != nil { + t.Fatalf("read updated message version content: %v", err) + } + if versionContent != wantUpdated { + t.Fatalf("updated version content = %q, want %q", versionContent, wantUpdated) + } + if err := repo.db.Get(&eventContent, `SELECT json_extract(payload, '$.content') FROM conversation_session_events WHERE session_id = 'session-journal-strip' AND event_type = 'message.updated'`); err != nil { + t.Fatalf("read updated message event content: %v", err) + } + if eventContent != wantUpdated { + t.Fatalf("updated event content = %q, want %q", eventContent, wantUpdated) + } +} + +func TestConversationJournalStripIgnoresClosingTagWithoutOpeningTag(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedForMsgTest(t, repo, "task-journal-closing-tag", "session-journal-closing-tag", "turn-journal-closing-tag") + content := "visibletail" + message := &models.Message{ + ID: "message-closing-tag", TaskSessionID: "session-journal-closing-tag", + TaskID: "task-journal-closing-tag", TurnID: "turn-journal-closing-tag", + AuthorType: models.MessageAuthorUser, Type: models.MessageTypeMessage, Content: content, + } + if err := repo.CreateMessage(ctx, message); err != nil { + t.Fatalf("create message: %v", err) + } + var eventContent string + if err := repo.db.Get(&eventContent, `SELECT json_extract(payload, '$.content') FROM conversation_session_events WHERE session_id = 'session-journal-closing-tag' AND event_type = 'message.added'`); err != nil { + t.Fatalf("read message event content: %v", err) + } + if eventContent != content { + t.Fatalf("event content = %q, want %q", eventContent, content) + } +} + +func TestConversationJournalStripFailsClosedPastDepthLimit(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedForMsgTest(t, repo, "task-journal-strip-deep", "session-journal-strip-deep", "turn-journal-strip-deep") + content := strings.Repeat("secret", 65) + "visible" + message := &models.Message{ + ID: "message-strip-deep", TaskSessionID: "session-journal-strip-deep", TaskID: "task-journal-strip-deep", + TurnID: "turn-journal-strip-deep", AuthorType: models.MessageAuthorUser, + Type: models.MessageTypeMessage, Content: content, + } + if err := repo.CreateMessage(ctx, message); err != nil { + t.Fatalf("create message: %v", err) + } + + for _, table := range []string{"conversation_message_versions", "conversation_session_events"} { + var payload string + query := "SELECT payload FROM " + table + " WHERE session_id = ? ORDER BY row_sequence DESC LIMIT 1" + if table == "conversation_session_events" { + query = "SELECT payload FROM " + table + " WHERE session_id = ? AND event_type = 'message.added' ORDER BY sequence DESC LIMIT 1" + } + if err := repo.db.Get(&payload, repo.db.Rebind(query), "session-journal-strip-deep"); err != nil { + t.Fatalf("read %s payload: %v", table, err) + } + if strings.Contains(payload, "secret") || strings.Contains(payload, "") { + t.Fatalf("%s persisted system content past sanitizer depth: %s", table, payload) + } + } +} +func TestConversationJournalPreservesPresentationMetadata(t *testing.T) { + repo := newRepoForSessionTests(t) + seedForMsgTest(t, repo, "task-presentation-metadata", "session-presentation-metadata", "turn-presentation-metadata") + message := &models.Message{ + ID: "message-presentation-metadata", TaskSessionID: "session-presentation-metadata", + TaskID: "task-presentation-metadata", TurnID: "turn-presentation-metadata", + AuthorType: models.MessageAuthorAgent, Type: models.MessageTypeClarificationRequest, + Content: "Question", RequestsInput: true, + Metadata: map[string]any{ + "agent_disconnected": true, + "agent_name": "Mock", + "command": "/usr/local/bin/mock-agent", + "completed_at": "2026-09-15T09:45:59.774503743Z", + "error": "must-not-be-dropped", + "exit_code": 0, + "has_hidden_prompts": false, + "is_resuming": true, + "pending_id": "pending-1", + "question": map[string]any{"id": "question-1", "prompt": "Choose"}, + "raw_content": "must-not-be-copied", + "script_type": "agent_boot", + "started_at": "2026-09-15T09:45:58.245720115Z", + }, + } + if err := repo.CreateMessage(context.Background(), message); err != nil { + t.Fatalf("create message: %v", err) + } + + var payload string + if err := repo.db.Get(&payload, `SELECT payload FROM conversation_session_events WHERE session_id = ? AND event_type = 'message.added'`, message.TaskSessionID); err != nil { + t.Fatalf("read event payload: %v", err) + } + var event map[string]any + if err := json.Unmarshal([]byte(payload), &event); err != nil { + t.Fatalf("decode event payload: %v", err) + } + if event["message_type"] != string(models.MessageTypeClarificationRequest) || event["requests_input"] != float64(1) { + t.Fatalf("message presentation fields = %#v", event) + } + metadata, ok := event["metadata"].(map[string]any) + if !ok || metadata["pending_id"] != "pending-1" || metadata["agent_disconnected"] != true || metadata["has_hidden_prompts"] != false { + t.Fatalf("message metadata = %#v", event["metadata"]) + } + for key, want := range map[string]any{ + "agent_name": "Mock", "command": "/usr/local/bin/mock-agent", "completed_at": "2026-09-15T09:45:59.774503743Z", + "error": "must-not-be-dropped", "exit_code": float64(0), "is_resuming": true, "script_type": "agent_boot", + "started_at": "2026-09-15T09:45:58.245720115Z", + } { + if metadata[key] != want { + t.Fatalf("message metadata[%q] = %#v, want %#v", key, metadata[key], want) + } + } + if _, exists := metadata["raw_content"]; exists { + t.Fatalf("private message metadata leaked: %#v", metadata) + } +} + +func TestConversationJournalStripKeepsRawTextWhenNoWellFormedBlock(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedForMsgTest(t, repo, "task-journal-strip-raw", "session-journal-strip-raw", "turn-journal-strip-raw") + // Shapes where the Go helper has no full match: an unterminated opening + // tag and a stray closing tag before the first opening tag. The SQLite + // strip must keep the raw text (never NULL), byte-identical to Go. + cases := map[string]string{ + "unterminated-open": "visible never closed", + "stray-close": "before closed after", + "deep-nesting": "" + strings.Repeat("", 70) + "x" + strings.Repeat("", 70) + "tail", + } + for name, content := range cases { + t.Run(name, func(t *testing.T) { + id := "message-strip-raw-" + name + message := &models.Message{ + ID: id, TaskSessionID: "session-journal-strip-raw", TaskID: "task-journal-strip-raw", + TurnID: "turn-journal-strip-raw", AuthorType: models.MessageAuthorUser, + Type: models.MessageTypeMessage, Content: content, + } + if err := repo.CreateMessage(ctx, message); err != nil { + t.Fatalf("create message: %v", err) + } + var versionContent *string + if err := repo.db.Get(&versionContent, `SELECT json_extract(payload, '$.content') FROM conversation_message_versions WHERE message_id = ?`, id); err != nil { + t.Fatalf("read message version content: %v", err) + } + if versionContent == nil { + t.Fatalf("strip returned NULL for %q", content) + } + want := sysprompt.StripSystemContent(content) + if name != "deep-nesting" && *versionContent != want { + t.Fatalf("version content = %q, want %q", *versionContent, want) + } + if strings.Contains(*versionContent, "") && name == "unterminated-open" { + // Unterminated opener text is preserved verbatim. + if *versionContent != content { + t.Fatalf("unterminated content = %q, want raw %q", *versionContent, content) + } + } + }) + } +} + +func TestConversationJournalSanitizeMigrationRewritesLegacyRows(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedForMsgTest(t, repo, "task-journal-migrate", "session-journal-migrate", "turn-journal-migrate") + legacy := "old\nsecretkept second text" + message := &models.Message{ + ID: "message-legacy", TaskSessionID: "session-journal-migrate", TaskID: "task-journal-migrate", + TurnID: "turn-journal-migrate", AuthorType: models.MessageAuthorUser, + Type: models.MessageTypeMessage, Content: "placeholder", + } + if err := repo.CreateMessage(ctx, message); err != nil { + t.Fatalf("create message: %v", err) + } + // Simulate rows written before the sanitize migration existed. + if _, err := repo.db.Exec(` + UPDATE conversation_message_versions SET payload = json_set(payload, '$.content', ?) WHERE message_id = 'message-legacy'; + `, legacy); err != nil { + t.Fatalf("seed legacy version payload: %v", err) + } + if _, err := repo.db.Exec(` + UPDATE conversation_session_events SET payload = json_set(payload, '$.content', ?) WHERE session_id = 'session-journal-migrate' AND event_type = 'message.added'; + `, legacy); err != nil { + t.Fatalf("seed legacy event payload: %v", err) + } + + if err := repo.initSQLiteConversationJournalTriggers(); err != nil { + t.Fatalf("re-init triggers (runs sanitize migration): %v", err) + } + want := sysprompt.StripSystemContent(legacy) + var versionContent string + if err := repo.db.Get(&versionContent, `SELECT json_extract(payload, '$.content') FROM conversation_message_versions WHERE message_id = 'message-legacy'`); err != nil { + t.Fatalf("read migrated version content: %v", err) + } + if versionContent != want { + t.Fatalf("migrated version content = %q, want %q", versionContent, want) + } + var eventContent string + if err := repo.db.Get(&eventContent, `SELECT json_extract(payload, '$.content') FROM conversation_session_events WHERE session_id = 'session-journal-migrate' AND event_type = 'message.added'`); err != nil { + t.Fatalf("read migrated event content: %v", err) + } + if eventContent != want { + t.Fatalf("migrated event content = %q, want %q", eventContent, want) + } +} + +func TestConversationJournalTurnDeletePreservesHistoryWithTombstone(t *testing.T) { + repo := newRepoForSessionTests(t) + seedForMsgTest(t, repo, "task-journal-turn-del", "session-journal-turn-del", "turn-journal-turn-del") + + now := time.Now().UTC() + if _, err := repo.db.Exec(repo.db.Rebind(`UPDATE task_session_turns SET completed_at = ?, updated_at = ? WHERE id = ?`), now, now, "turn-journal-turn-del"); err != nil { + t.Fatalf("complete turn: %v", err) + } + var cutoffBefore int64 + if err := repo.db.Get(&cutoffBefore, `SELECT watermark FROM conversation_session_streams WHERE session_id = 'session-journal-turn-del'`); err != nil { + t.Fatalf("read pre-delete cutoff: %v", err) + } + + if _, err := repo.db.Exec(repo.db.Rebind(`DELETE FROM task_session_turns WHERE id = ?`), "turn-journal-turn-del"); err != nil { + t.Fatalf("delete turn: %v", err) + } + var cutoffAfter int64 + if err := repo.db.Get(&cutoffAfter, `SELECT watermark FROM conversation_session_streams WHERE session_id = 'session-journal-turn-del'`); err != nil { + t.Fatalf("read deletion cutoff: %v", err) + } + if cutoffAfter != cutoffBefore+1 { + t.Fatalf("turn delete watermark = %d, want %d", cutoffAfter, cutoffBefore+1) + } + + var versions int + if err := repo.db.Get(&versions, `SELECT COUNT(*) FROM conversation_turn_versions WHERE turn_id = 'turn-journal-turn-del'`); err != nil { + t.Fatalf("count turn versions: %v", err) + } + if versions != 3 { + t.Fatalf("turn version count = %d, want two live versions plus tombstone", versions) + } + var tombstoneSequence int64 + if err := repo.db.Get(&tombstoneSequence, ` + SELECT row_sequence FROM conversation_turn_versions + WHERE turn_id = 'turn-journal-turn-del' AND tombstone = TRUE + `); err != nil { + t.Fatalf("read deletion tombstone: %v", err) + } + if tombstoneSequence != cutoffAfter { + t.Fatalf("tombstone sequence = %d, want deletion cutoff %d", tombstoneSequence, cutoffAfter) + } + + assertTurnLiveAtCutoff(t, repo, "session-journal-turn-del", "turn-journal-turn-del", cutoffBefore, true) + assertTurnLiveAtCutoff(t, repo, "session-journal-turn-del", "turn-journal-turn-del", cutoffAfter, false) + + var removalEvent string + if err := repo.db.Get(&removalEvent, `SELECT event_type FROM conversation_session_events WHERE session_id = 'session-journal-turn-del' ORDER BY sequence DESC LIMIT 1`); err != nil { + t.Fatalf("read last event type: %v", err) + } + if removalEvent != "session.turn.removed" { + t.Fatalf("last event type = %q, want session.turn.removed", removalEvent) + } +} + +func assertTurnLiveAtCutoff(t *testing.T, repo *Repository, sessionID, turnID string, cutoff int64, want bool) { + t.Helper() + var count int + if err := repo.db.Get(&count, repo.db.Rebind(` + WITH ranked AS ( + SELECT tombstone, + ROW_NUMBER() OVER (PARTITION BY turn_id ORDER BY row_sequence DESC) AS version_rank + FROM conversation_turn_versions + WHERE session_id = ? AND turn_id = ? AND row_sequence <= ? + ) + SELECT COUNT(*) FROM ranked WHERE version_rank = 1 AND tombstone = FALSE + `), sessionID, turnID, cutoff); err != nil { + t.Fatalf("query turn at cutoff %d: %v", cutoff, err) + } + if got := count == 1; got != want { + t.Fatalf("turn live at cutoff %d = %v, want %v", cutoff, got, want) + } +} + +func TestConversationJournalBackfillsExistingRowsIdempotently(t *testing.T) { + repo := newRepoForSessionTests(t) + seedForMsgTest(t, repo, "task-backfill", "session-backfill", "turn-backfill") + message := &models.Message{ + ID: "message-backfill", TaskSessionID: "session-backfill", TaskID: "task-backfill", + TurnID: "turn-backfill", AuthorType: models.MessageAuthorUser, + Type: models.MessageTypeMessage, Content: "hiddenexisting", + } + if err := repo.CreateMessage(context.Background(), message); err != nil { + t.Fatalf("create source message: %v", err) + } + if _, err := repo.db.Exec(` + DELETE FROM conversation_session_events; + DELETE FROM conversation_message_versions; + DELETE FROM conversation_turn_versions; + DELETE FROM conversation_session_streams; + `); err != nil { + t.Fatalf("clear journal: %v", err) + } + + if err := repo.backfillConversationJournal(); err != nil { + t.Fatalf("backfill conversation journal: %v", err) + } + if err := repo.backfillConversationJournal(); err != nil { + t.Fatalf("repeat conversation journal backfill: %v", err) + } + var eventCount int + if err := repo.db.Get(&eventCount, `SELECT COUNT(*) FROM conversation_session_events WHERE session_id = 'session-backfill'`); err != nil { + t.Fatalf("count backfill events: %v", err) + } + if eventCount != 2 { + t.Fatalf("backfill event count = %d, want 2", eventCount) + } + var content string + if err := repo.db.Get(&content, `SELECT json_extract(payload, '$.content') FROM conversation_message_versions WHERE message_id = 'message-backfill'`); err != nil { + t.Fatalf("read backfill message content: %v", err) + } + if content != "existing" { + t.Fatalf("backfill content = %q, want existing", content) + } +} + +func TestConversationJournalSenderTaskIDRequiresString(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedForMsgTest(t, repo, "task-journal-sender", "session-journal-sender", "turn-journal-sender") + if err := repo.CreateMessage(ctx, &models.Message{ + ID: "message-sender-numeric", TaskSessionID: "session-journal-sender", TaskID: "task-journal-sender", + TurnID: "turn-journal-sender", AuthorType: models.MessageAuthorUser, + Type: models.MessageTypeMessage, Content: "numeric", + Metadata: map[string]any{"sender_task_id": 42}, + }); err != nil { + t.Fatalf("create numeric-sender message: %v", err) + } + if err := repo.CreateMessage(ctx, &models.Message{ + ID: "message-sender-string", TaskSessionID: "session-journal-sender", TaskID: "task-journal-sender", + TurnID: "turn-journal-sender", AuthorType: models.MessageAuthorAgent, + Type: models.MessageTypeMessage, Content: "string", + Metadata: map[string]any{"sender_task_id": "task-sender-1"}, + }); err != nil { + t.Fatalf("create string-sender message: %v", err) + } + + var numericSender any + if err := repo.db.Get(&numericSender, `SELECT json_extract(payload, '$.sender_task_id') FROM conversation_message_versions WHERE message_id = 'message-sender-numeric'`); err != nil { + t.Fatalf("read numeric-sender payload: %v", err) + } + if numericSender != nil { + t.Fatalf("numeric sender_task_id stored as %v, want NULL", numericSender) + } + var stringSender string + if err := repo.db.Get(&stringSender, `SELECT json_extract(payload, '$.sender_task_id') FROM conversation_message_versions WHERE message_id = 'message-sender-string'`); err != nil { + t.Fatalf("read string-sender payload: %v", err) + } + if stringSender != "task-sender-1" { + t.Fatalf("string sender_task_id = %q, want task-sender-1", stringSender) + } +} + +// TestBackfillSkipsStaleMessageImageWithSameContent pins the round-9 fix: a +// concurrent update that only touches metadata (same content) must not let the +// stale pre-update backfill image win a later version. +func TestBackfillSkipsStaleMessageImageWithSameContent(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedForMsgTest(t, repo, "task-backfill-stale", "session-backfill-stale", "turn-backfill-stale") + now := time.Now().UTC().Truncate(time.Microsecond) + message := &models.Message{ + ID: "message-stale", TaskSessionID: "session-backfill-stale", TaskID: "task-backfill-stale", + TurnID: "turn-backfill-stale", AuthorType: models.MessageAuthorUser, + Type: models.MessageTypeMessage, Content: "same-content", + Metadata: map[string]any{"sender_task_id": "sender-1"}, CreatedAt: now, UpdatedAt: now, + } + if err := repo.CreateMessage(ctx, message); err != nil { + t.Fatalf("create message: %v", err) + } + // A concurrent metadata-only update journals its own version. + message.Metadata = map[string]any{"sender_task_id": "sender-2"} + message.UpdatedAt = now.Add(time.Minute) + if err := repo.UpdateMessage(ctx, message); err != nil { + t.Fatalf("update message: %v", err) + } + var versionsAfterUpdate int + if err := repo.db.Get(&versionsAfterUpdate, `SELECT COUNT(*) FROM conversation_message_versions WHERE message_id = 'message-stale'`); err != nil { + t.Fatalf("count versions: %v", err) + } + + tx, err := repo.db.Beginx() + if err != nil { + t.Fatal(err) + } + // The backfill seed reflects the pre-update image (same content, older + // timestamp): it must be skipped entirely. + stale := conversationJournalMessageSeed{ + ID: "message-stale", TaskSessionID: "session-backfill-stale", TaskID: "task-backfill-stale", + TurnID: "turn-backfill-stale", AuthorType: string(models.MessageAuthorUser), + Content: "same-content", MessageType: string(models.MessageTypeMessage), + Metadata: `{"sender_task_id":"sender-1"}`, CreatedAt: now, UpdatedAt: now, PromptIndex: message.PromptIndex, + } + if err := repo.backfillConversationMessage(tx, stale); err != nil { + _ = tx.Rollback() + t.Fatalf("backfill stale image: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + var versions int + if err := repo.db.Get(&versions, `SELECT COUNT(*) FROM conversation_message_versions WHERE message_id = 'message-stale'`); err != nil { + t.Fatalf("count versions: %v", err) + } + if versions != versionsAfterUpdate { + t.Fatalf("stale backfill added a version: %d -> %d", versionsAfterUpdate, versions) + } +} + +// TestConversationJournalSanitizeMigrationSkipsMalformedRows pins the +// round-9 fix: a pre-existing malformed payload must not abort the boot-time +// sanitize migration; the row is preserved verbatim so the mirror path can +// turn it into a durable poison record. +func TestConversationJournalSanitizeMigrationSkipsMalformedRows(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedForMsgTest(t, repo, "task-journal-malformed", "session-journal-malformed", "turn-journal-malformed") + message := &models.Message{ + ID: "message-malformed", TaskSessionID: "session-journal-malformed", TaskID: "task-journal-malformed", + TurnID: "turn-journal-malformed", AuthorType: models.MessageAuthorUser, + Type: models.MessageTypeMessage, Content: "placeholder", + } + if err := repo.CreateMessage(ctx, message); err != nil { + t.Fatalf("create message: %v", err) + } + malformed := `{"content":"not valid json` + if _, err := repo.db.Exec(` + UPDATE conversation_message_versions SET payload = ? WHERE message_id = 'message-malformed'; + UPDATE conversation_session_events SET payload = ? WHERE session_id = 'session-journal-malformed' AND event_type = 'message.added'; + `, malformed, malformed); err != nil { + t.Fatalf("seed malformed rows: %v", err) + } + + if err := repo.initSQLiteConversationJournalTriggers(); err != nil { + t.Fatalf("re-init triggers (runs sanitize migration): %v", err) + } + var versionPayload string + if err := repo.db.Get(&versionPayload, `SELECT payload FROM conversation_message_versions WHERE message_id = 'message-malformed'`); err != nil { + t.Fatalf("read version payload: %v", err) + } + if versionPayload != malformed { + t.Fatalf("malformed version payload was rewritten to %q", versionPayload) + } + var eventPayload string + if err := repo.db.Get(&eventPayload, `SELECT payload FROM conversation_session_events WHERE session_id = 'session-journal-malformed' AND event_type = 'message.added'`); err != nil { + t.Fatalf("read event payload: %v", err) + } + if eventPayload != malformed { + t.Fatalf("malformed event payload was rewritten to %q", eventPayload) + } +} + +func TestConversationJournalTurnCompletionIncludesHadOutput(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedForMsgTest(t, repo, "task-journal-empty", "session-journal-empty", "turn-journal-empty") + + if err := repo.CompleteTurn(ctx, "turn-journal-empty"); err != nil { + t.Fatalf("complete turn: %v", err) + } + + var raw string + if err := repo.db.Get(&raw, ` + SELECT payload FROM conversation_session_events + WHERE session_id = 'session-journal-empty' AND event_type = 'session.turn.completed' + `); err != nil { + t.Fatalf("read completed turn event: %v", err) + } + var payload map[string]any + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + t.Fatalf("decode completed turn event: %v", err) + } + if hadOutput, ok := payload["had_output"].(bool); !ok || hadOutput { + t.Fatalf("had_output = %#v, want false", payload["had_output"]) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/message.go b/apps/backend/internal/task/repository/sqlite/message.go index 3a7ae5bd3b..eef49820b5 100644 --- a/apps/backend/internal/task/repository/sqlite/message.go +++ b/apps/backend/internal/task/repository/sqlite/message.go @@ -455,10 +455,21 @@ func buildListMessagesQuery(driverName, sessionID string, opts models.ListMessag FROM task_session_messages WHERE task_session_id = ?` args := []interface{}{sessionID} - if opts.AuthorType != "" { + if len(opts.AuthorTypes) > 0 { + placeholders := make([]string, len(opts.AuthorTypes)) + for index, author := range opts.AuthorTypes { + placeholders[index] = "?" + args = append(args, author) + } + query += " AND author_type IN (" + strings.Join(placeholders, ",") + ")" + } else if opts.AuthorType != "" { query += " AND author_type = ?" args = append(args, opts.AuthorType) } + if opts.TaskID != "" { + query += " AND task_id = ?" + args = append(args, opts.TaskID) + } if cursor != nil { if opts.Before != "" { query += fmt.Sprintf(" AND (%s < %s OR (%s = %s AND id < ?))", nm, bound, nm, bound) diff --git a/apps/backend/internal/task/repository/sqlite/message_test.go b/apps/backend/internal/task/repository/sqlite/message_test.go index 533a8e030d..c9f9e4fac2 100644 --- a/apps/backend/internal/task/repository/sqlite/message_test.go +++ b/apps/backend/internal/task/repository/sqlite/message_test.go @@ -665,3 +665,38 @@ func ids(msgs []*models.Message) []string { } return out } + +func TestListMessagesPaginatedAuthorTypesAndTaskFilter(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedForMsgTest(t, repo, "task-page-filters", "session-page-filters", "turn-page-filters") + now := time.Now().UTC() + seeds := []*models.Message{ + {ID: "page-user-1", TaskID: "task-page-filters", TaskSessionID: "session-page-filters", TurnID: "turn-page-filters", AuthorType: models.MessageAuthorUser, Type: models.MessageTypeMessage, Content: "u1", CreatedAt: now}, + {ID: "page-agent-1", TaskID: "task-page-filters", TaskSessionID: "session-page-filters", TurnID: "turn-page-filters", AuthorType: models.MessageAuthorAgent, Type: models.MessageTypeMessage, Content: "a1", CreatedAt: now.Add(time.Second)}, + {ID: "page-user-2", TaskID: "task-page-filters", TaskSessionID: "session-page-filters", TurnID: "turn-page-filters", AuthorType: models.MessageAuthorUser, Type: models.MessageTypeMessage, Content: "u2", CreatedAt: now.Add(2 * time.Second)}, + } + for _, message := range seeds { + if err := repo.CreateMessage(ctx, message); err != nil { + t.Fatalf("create %s: %v", message.ID, err) + } + } + page := func(opts models.ListMessagesOptions) []string { + t.Helper() + opts.Sort = "asc" + messages, _, err := repo.ListMessagesPaginated(ctx, "session-page-filters", opts) + if err != nil { + t.Fatalf("list: %v", err) + } + ids := make([]string, 0, len(messages)) + for _, message := range messages { + ids = append(ids, message.ID) + } + return ids + } + require.Equal(t, []string{"page-user-1", "page-user-2"}, page(models.ListMessagesOptions{Limit: 10, AuthorTypes: []string{string(models.MessageAuthorUser)}})) + require.Equal(t, []string{"page-user-1", "page-agent-1", "page-user-2"}, page(models.ListMessagesOptions{Limit: 10, AuthorTypes: []string{string(models.MessageAuthorUser), string(models.MessageAuthorAgent)}})) + require.Equal(t, []string{"page-user-1", "page-user-2"}, page(models.ListMessagesOptions{Limit: 10, AuthorType: string(models.MessageAuthorUser)})) + require.Empty(t, page(models.ListMessagesOptions{Limit: 10, TaskID: "task-other"})) + require.Len(t, page(models.ListMessagesOptions{Limit: 10, TaskID: "task-page-filters"}), 3) +} diff --git a/apps/backend/internal/task/repository/sqlite/session_test.go b/apps/backend/internal/task/repository/sqlite/session_test.go index 05aef4a7cb..9e46345fc0 100644 --- a/apps/backend/internal/task/repository/sqlite/session_test.go +++ b/apps/backend/internal/task/repository/sqlite/session_test.go @@ -516,9 +516,16 @@ func seedForMsgTest(t *testing.T, repo *Repository, taskID, sessionID, turnID st } _, err = repo.db.Exec(repo.db.Rebind(` INSERT OR IGNORE INTO task_sessions - (id, task_id, started_at, updated_at) - VALUES (?, ?, ?, ?) - `), sessionID, taskID, now, now) + (id, task_id, queue_incarnation_id, started_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `), sessionID, taskID, "incarnation-"+sessionID, now, now) + if err != nil && strings.Contains(err.Error(), "no column named queue_incarnation_id") { + _, err = repo.db.Exec(repo.db.Rebind(` + INSERT OR IGNORE INTO task_sessions + (id, task_id, started_at, updated_at) + VALUES (?, ?, ?, ?) + `), sessionID, taskID, now, now) + } if err != nil { t.Fatalf("seed session %s: %v", sessionID, err) } diff --git a/apps/backend/internal/task/service/service_messages.go b/apps/backend/internal/task/service/service_messages.go index fb5db86c2e..61e101b05c 100644 --- a/apps/backend/internal/task/service/service_messages.go +++ b/apps/backend/internal/task/service/service_messages.go @@ -825,19 +825,22 @@ func (s *Service) ListMessagesPaginated(ctx context.Context, req ListMessagesReq return nil, false, err } limit := req.Limit - if limit <= 0 && (req.Before != "" || req.After != "" || req.Around != "" || req.AuthorType != "") { + if limit <= 0 && (req.Before != "" || req.After != "" || req.Around != "" || + req.AuthorType != "" || len(req.AuthorTypes) > 0 || req.TaskID != "") { limit = DefaultMessagesPageSize } if limit > MaxMessagesPageSize { limit = MaxMessagesPageSize } return s.messages.ListMessagesPaginated(ctx, req.TaskSessionID, models.ListMessagesOptions{ - Limit: limit, - Before: req.Before, - After: req.After, - Sort: req.Sort, - AuthorType: req.AuthorType, - Around: req.Around, + Limit: limit, + Before: req.Before, + After: req.After, + Sort: req.Sort, + AuthorType: req.AuthorType, + AuthorTypes: req.AuthorTypes, + TaskID: req.TaskID, + Around: req.Around, }) } diff --git a/apps/backend/internal/task/service/service_messages_coverage_test.go b/apps/backend/internal/task/service/service_messages_coverage_test.go index 675eda6fd7..9b1c60d921 100644 --- a/apps/backend/internal/task/service/service_messages_coverage_test.go +++ b/apps/backend/internal/task/service/service_messages_coverage_test.go @@ -3,6 +3,7 @@ package service import ( "context" "errors" + "fmt" "testing" "time" @@ -398,6 +399,49 @@ func TestListMessagesPaginatedClampsLimit(t *testing.T) { } } +func TestListMessagesPaginatedDefaultsLimitForAdditionalFilters(t *testing.T) { + svc, _, repo := newMessageTestService(t) + ctx := context.Background() + for index := 0; index <= DefaultMessagesPageSize; index++ { + seedMessage(t, repo, &models.Message{ + ID: fmt.Sprintf("filtered-%d", index), + AuthorType: models.MessageAuthorUser, + Content: fmt.Sprintf("message-%d", index), + }) + } + + tests := []struct { + name string + request ListMessagesRequest + }{ + { + name: "author types", + request: ListMessagesRequest{ + TaskSessionID: "sess-msg", + AuthorTypes: []string{string(models.MessageAuthorUser)}, + }, + }, + { + name: "task id", + request: ListMessagesRequest{ + TaskSessionID: "sess-msg", + TaskID: "task-msg", + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + page, hasMore, err := svc.ListMessagesPaginated(ctx, test.request) + if err != nil { + t.Fatalf("ListMessagesPaginated: %v", err) + } + if len(page) != DefaultMessagesPageSize || !hasMore { + t.Fatalf("page = %d, hasMore = %v; want %d and true", len(page), hasMore, DefaultMessagesPageSize) + } + }) + } +} + func TestListMessagesForPluginFiltersBySession(t *testing.T) { svc, _, repo := newMessageTestService(t) seedMessage(t, repo, &models.Message{ID: "m-plugin", Content: "visible"}) diff --git a/apps/backend/internal/task/service/service_requests.go b/apps/backend/internal/task/service/service_requests.go index 2086d8f887..d7dcd1e814 100644 --- a/apps/backend/internal/task/service/service_requests.go +++ b/apps/backend/internal/task/service/service_requests.go @@ -338,7 +338,11 @@ type ListMessagesRequest struct { After string Sort string AuthorType string - Around string + // AuthorTypes narrows by any listed author; when non-empty it takes + // precedence over AuthorType. TaskID narrows the page to one task. + AuthorTypes []string + TaskID string + Around string } // CreateRepositoryScriptRequest contains the data for creating a repository script diff --git a/apps/backend/internal/task/service/service_sessions.go b/apps/backend/internal/task/service/service_sessions.go index 9375c660f4..071340274a 100644 --- a/apps/backend/internal/task/service/service_sessions.go +++ b/apps/backend/internal/task/service/service_sessions.go @@ -90,6 +90,27 @@ func (s *Service) GetTaskSession(ctx context.Context, sessionID string) (*models return session, nil } +// DeleteSessionAndPublishRemoval removes a session inside the repository's +// transaction and publishes the terminal event only after that commit. +// Internal rollback callers use this to keep the ordered stream authoritative. +func (s *Service) DeleteSessionAndPublishRemoval(ctx context.Context, sessionID string) error { + session, err := s.sessions.GetTaskSession(ctx, sessionID) + if err != nil { + return err + } + if err := s.sessions.DeleteTaskSession(ctx, session); err != nil { + return err + } + if s.eventBus == nil { + return nil + } + return s.eventBus.Publish(ctx, events.SessionRemoved, bus.NewEvent( + events.SessionRemoved, + "task-service", + map[string]interface{}{sessionEventFieldSessionID: session.ID, sessionEventFieldTaskID: session.TaskID}, + )) +} + // GetExecutorRunningBySessionID returns the live executor row for sessionID, // or models.ErrExecutorRunningNotFound if the session has none (e.g. it // never started, or has since completed and been cleaned up). Exposed at diff --git a/apps/backend/internal/task/service/service_turns.go b/apps/backend/internal/task/service/service_turns.go index e0e4c7e846..8cababe26e 100644 --- a/apps/backend/internal/task/service/service_turns.go +++ b/apps/backend/internal/task/service/service_turns.go @@ -267,7 +267,21 @@ func (s *Service) RollbackReservedTurn( ctx context.Context, sessionID, turnID string, ) (bool, error) { - return s.turns.DeleteTurnIfUnreferenced(ctx, sessionID, turnID) + turn, err := s.turns.GetTurn(ctx, turnID) + if err != nil { + return false, err + } + if turn.TaskSessionID != sessionID { + return false, nil + } + removed, err := s.turns.DeleteTurnIfUnreferenced(ctx, sessionID, turnID) + if err != nil || !removed { + return removed, err + } + if err := s.publishTurnEvent(events.TurnRemoved, turn, nil); err != nil { + return true, err + } + return true, nil } // createCompletedTurn persists a synthetic turn that is never observable as diff --git a/apps/backend/internal/task/service/service_turns_test.go b/apps/backend/internal/task/service/service_turns_test.go index d1bef693a3..88bbfab222 100644 --- a/apps/backend/internal/task/service/service_turns_test.go +++ b/apps/backend/internal/task/service/service_turns_test.go @@ -253,6 +253,10 @@ func TestReservedTurnPublishesOnlyAfterAcceptanceAndRollsBackWhenEmpty(t *testin if _, err := repo.GetTurn(ctx, rejected.ID); !errors.Is(err, sql.ErrNoRows) { t.Fatalf("GetTurn(rolled back) error = %v, want sql.ErrNoRows", err) } + removedEvents := eventBus.GetPublishedEvents() + if len(removedEvents) != 1 || removedEvents[0].Type != events.TurnRemoved { + t.Fatalf("rollback events = %#v, want one turn.removed event", removedEvents) + } accepted, err := svc.ReserveTurn(ctx, sessionID, recovery) if err != nil { diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 385fbadcf8..84a7433d16 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -169,10 +169,12 @@ const ( ActionWorkflowHistoryList = "workflow.history.list" // Subscription actions - ActionTaskSubscribe = "task.subscribe" - ActionTaskUnsubscribe = "task.unsubscribe" - ActionSessionSubscribe = "session.subscribe" - ActionSessionUnsubscribe = "session.unsubscribe" + ActionTaskSubscribe = "task.subscribe" + ActionTaskUnsubscribe = "task.unsubscribe" + ActionSessionSubscribe = "session.subscribe" + ActionSessionUnsubscribe = "session.unsubscribe" + ActionSessionAck = "session.ack" + ActionSessionPoisonRequeue = "session.event.poison.requeue" // Focus signals are layered on top of subscriptions to indicate which // session the user is actively viewing (task details page or task panel), // vs merely subscribed (sidebar diff badges). Drives backend polling tier. @@ -248,6 +250,7 @@ const ( ActionSessionMessageAdded = "session.message.added" ActionSessionMessageUpdated = "session.message.updated" ActionSessionMessageDeleted = "session.message.deleted" + ActionSessionRemoved = "session.removed" ActionSessionStateChanged = "session.state_changed" ActionSessionPendingActionChanged = "session.pending_action_changed" ActionSessionActivityChanged = "session.activity_changed" @@ -258,6 +261,7 @@ const ( ActionSessionAgentctlError = "session.agentctl_error" ActionSessionTurnStarted = "session.turn.started" ActionSessionTurnCompleted = "session.turn.completed" + ActionSessionTurnRemoved = "session.turn.removed" ActionSessionAvailableCommands = "session.available_commands" ActionSessionModeChanged = "session.mode_changed" ActionSessionAgentCapabilities = "session.agent_capabilities" diff --git a/apps/packages/plugin-sdk/src/index.ts b/apps/packages/plugin-sdk/src/index.ts index 2b74d3aaf4..e381640376 100644 --- a/apps/packages/plugin-sdk/src/index.ts +++ b/apps/packages/plugin-sdk/src/index.ts @@ -85,6 +85,7 @@ export interface HostReact { createElement: ElementFactory; useState(initialValue: Value | (() => Value)): [Value, StateSetter]; useEffect(effect: () => void | (() => void), dependencies?: readonly unknown[]): void; + useLayoutEffect(effect: () => void | (() => void), dependencies?: readonly unknown[]): void; useMemo(factory: () => Value, dependencies: readonly unknown[]): Value; useCallback unknown>( callback: Callback, @@ -291,19 +292,107 @@ export interface PluginRouteOptions { topbar?: boolean | PluginPageChrome; } -export interface PluginTaskPanelProps { - panelId: string; +export type PluginSessionKind = "managed" | "passthrough" | null; +export type PluginConversationAuthor = "user" | "agent"; +export type PluginConversationSort = "asc" | "desc"; + +export interface PluginConversationMessage { + id: string; + taskId: string | null; + sessionId: string; + turnId?: string; + authorType: PluginConversationAuthor; + type: string; + content: string; + createdAt: string; + updatedAt: string; + promptIndex?: number; + senderTaskId?: string; +} + +export interface PluginConversationTurn { + id: string; + taskId: string | null; + sessionId: string; + startedAt: string; + completedAt?: string; + updatedAt: string; +} + +export interface PluginSessionMessagesQuery { + sessionId: string | null; + taskId?: string | null; + authorTypes?: readonly PluginConversationAuthor[]; + sort?: PluginConversationSort; + pageSize?: number; +} + +export type PluginConversationErrorCode = + | "unauthenticated" + | "not_found" + | "invalid_query" + | "upstream_failure"; + +export interface PluginConversationError { + code: PluginConversationErrorCode; + message: string; + retryable: boolean; +} + +export interface PluginSessionMessagesState { + messages: readonly PluginConversationMessage[]; + loading: boolean; + hydrated: boolean; + loadingMore: boolean; + error: PluginConversationError | null; + hasMore: boolean; + removed: boolean; + loadMore(): Promise; + retry(): void; +} + +export interface PluginSessionTurnsState { + turns: readonly PluginConversationTurn[]; + loading: boolean; + hydrated: boolean; + error: PluginConversationError | null; + removed: boolean; + retry(): void; +} + +export interface PluginConversationApi { + useSessionMessages(query: PluginSessionMessagesQuery): PluginSessionMessagesState; + useSessionTurns(sessionId: string | null, taskId?: string | null): PluginSessionTurnsState; + useMessageFavorite(sessionId: string | null, messageId: string): boolean; +} + +export type PluginOpenMessageResult = { status: "accepted" | "unavailable" }; + +export interface PluginTaskPanelConversationCapability { + openMessage(messageId: string): PluginOpenMessageResult; + history: PluginConversationApi; +} + +export interface PluginTaskPanelContext { taskId: string; sessionId: string | null; + sessionKind: PluginSessionKind; presentation: "desktop" | "mobile"; } +export interface PluginTaskPanelProps extends PluginTaskPanelContext { + panelId: string; + conversation: PluginTaskPanelConversationCapability; +} + export interface TaskPanelRegistration { id: string; title: string; + titleKey?: string; icon?: PluginIcon; Component: Component; mobileEnabled?: boolean; + visible?(context: PluginTaskPanelContext): boolean; } export interface PluginTaskMenuContext { @@ -485,6 +574,7 @@ interface PluginUIShape { PopoverTitle: unknown; PopoverTrigger: unknown; Progress: unknown; + PromptMentionText: Component<{ text: string; interactive?: boolean }>; RichTextEditor: unknown; RichTextReadOnly: unknown; ScrollArea: unknown; @@ -544,7 +634,9 @@ export type SettingsSaveContributor = { }; export type PluginUIApi = { - readonly [Name in keyof PluginUIShape]: HostComponent; + readonly [Name in keyof PluginUIShape]: PluginUIShape[Name] extends Component + ? Component + : HostComponent; }; export interface PluginToastApi { @@ -580,6 +672,7 @@ export interface PluginHostApi { React: HostReact; jsx: ElementFactory; ui: PluginUIApi; + conversation: PluginConversationApi; i18n: PluginI18nApi; context: PluginContextApi; api: { diff --git a/apps/web/components/quick-chat/quick-chat-content.tsx b/apps/web/components/quick-chat/quick-chat-content.tsx index 4e66cc6154..2928b15733 100644 --- a/apps/web/components/quick-chat/quick-chat-content.tsx +++ b/apps/web/components/quick-chat/quick-chat-content.tsx @@ -114,6 +114,7 @@ export const QuickChatContent = memo(function QuickChatContent({ key={sessionId} pending={Boolean(pendingClarification)} messages={pendingClarificationGroup} + agentDisconnected={panelState.session?.pending_action === null} onResolved={handleClarificationResolved} shortcutScopeRef={shortcutScopeRef} maxHeightVh={35} diff --git a/apps/web/components/quick-chat/quick-chat-provider-focus.test.tsx b/apps/web/components/quick-chat/quick-chat-provider-focus.test.tsx index b7be33758d..72bd1820d7 100644 --- a/apps/web/components/quick-chat/quick-chat-provider-focus.test.tsx +++ b/apps/web/components/quick-chat/quick-chat-provider-focus.test.tsx @@ -1,6 +1,9 @@ import { cleanup, render } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; +const mockUnsubscribeSession = vi.hoisted(() => vi.fn()); +const mockSubscribeSession = vi.hoisted(() => vi.fn(() => mockUnsubscribeSession)); + const providerState = vi.hoisted(() => ({ quickChat: { isOpen: true, @@ -11,6 +14,7 @@ const providerState = vi.hoisted(() => ({ activeTerminalTabId: null, }, workspaces: { activeId: "ws-1" }, + connection: { status: "disconnected" as "connected" | "disconnected" }, })); vi.mock("@/components/state-provider", () => ({ @@ -18,6 +22,9 @@ vi.mock("@/components/state-provider", () => ({ })); vi.mock("@/hooks/domains/settings/use-settings-data", () => ({ useSettingsData: vi.fn() })); vi.mock("@/hooks/use-quick-chat-resync", () => ({ useQuickChatResync: vi.fn() })); +vi.mock("@/lib/ws/connection", () => ({ + getWebSocketClient: () => ({ subscribeSession: mockSubscribeSession }), +})); vi.mock("./quick-chat-modal", () => ({ QuickChatModal: () => null })); import { captureQuickChatLauncherFocus } from "./quick-chat-focus"; @@ -25,6 +32,10 @@ import { QuickChatProvider } from "./quick-chat-provider"; afterEach(() => { cleanup(); + providerState.quickChat.isOpen = true; + providerState.connection.status = "disconnected"; + mockSubscribeSession.mockClear(); + mockUnsubscribeSession.mockClear(); vi.unstubAllGlobals(); }); @@ -52,4 +63,23 @@ describe("QuickChatProvider focus contract", () => { expect(document.activeElement).toBe(launcher); }); + it("keeps Quick Chat sessions subscribed after the modal closes", () => { + providerState.connection.status = "connected"; + const { rerender } = render( + + + , + ); + + expect(mockSubscribeSession).toHaveBeenCalledWith("chat-1"); + + providerState.quickChat.isOpen = false; + rerender( + + + , + ); + + expect(mockUnsubscribeSession).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/components/quick-chat/quick-chat-provider.tsx b/apps/web/components/quick-chat/quick-chat-provider.tsx index 8ebde73559..aba533a110 100644 --- a/apps/web/components/quick-chat/quick-chat-provider.tsx +++ b/apps/web/components/quick-chat/quick-chat-provider.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useSyncExternalStore } from "react"; import { useAppStore } from "@/components/state-provider"; import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { useQuickChatResync } from "@/hooks/use-quick-chat-resync"; +import { getWebSocketClient } from "@/lib/ws/connection"; import { restoreQuickChatLauncherFocus } from "./quick-chat-focus"; import { QuickChatModal } from "./quick-chat-modal"; @@ -68,6 +69,22 @@ export function QuickChatProvider({ children }: { children: React.ReactNode }) { // Quick chats are shared across devices: re-read the server's list whenever // the socket connects so tabs opened or closed elsewhere show up here too. useQuickChatResync(activeWorkspace); + const connectionStatus = useAppStore((s) => s.connection.status); + + // Keep persisted Quick Chat sessions subscribed while their modal is closed. + // The sidebar activity indicator must receive the completion event that arms + // the unseen marker after the conversation view unmounts. + useEffect(() => { + if (connectionStatus !== "connected") return; + const client = getWebSocketClient(); + if (!client) return; + const unsubscribes = quickChatSessions.map((session) => + client.subscribeSession(session.sessionId), + ); + return () => { + unsubscribes.forEach((unsubscribe) => unsubscribe()); + }; + }, [connectionStatus, quickChatSessions]); const workspaceId = getWorkspaceId({ sessions: quickChatSessions, diff --git a/apps/web/components/task/chat/clarification-input-overlay.tsx b/apps/web/components/task/chat/clarification-input-overlay.tsx index cbf186229b..4a6b91de3c 100644 --- a/apps/web/components/task/chat/clarification-input-overlay.tsx +++ b/apps/web/components/task/chat/clarification-input-overlay.tsx @@ -30,6 +30,8 @@ type ClarificationInputOverlayProps = { onResolved: () => void; shortcutScopeRef: RefObject; keyboardShortcutsEnabled?: boolean; + /** True when the session no longer has a live clarification waiter. */ + agentDisconnected?: boolean; // Called when the user presses Escape. Unlike Skip, this must not answer or // reject the bundle — it only dismisses the UI (e.g. collapses the panel). // The question stays pending and the agent stays blocked. @@ -393,6 +395,7 @@ type CarouselBodyProps = { setCustomDrafts: React.Dispatch>>; allAnswered: boolean; isSubmitting: boolean; + agentDisconnected: boolean; shortcutScopeRef: RefObject; armedEventRef: RefObject; keyboardShortcutsEnabled: boolean; @@ -495,7 +498,6 @@ function buildQuestionHandlers(ctx: QuestionHandlerCtx): QuestionHandlers { }, }; } - function ClarificationCarouselBody({ sortedMessages, meta, @@ -506,6 +508,7 @@ function ClarificationCarouselBody({ setCustomDrafts, allAnswered, isSubmitting, + agentDisconnected, shortcutScopeRef, armedEventRef, keyboardShortcutsEnabled, @@ -513,9 +516,11 @@ function ClarificationCarouselBody({ onDismiss, }: CarouselBodyProps) { const total = sortedMessages.length; - const showAgentDisconnectedAtTop = sortedMessages.some( - (m) => (m.metadata as ClarificationRequestMetadata | undefined)?.agent_disconnected === true, - ); + const showAgentDisconnectedAtTop = + agentDisconnected || + sortedMessages.some( + (m) => (m.metadata as ClarificationRequestMetadata | undefined)?.agent_disconnected === true, + ); const isSingleQuestion = total === 1; if (!meta) return null; @@ -580,12 +585,14 @@ function ClarificationCarouselBody({ ); } +// eslint-disable-next-line max-lines-per-function -- coordinates the complete clarification overlay lifecycle. export function ClarificationInputOverlay({ messages, onResolved, onOutcome, shortcutScopeRef, keyboardShortcutsEnabled = true, + agentDisconnected = false, onDismiss, onCollapse, collapseContentId, @@ -667,6 +674,7 @@ export function ClarificationInputOverlay({ setCustomDrafts={setCustomDrafts} allAnswered={allAnswered} isSubmitting={isSubmitting} + agentDisconnected={agentDisconnected} shortcutScopeRef={shortcutScopeRef} armedEventRef={armedEventRef} keyboardShortcutsEnabled={keyboardShortcutsEnabled} diff --git a/apps/web/components/task/chat/clarification-panel-section.tsx b/apps/web/components/task/chat/clarification-panel-section.tsx index 363eee2e0c..c97531f0a1 100644 --- a/apps/web/components/task/chat/clarification-panel-section.tsx +++ b/apps/web/components/task/chat/clarification-panel-section.tsx @@ -16,18 +16,22 @@ type ClarificationPanelSectionProps = { messages: readonly Message[] | null | undefined; onResolved: () => void; shortcutScopeRef: RefObject; + /** + * True when the session no longer has a live clarification waiter. + * Detached requests remain answerable even if their row refresh lags. + */ + agentDisconnected?: boolean; /** * Caps the expanded overlay's height as a percentage of the viewport. - * Drives both the rendered CSS max-height and the resize hook's drag - * clamp — the two MUST agree, or the drag handle silently stops - * responding before its own visible ceiling. + * Drives both the rendered CSS max-height and the resize hook's drag clamp + * - the two MUST agree, or the drag handle silently stops responding before + * its own visible ceiling. */ maxHeightVh: number; // Additive: forwarded straight through to ClarificationInputOverlay. // Existing hosts (task chat, Quick Chat) leave this unset. onOutcome?: (outcome: ClarificationOutcome) => void; }; - function pendingIdFromMessages(messages: readonly Message[] | null | undefined): string | null { const first = messages?.[0]; if (!first) return null; @@ -61,6 +65,7 @@ export function ClarificationPanelSection({ messages, onResolved, shortcutScopeRef, + agentDisconnected = false, maxHeightVh, onOutcome, }: ClarificationPanelSectionProps) { @@ -89,6 +94,67 @@ export function ClarificationPanelSection({ const questionCount = messages?.length ?? 0; const actionLabel = collapsed ? t("chat:expandClarification") : t("chat:collapseClarification"); + + return ( + setCollapsed(true)} + onOutcome={onOutcome} + onResolved={onResolved} + onToggleCollapse={() => setCollapsed((current) => !current)} + questionCount={questionCount} + resizeHandleProps={resizeHandleProps} + shortcutScopeRef={shortcutScopeRef} + /> + ); +} + +type ClarificationPanelContentProps = { + actionLabel: string; + agentDisconnected: boolean; + collapsed: boolean; + containerRef: RefObject; + contentId: string; + disclosure: ReturnType; + height: number | null; + maxHeightVh: number; + messages: readonly Message[] | null | undefined; + onCollapse: () => void; + onOutcome?: (outcome: ClarificationOutcome) => void; + onResolved: () => void; + onToggleCollapse: () => void; + questionCount: number; + resizeHandleProps: Parameters[0]; + shortcutScopeRef: RefObject; +}; + +function ClarificationPanelContent({ + actionLabel, + agentDisconnected, + collapsed, + containerRef, + contentId, + disclosure, + height, + maxHeightVh, + messages, + onCollapse, + onOutcome, + onResolved, + onToggleCollapse, + questionCount, + resizeHandleProps, + shortcutScopeRef, +}: ClarificationPanelContentProps) { + const { t } = useTranslation(); const compact = collapsed || questionCount === 0; return ( @@ -135,7 +201,7 @@ export function ClarificationPanelSection({ aria-controls={contentId} title={actionLabel} data-testid="clarification-collapse-toggle" - onClick={() => setCollapsed((current) => !current)} + onClick={onToggleCollapse} > {collapsed ? ( @@ -156,8 +222,9 @@ export function ClarificationPanelSection({ onOutcome={onOutcome} shortcutScopeRef={shortcutScopeRef} keyboardShortcutsEnabled={!collapsed} - onDismiss={() => setCollapsed(true)} - onCollapse={() => setCollapsed(true)} + agentDisconnected={agentDisconnected} + onDismiss={onCollapse} + onCollapse={onCollapse} collapseContentId={contentId} /> diff --git a/apps/web/components/task/chat/use-chat-panel-state.ts b/apps/web/components/task/chat/use-chat-panel-state.ts index 5430fec090..bbb7afb6d3 100644 --- a/apps/web/components/task/chat/use-chat-panel-state.ts +++ b/apps/web/components/task/chat/use-chat-panel-state.ts @@ -465,6 +465,11 @@ function useSessionData( () => clarificationTurnIdForSession(session?.state, turns), [session?.state, turns], ); + const currentTurnCompleted = useMemo(() => { + if (currentTurnId === null) return true; + if (currentTurnId === undefined || !turns) return undefined; + return turns.find((turn) => turn.id === currentTurnId)?.completed_at != null; + }, [currentTurnId, turns]); const lastAgentError = useMemo(() => readLastAgentError(session?.metadata), [session?.metadata]); const processed = useProcessedMessages(messages, taskId, resolvedSessionId, taskDescription, { initialPromptPreview: session?.metadata?.initial_prompt_preview, @@ -472,6 +477,7 @@ function useSessionData( hasOlderMessages, lastAgentError, currentTurnId, + currentTurnCompleted, pendingAction: session?.pending_action, }); const { sessionModel, activeModel } = useSessionModel( diff --git a/apps/web/components/task/dockview-add-panel-items.test.tsx b/apps/web/components/task/dockview-add-panel-items.test.tsx index 6f862d8c18..0de4a55c26 100644 --- a/apps/web/components/task/dockview-add-panel-items.test.tsx +++ b/apps/web/components/task/dockview-add-panel-items.test.tsx @@ -456,7 +456,7 @@ describe("AddPanelMenuItems — plugin task panels (AC1)", () => { .forPlugin("kandev-plugin-notes") .registerTaskPanel({ id: "notes", title: "Notes", Component: Notes }); - renderMenu(); + renderMenu({ taskId: "task-1" }); const row = screen.getByTestId("add-panel-plugin-item-kandev-plugin-notes-notes"); expect(row.textContent).toContain("Notes"); diff --git a/apps/web/components/task/dockview-add-panel-items.tsx b/apps/web/components/task/dockview-add-panel-items.tsx index e0359cf8b7..afc47fd202 100644 --- a/apps/web/components/task/dockview-add-panel-items.tsx +++ b/apps/web/components/task/dockview-add-panel-items.tsx @@ -26,7 +26,9 @@ import { useDockviewStore } from "@/lib/state/dockview-store"; import { reviewPanelId } from "@/lib/state/dockview-review-panel-id"; import { pluginRegistry, usePluginRegistry } from "@/lib/plugins/registry"; import { resolvePluginIcon } from "@/lib/plugins/icons"; -import type { ReviewItemSummary } from "@/lib/plugins/types"; +import { registrationIsVisible } from "./plugin-task-panel"; +import { resolveTaskPanelTitle } from "@/lib/state/layout-manager/plugin-panels"; +import type { PluginSessionKind, ReviewItemSummary } from "@/lib/plugins/types"; import type { TaskPR } from "@/lib/types/github"; import type { TaskMR } from "@/lib/types/gitlab"; import { useAppStore } from "@/components/state-provider"; @@ -200,20 +202,41 @@ function PRPanelMenuItems({ prs, onOpenPR }: { prs: TaskPR[]; onOpenPR: (pr: Tas } /** One "+" menu row per plugin-registered task panel (AC1), rendered after Plan. */ -function PluginTaskPanelMenuItems({ groupId }: { groupId: string }) { +function PluginTaskPanelMenuItems({ + groupId, + state, +}: { + groupId: string; + state: AddPanelMenuState; +}) { usePluginRegistry(); const addPluginPanel = useDockviewStore((s) => s.addPluginPanel); - const panels = pluginRegistry.getTaskPanels(); + const sessionId = useAppStore((store) => store.tasks.activeSessionId); + let sessionKind: PluginSessionKind = null; + if (sessionId) { + sessionKind = state.isPassthrough ? "passthrough" : "managed"; + } + const panels = state.taskId + ? pluginRegistry.getTaskPanels().filter((registration) => + registrationIsVisible(registration, { + taskId: state.taskId!, + sessionId, + sessionKind, + presentation: "desktop", + }), + ) + : []; return ( <> {panels.map((registration) => { const Icon = resolvePluginIcon(registration.icon); + const title = resolveTaskPanelTitle(registration); return ( - addPluginPanel(registration.pluginId, registration.id, registration.title, { + addPluginPanel(registration.pluginId, registration.id, title, { groupId, }) } @@ -221,7 +244,7 @@ function PluginTaskPanelMenuItems({ groupId }: { groupId: string }) { data-testid={`add-panel-plugin-item-${registration.pluginId}-${registration.id}`} > - {registration.title} + {title} ); })} @@ -354,7 +377,7 @@ export function AddPanelMenuItems({ {t("task:portForwarding")} )} - + {!state.isPassthrough && ( addTodosPanel({ groupId })} className={MENU_ITEM_CLASS}> diff --git a/apps/web/components/task/layout-preset-selector.test.tsx b/apps/web/components/task/layout-preset-selector.test.tsx index a749768c38..9bd0d231cd 100644 --- a/apps/web/components/task/layout-preset-selector.test.tsx +++ b/apps/web/components/task/layout-preset-selector.test.tsx @@ -28,6 +28,7 @@ vi.mock("react-i18next", () => ({ })); vi.mock("@/lib/i18n", () => ({ + i18n: { language: "en", on: vi.fn() }, t: (key: string) => key, })); diff --git a/apps/web/components/task/mobile/plugin-panel-picker.test.tsx b/apps/web/components/task/mobile/plugin-panel-picker.test.tsx index 8c29a06237..3579ac5c83 100644 --- a/apps/web/components/task/mobile/plugin-panel-picker.test.tsx +++ b/apps/web/components/task/mobile/plugin-panel-picker.test.tsx @@ -1,5 +1,17 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + tasks: { activeTaskId: "task-1", activeSessionId: "session-1" }, + taskSessions: { + items: { + "session-1": { id: "session-1", is_passthrough: false }, + }, + }, + }), +})); import { pluginRegistry } from "@/lib/plugins/registry"; import { PluginPanelPicker } from "./plugin-panel-picker"; @@ -24,7 +36,7 @@ describe("PluginPanelPicker", () => { .forPlugin(PLUGIN_B) .registerTaskPanel({ id: "notes", title: "Notes", Component: Notes, mobileEnabled: true }); - render(); + render(); const options = screen.getAllByTestId(/^mobile-plugin-panel-option-/); expect(options).toHaveLength(2); @@ -43,11 +55,49 @@ describe("PluginPanelPicker", () => { const onOpenChange = vi.fn(); const onSelect = vi.fn(); - render(); + render( + , + ); fireEvent.click(screen.getByTestId("mobile-plugin-panel-option-picker-plugin-b-notes")); expect(onSelect).toHaveBeenCalledWith("plugin:picker-plugin-b:notes"); expect(onOpenChange).toHaveBeenCalledWith(false); }); + + it("omits registrations whose visibility predicate rejects the active mobile context", () => { + function Notes() { + return null; + } + pluginRegistry.forPlugin(PLUGIN_A).registerTaskPanel({ + id: "notes", + title: "Notes", + Component: Notes, + mobileEnabled: true, + visible: (context) => context.presentation === "desktop", + }); + + render(); + + expect(screen.queryByTestId(`mobile-plugin-panel-option-${PLUGIN_A}-notes`)).toBeNull(); + }); + + it("does not invoke plugin visibility without a task context", () => { + function Notes() { + return null; + } + const visible = vi.fn(() => true); + pluginRegistry.forPlugin(PLUGIN_A).registerTaskPanel({ + id: "notes", + title: "Notes", + Component: Notes, + mobileEnabled: true, + visible, + }); + + render(); + + expect(visible).not.toHaveBeenCalled(); + expect(screen.queryByTestId(`mobile-plugin-panel-option-${PLUGIN_A}-notes`)).toBeNull(); + }); }); diff --git a/apps/web/components/task/mobile/plugin-panel-picker.tsx b/apps/web/components/task/mobile/plugin-panel-picker.tsx index f53790f35f..a9cae08708 100644 --- a/apps/web/components/task/mobile/plugin-panel-picker.tsx +++ b/apps/web/components/task/mobile/plugin-panel-picker.tsx @@ -7,6 +7,8 @@ import { pluginPanelId } from "@/lib/state/layout-manager/plugin-panels"; import type { MobileSessionPanel } from "@/lib/state/slices/ui/types"; import { resolvePluginIcon } from "@/lib/plugins/icons"; import { pluginRegistry, usePluginRegistry } from "@/lib/plugins/registry"; +import { registrationIsVisible } from "../plugin-task-panel"; +import { resolveTaskPanelTitle } from "@/lib/state/layout-manager/plugin-panels"; import { MobilePickerSheet } from "./mobile-picker-sheet"; type PluginPanelPickerProps = { @@ -16,6 +18,9 @@ type PluginPanelPickerProps = { showPromptHistory?: boolean; taskCanvases?: Canvas[]; onOpenCanvas?: (canvasId: string) => void; + taskId?: string | null; + sessionId?: string | null; + sessionKind?: "managed" | "passthrough" | null; }; /** One grouped, scrollable phone picker for all mobile-enabled plugin panels. */ @@ -26,12 +31,25 @@ export function PluginPanelPicker({ showPromptHistory = false, taskCanvases = [], onOpenCanvas, + taskId = null, + sessionId = null, + sessionKind = null, }: PluginPanelPickerProps) { const { t } = useTranslation(); usePluginRegistry(); - const registrations = pluginRegistry - .getTaskPanels() - .filter((registration) => registration.mobileEnabled); + const registrations = taskId + ? pluginRegistry + .getTaskPanels() + .filter((registration) => registration.mobileEnabled) + .filter((registration) => + registrationIsVisible(registration, { + taskId, + sessionId, + sessionKind, + presentation: "mobile", + }), + ) + : []; if (!open) return null; @@ -83,7 +101,7 @@ export function PluginPanelPicker({ }} > - {registration.title} + {resolveTaskPanelTitle(registration)} ); })} diff --git a/apps/web/components/task/mobile/session-mobile-bottom-nav.test.tsx b/apps/web/components/task/mobile/session-mobile-bottom-nav.test.tsx index 16fe08c7f1..94c8f3540f 100644 --- a/apps/web/components/task/mobile/session-mobile-bottom-nav.test.tsx +++ b/apps/web/components/task/mobile/session-mobile-bottom-nav.test.tsx @@ -110,6 +110,7 @@ describe("SessionMobileBottomNav plugin panels", () => { render( { render( { expect(screen.getByRole("button", { name: "Panels" }).className).toContain("text-primary"); }); + it("does not evaluate task-panel visibility without a task", () => { + function Notes() { + return null; + } + const visible = vi.fn(() => true); + pluginRegistry.forPlugin(PLUGIN_A).registerTaskPanel({ + id: "notes", + title: "Notes", + Component: Notes, + mobileEnabled: true, + visible, + }); + + render( + , + ); + + expect(visible).not.toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: "Panels" })).toBeNull(); + }); + it("keeps the grouped Panels action active for Prompt history", () => { render( void; + taskId?: string | null; + sessionId?: string | null; + sessionKind?: "managed" | "passthrough" | null; }; type NavItem = { @@ -44,8 +48,22 @@ type NavItem = { connectionIssueSeverity?: Exclude; } & ({ panel: MobileSessionPanel; onClick?: never } | { panel?: never; onClick: () => void }); -function hasMobilePluginPanels(): boolean { - return pluginRegistry.getTaskPanels().some((registration) => registration.mobileEnabled); +function hasMobilePluginPanels( + taskId: string | null, + sessionId: string | null, + sessionKind: "managed" | "passthrough" | null, +): boolean { + if (!taskId) return false; + return pluginRegistry.getTaskPanels().some( + (registration) => + registration.mobileEnabled && + registrationIsVisible(registration, { + taskId, + sessionId, + sessionKind, + presentation: "mobile", + }), + ); } function buildMobileNavItems({ @@ -58,6 +76,7 @@ function buildMobileNavItems({ onOpenPluginPicker, showPromptHistory, hasTaskCanvases, + mobilePluginPanelsAvailable, connectionIssueSeverity, t, }: { @@ -72,6 +91,7 @@ function buildMobileNavItems({ hasTaskCanvases: boolean; connectionIssueSeverity: ConnectionIssueSeverity; t: (key: string) => string; + mobilePluginPanelsAvailable: boolean; }): NavItem[] { return [ { @@ -120,7 +140,7 @@ function buildMobileNavItems({ label: t("task:terminal"), icon: , }, - ...(showPromptHistory || hasTaskCanvases || hasMobilePluginPanels() + ...(showPromptHistory || hasTaskCanvases || mobilePluginPanelsAvailable ? [ { label: t("common:panels"), @@ -156,11 +176,15 @@ export function SessionMobileBottomNav({ connectionIssueSeverity = "none", taskCanvases = [], onOpenCanvas, + taskId = null, + sessionId = null, + sessionKind = null, }: SessionMobileBottomNavProps) { const { t } = useTranslation(); usePluginRegistry(); const registryVersion = pluginRegistry.getVersion(); const [pluginPickerOpen, setPluginPickerOpen] = useState(false); + const mobilePluginPanelsAvailable = hasMobilePluginPanels(taskId, sessionId, sessionKind); const items: NavItem[] = useMemo( () => buildMobileNavItems({ @@ -173,6 +197,7 @@ export function SessionMobileBottomNav({ onOpenPluginPicker: () => setPluginPickerOpen(true), showPromptHistory, hasTaskCanvases: taskCanvases.length > 0, + mobilePluginPanelsAvailable, connectionIssueSeverity, t, }), @@ -187,6 +212,7 @@ export function SessionMobileBottomNav({ activePanel, showPromptHistory, taskCanvases.length, + mobilePluginPanelsAvailable, t, ], ); @@ -211,6 +237,9 @@ export function SessionMobileBottomNav({ showPromptHistory={showPromptHistory} taskCanvases={taskCanvases} onOpenCanvas={onOpenCanvas} + taskId={taskId} + sessionId={sessionId} + sessionKind={sessionKind} /> ); diff --git a/apps/web/components/task/mobile/session-mobile-layout.tsx b/apps/web/components/task/mobile/session-mobile-layout.tsx index 7541dae131..7082853816 100644 --- a/apps/web/components/task/mobile/session-mobile-layout.tsx +++ b/apps/web/components/task/mobile/session-mobile-layout.tsx @@ -27,7 +27,11 @@ import type { MobileSessionPanel } from "@/lib/state/slices/ui/types"; import type { OpenFileTab } from "@/lib/types/backend"; import { useAppStore } from "@/components/state-provider"; import { useNormalizedTaskReviewsState } from "../review-panel-provider"; -import type { ReviewItemSummary } from "@/lib/plugins/types"; +import type { + PluginOpenMessageResult, + PluginSessionKind, + ReviewItemSummary, +} from "@/lib/plugins/types"; import type { Canvas } from "@/lib/api/domains/canvas-api"; import { reviewItemId, useReviewItemSelection } from "../review-selection"; import { PluginTaskPanel } from "../plugin-task-panel"; @@ -165,7 +169,7 @@ type MobilePanelAreaProps = { handleClearSelectedDiff: () => void; handleOpenFile: (file: OpenFileTab) => void; handlePanelChangeAndClearSheet: (panel: MobileSessionPanel) => void; - onNavigateToPrompt: (messageId: string) => void; + onNavigateToPrompt: (messageId: string) => PluginOpenMessageResult; onScrollTargetConsumed?: (messageId: string) => void; mobileScrollTarget: PendingMessageScrollTarget | null; topNavHeight: string; @@ -189,6 +193,26 @@ export function terminalPaddingBottom( : `${KEYBAR_HEIGHT_PX}px`; } +function MobileTerminalPanel({ + sessionId, + paddingBottom, +}: { + sessionId: string | null; + paddingBottom: string; +}) { + return ( +
+ + + +
+ ); +} + export function MobilePanelArea({ currentMobilePanel, activeTaskId, @@ -211,7 +235,6 @@ export function MobilePanelArea({ onSelectReview, }: MobilePanelAreaProps) { const { keyboardOpen, bottomOffset } = useVisualViewportOffset(); - const terminalPadding = terminalPaddingBottom(keyboardOpen, bottomOffset, bottomNavHeight); return (
)} {currentMobilePanel === "terminal" && ( -
- - - -
+ )} - +
); } @@ -308,7 +329,13 @@ function MobilePlanPanel({ * plugin-contributed nav entries. A non-plugin id (the common case) is a * no-op, so this stays a single trailing branch instead of one per plugin. */ -function MobilePluginPanel({ currentMobilePanel }: { currentMobilePanel: MobileSessionPanel }) { +function MobilePluginPanel({ + currentMobilePanel, + onOpenMessage, +}: { + currentMobilePanel: MobileSessionPanel; + onOpenMessage: (messageId: string) => PluginOpenMessageResult; +}) { const parsed = parsePluginPanelId(currentMobilePanel); if (!parsed) return null; return ( @@ -318,6 +345,7 @@ function MobilePluginPanel({ currentMobilePanel }: { currentMobilePanel: MobileS panelKey={parsed.panelKey} panelId={currentMobilePanel} presentation="mobile" + onOpenMessage={onOpenMessage} /> ); @@ -529,6 +557,8 @@ export function useMobilePanelHandlers({ type SessionMobileFooterProps = { sessionId: string | null; + taskId: string | null; + sessionKind: "managed" | "passthrough" | null; activePanel: MobileSessionPanel; onPanelChange: (panel: MobileSessionPanel) => void; showPromptHistory: boolean; @@ -544,6 +574,8 @@ type SessionMobileFooterProps = { function SessionMobileFooter({ sessionId, + taskId, + sessionKind, activePanel, onPanelChange, showPromptHistory, @@ -575,6 +607,9 @@ function SessionMobileFooter({ connectionIssueSeverity={connectionIssueSeverity} taskCanvases={taskCanvases} onOpenCanvas={onOpenCanvas} + taskId={taskId} + sessionId={sessionId} + sessionKind={sessionKind} /> ); @@ -637,18 +672,22 @@ export const SessionMobileLayout = memo(function SessionMobileLayout( [handlePanelChangeAndClearSheet], ); const handleNavigateToPrompt = useCallback( - (messageId: string) => { - if (!effectiveSessionId) return; + (messageId: string): PluginOpenMessageResult => { + const normalizedMessageId = messageId.trim(); + if (isPassthroughMode || !effectiveSessionId || normalizedMessageId === "") { + return { status: "unavailable" }; + } const token = ++mobileScrollTokenRef.current; setMobileScrollTarget({ sessionId: effectiveSessionId, - messageId, + messageId: normalizedMessageId, token, hostPanelId: "mobile-chat", }); handlePanelChangeAndClearSheet("chat"); + return { status: "accepted" }; }, - [effectiveSessionId, handlePanelChangeAndClearSheet], + [effectiveSessionId, handlePanelChangeAndClearSheet, isPassthroughMode], ); const handleMobileScrollTargetConsumed = useCallback(() => { setMobileScrollTarget(null); @@ -666,6 +705,10 @@ export const SessionMobileLayout = memo(function SessionMobileLayout( setMobileScrollTarget(null); } }, [currentMobilePanel, effectiveMobilePanel]); + let sessionKind: PluginSessionKind = null; + if (effectiveSessionId) { + sessionKind = isPassthroughMode ? "passthrough" : "managed"; + } return (
{ + if (title && props.api.title !== title) props.api.setTitle(title); + }, [props.api, title]); return (
diff --git a/apps/web/components/task/plugin-task-panel.test.tsx b/apps/web/components/task/plugin-task-panel.test.tsx index 40aa763725..d465aa9a6e 100644 --- a/apps/web/components/task/plugin-task-panel.test.tsx +++ b/apps/web/components/task/plugin-task-panel.test.tsx @@ -8,6 +8,11 @@ vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (state: Record) => unknown) => selector({ tasks: { activeTaskId: mockActiveTaskId, activeSessionId: mockActiveSessionId }, + taskSessions: { + items: { + session_1: { id: "session_1", is_passthrough: false, name: "Session" }, + }, + }, }), })); @@ -53,6 +58,148 @@ describe("PluginTaskPanel", () => { ); }); + it("passes session kind and a presentation-scoped navigation facade", () => { + const onOpenMessage = vi.fn(() => ({ status: "accepted" as const })); + function Notes(props: { + sessionKind: string | null; + conversation: { openMessage: (messageId: string) => { status: string } }; + }) { + return ( + + ); + } + pluginRegistry + .forPlugin("plugin-a") + .registerTaskPanel({ id: "notes", title: "Notes", Component: Notes, mobileEnabled: true }); + + render( + , + ); + + screen.getByRole("button", { name: "managed" }).click(); + expect(onOpenMessage).toHaveBeenCalledWith("message-1"); + }); + + it("reports unavailable for invalid or rejected mobile navigation", () => { + let emptyResult = ""; + let rejectedResult = ""; + const onOpenMessage = vi.fn(() => ({ status: "unavailable" as const })); + function Notes(props: { + conversation: { openMessage: (messageId: string) => { status: string } }; + }) { + return ( + <> + + + + ); + } + pluginRegistry + .forPlugin("plugin-a") + .registerTaskPanel({ id: "notes", title: "Notes", Component: Notes, mobileEnabled: true }); + + render( + , + ); + + screen.getByRole("button", { name: "empty" }).click(); + expect(emptyResult).toBe("unavailable"); + expect(onOpenMessage).not.toHaveBeenCalled(); + + screen.getByRole("button", { name: "rejected" }).click(); + expect(rejectedResult).toBe("unavailable"); + expect(onOpenMessage).toHaveBeenCalledWith("message-1"); + }); +}); +describe("PluginTaskPanel session lifecycle", () => { + it("keeps the panel bound to a deleted active session", () => { + function Notes(props: { sessionId: string | null }) { + return
{props.sessionId}
; + } + pluginRegistry + .forPlugin("plugin-a") + .registerTaskPanel({ id: "notes", title: "Notes", Component: Notes }); + + const view = render( + , + ); + expect(screen.getByTestId("notes-session").textContent).toBe("session_1"); + + mockActiveSessionId = null; + view.rerender( + , + ); + expect(screen.getByTestId("notes-session").textContent).toBe("session_1"); + }); +}); + +describe("PluginTaskPanel failure containment", () => { + it("fails closed when a managed visibility predicate throws", () => { + function Notes() { + return
secret panel
; + } + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + pluginRegistry.forPlugin("plugin-a").registerTaskPanel({ + id: "notes", + title: "Notes", + Component: Notes, + visible: () => { + throw new Error("predicate failed"); + }, + }); + + render( + , + ); + + expect(screen.queryByText("secret panel")).toBeNull(); + expect(screen.getByText("This panel is no longer available.")).not.toBeNull(); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining("plugin-a:notes"), + expect.any(Error), + ); + consoleError.mockRestore(); + }); + it("renders a not-available fallback when the plugin is no longer registered (AC5)", () => { render( { console.error = originalConsoleError; }); }); +describe("PluginTaskPanel lease", () => { + it("revokes a panel navigation lease when visibility changes", () => { + let visible = true; + let openMessage: ((messageId: string) => { status: string }) | undefined; + const onOpenMessage = vi.fn(() => ({ status: "accepted" as const })); + function Notes(props: { + conversation: { openMessage: (messageId: string) => { status: string } }; + }) { + openMessage = props.conversation.openMessage; + return
visible panel
; + } + pluginRegistry.forPlugin("plugin-a").registerTaskPanel({ + id: "notes", + title: "Notes", + Component: Notes, + mobileEnabled: true, + visible: () => visible, + }); + + const view = render( + , + ); + const initialOpenMessage = openMessage; + expect(initialOpenMessage?.("message-1").status).toBe("accepted"); + expect(onOpenMessage).toHaveBeenCalledWith("message-1"); + + visible = false; + view.rerender( + , + ); + + expect(screen.getByText("This panel is no longer available.")).not.toBeNull(); + expect(initialOpenMessage?.("message-2").status).toBe("unavailable"); + expect(onOpenMessage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/components/task/plugin-task-panel.tsx b/apps/web/components/task/plugin-task-panel.tsx index acb9f718d4..f7161f9cd7 100644 --- a/apps/web/components/task/plugin-task-panel.tsx +++ b/apps/web/components/task/plugin-task-panel.tsx @@ -1,10 +1,22 @@ "use client"; +import { useEffect, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { useAppStore } from "@/components/state-provider"; import { PluginErrorBoundary } from "@/components/plugins/plugin-error-boundary"; +import { + PluginConversationScopeProvider, + pluginConversationApi, +} from "@/lib/plugins/conversation-host"; +import type { PluginTaskPanelRegistration } from "@/lib/plugins/registry-registration-types"; import { pluginRegistry, usePluginRegistry } from "@/lib/plugins/registry"; -import type { PluginPresentation } from "@/lib/plugins/types"; +import type { + PluginOpenMessageResult, + PluginPresentation, + PluginTaskPanelContext, + PluginSessionKind, +} from "@/lib/plugins/types"; +import { useDockviewStore } from "@/lib/state/dockview-store"; export interface PluginTaskPanelContainerProps { pluginId: string; @@ -12,6 +24,7 @@ export interface PluginTaskPanelContainerProps { /** Full dockview/mobile panel id, e.g. `plugin::`. */ panelId: string; presentation: PluginPresentation; + onOpenMessage?: (messageId: string) => PluginOpenMessageResult; } function PluginTaskPanelUnavailable() { @@ -32,42 +45,120 @@ function PluginTaskPanelFailed() { ); } -/** - * Resolves `{ pluginId, panelKey }` to its current `TaskPanelRegistration` - * and renders the plugin's `Component` with `PluginTaskPanelProps`, wrapped - * in a `PluginErrorBoundary` (AC6) so a throw inside the plugin's render - * can't take down the surrounding dockview/mobile layout. Renders a - * "no longer available" fallback — not a throw — when the plugin was - * disabled/uninstalled after the panel was opened, or the layout it was - * restored from references a panel of a plugin that is no longer installed - * (AC5). - */ +export function registrationIsVisible( + registration: PluginTaskPanelRegistration, + context: PluginTaskPanelContext, +): boolean { + if (!registration.visible) return true; + try { + return registration.visible(context); + } catch (error) { + console.error( + `[plugins] visibility predicate for "${registration.pluginId}:${registration.id}" threw`, + error, + ); + return false; + } +} + +type RememberedSession = { taskId: string | null; sessionId: string }; + +function useRememberedPanelSessionId( + taskId: string | null, + activeSessionId: string | null, +): string | null { + const rememberedSession = useRef(null); + if (activeSessionId) { + rememberedSession.current = { taskId, sessionId: activeSessionId }; + } + return ( + activeSessionId ?? + (rememberedSession.current?.taskId === taskId ? rememberedSession.current.sessionId : null) + ); +} + +/** Resolves and contains one plugin-contributed task panel. */ export function PluginTaskPanel({ pluginId, panelKey, panelId, presentation, + onOpenMessage, }: PluginTaskPanelContainerProps) { - // Re-render when the registry changes (plugin disable/uninstall/reload) so - // this panel picks up the fallback the instant its registration disappears. usePluginRegistry(); + const { t } = useTranslation(); const taskId = useAppStore((state) => state.tasks.activeTaskId); - const sessionId = useAppStore((state) => state.tasks.activeSessionId); + const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); + // Keep the deleted session identity until the host unmounts this panel. + const sessionId = useRememberedPanelSessionId(taskId, activeSessionId); + const session = useAppStore((state) => + sessionId ? state.taskSessions?.items?.[sessionId] : undefined, + ); const registration = pluginRegistry.getTaskPanel(pluginId, panelKey); + const generation = pluginRegistry.getPluginLifecycle(pluginId)?.generation ?? 0; + let sessionKind: PluginSessionKind = null; + if (sessionId) { + sessionKind = session?.is_passthrough ? "passthrough" : "managed"; + } + const context: PluginTaskPanelContext = { + taskId: taskId ?? "", + sessionId, + sessionKind, + presentation, + }; + const registrationVisible = registration ? registrationIsVisible(registration, context) : false; - if (!registration || !taskId) { + const lease = useMemo( + () => ({ active: true }), + [generation, panelId, panelKey, pluginId, presentation, registrationVisible, sessionId, taskId], + ); + useEffect( + () => () => { + lease.active = false; + }, + [lease], + ); + const conversation = useMemo( + () => ({ + history: pluginConversationApi, + openMessage(messageId: string): PluginOpenMessageResult { + if (!lease.active || !sessionId || messageId.trim() === "") { + return { status: "unavailable" }; + } + if (presentation === "mobile") { + return onOpenMessage?.(messageId) ?? { status: "unavailable" }; + } + const queued = useDockviewStore + .getState() + .scrollTranscriptToMessage(sessionId, messageId, session?.name || t("task:chat")); + return { status: queued ? "accepted" : "unavailable" }; + }, + }), + [lease, onOpenMessage, presentation, session?.name, sessionId, t], + ); + + if (!registration || !taskId || !registrationVisible) { return ; } - const { Component } = registration; return ( }> - + > + + ); } diff --git a/apps/web/components/task/task-chat-panel.scroll-target.test.tsx b/apps/web/components/task/task-chat-panel.scroll-target.test.tsx index 95a84b4349..249486d774 100644 --- a/apps/web/components/task/task-chat-panel.scroll-target.test.tsx +++ b/apps/web/components/task/task-chat-panel.scroll-target.test.tsx @@ -57,12 +57,31 @@ vi.mock("@/hooks/domains/session/load-message-window", () => ({ })); import { + isMessageRowRendered, usePendingMessageScroll, useScrollTargetConsumption, type PendingMessageScrollTarget, } from "./task-chat-panel"; +import type { RenderItem } from "@/hooks/use-processed-messages"; import { loadMessageWindowAround } from "@/hooks/domains/session/load-message-window"; +describe("isMessageRowRendered", () => { + it("only treats direct message items as scrollable rows", () => { + const items = [ + { + type: "turn_group", + id: "group-1", + turnId: "turn-1", + messages: [{ id: "grouped-prompt" }], + }, + { type: "message", message: { id: "direct-prompt" } }, + ] as unknown as RenderItem[]; + + expect(isMessageRowRendered(items, "grouped-prompt")).toBe(false); + expect(isMessageRowRendered(items, "direct-prompt")).toBe(true); + }); +}); + let pendingFrames: Array<(() => void) | undefined> = []; /** Runs two rounds of pending requestAnimationFrame callbacks inside act. */ diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index ed5e930311..8fc4260c91 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -44,6 +44,8 @@ import { useLazyLoadMessages } from "@/hooks/use-lazy-load-messages"; import { findUnreadDividerItemId, lastRenderedMessageId } from "@/lib/session-unread-divider"; import { useSessionReadTracking } from "./chat/use-session-read-tracking"; import { useDrainOlderMessages } from "@/components/task/chat/use-drain-older-messages"; +import type { RenderItem } from "@/hooks/use-processed-messages"; + import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { getSessionWorkspacePath } from "@/lib/session-workspace-path"; import type { AppState } from "@/lib/state/store"; @@ -77,6 +79,10 @@ export type PendingMessageScrollTarget = { token: number; hostPanelId: string; }; +/** Reports whether a target has a dedicated DOM row in the transcript. */ +export function isMessageRowRendered(items: readonly RenderItem[], messageId: string): boolean { + return items.some((item) => item.type === "message" && item.message.id === messageId); +} /** Scrolls a non-Dockview host target after the message row becomes rendered. */ type PendingMessageScrollOptions = { @@ -1063,8 +1069,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({ messageListRef, isInitialMessagesLoading, targetRendered: Boolean( - dockviewTargetMessageId && - allMessages.some((message) => message.id === dockviewTargetMessageId), + dockviewTargetMessageId && isMessageRowRendered(groupedItems, dockviewTargetMessageId), ), renderedMessageCount: allMessages.length, }); @@ -1229,6 +1234,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({ [{value,label,color}]) so a spec can drive multi-value + // membership, colors, and the untagged fallback against task ids the + // fixture cannot know ahead of time. `window.__e2eFacetNotify()` fires + // the subscription so the reactive re-read path is exercised for real. + var facetListeners = new Set(); + + function emit() { + listeners.forEach(function (fn) { + fn(moduleCount); + }); + } + + function incrementCount() { + moduleCount += 1; + emit(); + } + + function useCounter(React) { + var state = React.useState(moduleCount); + var count = state[0]; + var setCount = state[1]; + React.useEffect(function () { + setCount(moduleCount); + listeners.add(setCount); + return function () { + listeners.delete(setCount); + }; + }, []); + return count; + } + + var PROVIDER_ID = "fixture-source-control"; + var PULL_REQUEST_URL = + "https://bitbucket.example.test/projects/TEAM/repos/fixture/pull-requests/42"; + var REPOSITORY_URL = "https://bitbucket.example.test/scm/TEAM/fixture.git"; + + function fixtureRepository() { + return { + id: "fixture-repository", + repositoryId: "fixture-repository", + owner: "TEAM", + ownerOrProject: "TEAM", + name: "fixture", + repositoryName: "fixture", + fullName: "TEAM/fixture", + url: REPOSITORY_URL, + cloneUrl: REPOSITORY_URL, + providerHost: "bitbucket.example.test", + defaultBranch: "main", + private: true, + }; + } + + function abortableRefresh(signal) { + return new Promise(function (_resolve, reject) { + if (signal.aborted) { + reject(new Error("fixture review refresh aborted")); + return; + } + signal.addEventListener( + "abort", + function () { + reject(new Error("fixture review refresh aborted")); + }, + { once: true }, + ); + }); + } + + window.registerKandevPlugin("kandev-plugin-e2e", { + initialize: function (registry, host) { + var React = host.React; + var jsx = host.jsx; + var ui = host.ui; + + // Reads host.theme once on mount, then tracks it purely through + // host.onThemeChange — so the readout only stays correct if the + // subscription actually fires. Also counts notifications, proving the + // host does not re-notify on unrelated class churn. + function useHostTheme() { + var state = React.useState(function () { + return { theme: host.theme, changes: 0 }; + }); + var value = state[0]; + var setValue = state[1]; + React.useEffect(function () { + return host.onThemeChange(function (next) { + setValue(function (prev) { + return { theme: next, changes: prev.changes + 1 }; + }); + }); + }, []); + return value; + } + + function PluginPage() { + var count = useCounter(React); + var connectionState = React.useState("Not checked"); + var connection = connectionState[0]; + var setConnection = connectionState[1]; + var themeState = useHostTheme(); + function checkConnection() { + host.api + .invokeAction("connection-status", { + workspaceId: host.store.getState().workspaces.activeId || undefined, + }) + .then(function (result) { + setConnection( + result.connected ? "Connected" : result.error || "Connection unavailable", + ); + }) + .catch(function (error) { + setConnection(error instanceof Error ? error.message : "Connection unavailable"); + }); + } + return jsx( + "div", + { id: "hello-plugin-page-root" }, + jsx("h1", { id: "hello-plugin-page" }, "Hello E2E"), + jsx("span", { id: "hello-task-counter" }, String(count)), + jsx( + "button", + { + id: "fixture-connection-status", + "data-testid": "fixture-connection-status", + type: "button", + onClick: checkConnection, + }, + "Check Bitbucket connection", + ), + jsx( + "span", + { id: "fixture-connection-result", "data-testid": "fixture-connection-result" }, + connection, + ), + jsx( + "span", + { + "data-testid": "hello-theme-readout", + "data-theme-changes": String(themeState.changes), + }, + themeState.theme, + ), + // host.toast is a per-plugin Proxy over sonner; unit tests only ever + // see it against a mocked sonner, so this button is how an e2e + // proves a real toast renders and that .error does NOT file a + // frontend-error report. + jsx( + "button", + { + "data-testid": "hello-toast-error", + onClick: function () { + host.toast.error("Plugin toast error"); + }, + }, + "toast error", + ), + ); + } + + function FixtureReviewPanel(props) { + return jsx( + "section", + { + "data-testid": "fixture-review-panel-" + props.presentation, + "data-review-key": props.reviewKey, + }, + jsx( + "h2", + null, + "Bitbucket pull request #" + props.reviewKey.replace("pull-request-", ""), + ), + jsx("p", null, "Provider-neutral fixture review panel"), + ); + } + + function FixtureReviewSelector() { + return jsx("span", { "data-testid": "fixture-review-selector" }, "Bitbucket"); + } + + function openLinkResult(context) { + host.openTaskLinkDialog({ + title: "Link Bitbucket pull request", + description: "Use a Bitbucket pull request URL for this task.", + inputLabel: "Pull request", + placeholder: PULL_REQUEST_URL, + emptyError: "Enter a Bitbucket pull request URL.", + failureMessage: "Failed to link Bitbucket pull request.", + successMessage: "Bitbucket pull request linked", + inputTestId: "fixture-link-pull-request-input", + errorTestId: "fixture-link-pull-request-error", + submitTestId: "fixture-link-pull-request-submit", + onSubmit: function (reference) { + return host.api + .invokeAction("link-pull-request", { + workspaceId: context.workspaceId, + taskId: context.taskId, + body: { pullRequestUrl: reference }, + }) + .then(function (result) { + if (!result.linked) { + throw new Error(result.error || "Connection unavailable"); + } + }); + }, + }); + return Promise.resolve(); + } + + function SidebarSlot() { + return jsx("div", { id: "hello-sidebar" }, "Hello E2E sidebar"); + } + + function MainTopBarSlot(props) { + var slotProps = props.slotProps || {}; + var label = "Hello " + slotProps.currentPage; + return jsx( + ui.Button, + { + id: "hello-main-top-bar", + variant: "outline", + size: "icon-sm", + "aria-label": label, + }, + jsx( + "svg", + { + className: "h-4 w-4", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "aria-hidden": "true", + }, + jsx("path", { d: "M5 12h14M12 5l7 7l-7 7" }), + ), + jsx("span", { className: "sr-only" }, label), + ); + } + + // Debounce delay for the Notes panel's autosave — short, so e2e specs + // don't need to wait long for a write to reach host.storage. + var NOTES_SAVE_DEBOUNCE_MS = 150; + + function useNotesValue(taskId, panelId) { + var state = React.useState(""); + var value = state[0]; + var setValue = state[1]; + var loadedState = React.useState(false); + var loaded = loadedState[0]; + var setLoaded = loadedState[1]; + + React.useEffect( + function () { + var cancelled = false; + // Each notification (subscribe's refresh callback, plus the initial + // call) starts its own host.storage.get. Two in flight at once can + // resolve out of order, so `cancelled` alone (unmount-only) isn't + // enough — an older read landing after a newer one would overwrite + // it. Only the read matching the current generation may commit. + var refreshGeneration = 0; + function refresh() { + var generation = ++refreshGeneration; + host.storage.get("task", taskId, "note").then( + function (entry) { + if (cancelled || generation !== refreshGeneration) return; + setValue(entry ? entry.value : ""); + setLoaded(true); + }, + function () { + if (cancelled || generation !== refreshGeneration) return; + setLoaded(true); + }, + ); + } + refresh(); + // Scope echo suppression to this panel instance (panelId) rather + // than the host's shared tab-wide default writerId. The kanban + // "Enhance notes" action below writes this same note using that + // shared default (it has no ongoing subscription of its own to + // protect) — if this panel used the same default, the action's + // write would look like this panel's own echo and get silently + // dropped instead of refreshing the panel. + var unsubscribe = host.storage.subscribe( + { scope: "task", scopeId: taskId, key: "note", writerId: panelId }, + refresh, + ); + return function () { + cancelled = true; + unsubscribe(); + }; + }, + [taskId, panelId], + ); + + return [value, setValue, loaded]; + } + + function NotesPanel(props) { + var taskId = props.taskId; + var panelId = props.panelId; + var notesValue = useNotesValue(taskId, panelId); + var value = notesValue[0]; + var setValue = notesValue[1]; + var loaded = notesValue[2]; + var debounceRef = React.useRef(null); + + function handleChange(e) { + var next = e.target.value; + setValue(next); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(function () { + host.storage + .set("task", taskId, "note", next, { writerId: panelId }) + .catch(function () { + // surface the failed autosave instead of dropping it silently + }); + }, NOTES_SAVE_DEBOUNCE_MS); + } + + if (!loaded) { + return jsx("div", { "data-testid": "e2e-notes-panel-loading" }, "Loading notes…"); + } + return jsx("textarea", { + "data-testid": "e2e-notes-panel", + "data-presentation": props.presentation, + value: value, + onChange: handleChange, + }); + } + + function promptDuration(startedAt, completedAt, translated) { + var started = Date.parse(startedAt); + var completed = Date.parse(completedAt); + if (Number.isNaN(started) || Number.isNaN(completed)) return ""; + var seconds = Math.max(0, Math.floor((completed - started) / 1000)); + var hours = Math.floor(seconds / 3600); + var minutes = Math.floor((seconds % 3600) / 60); + var remaining = seconds % 60; + if (hours > 0) { + return ( + hours + + translated.t("durationHours") + + " " + + minutes + + translated.t("durationMinutes") + + " " + + remaining + + translated.t("durationSeconds") + ); + } + if (minutes > 0) { + return ( + minutes + + translated.t("durationMinutes") + + " " + + remaining + + translated.t("durationSeconds") + ); + } + return remaining + translated.t("durationSeconds"); + } + + function PromptHistoryPluginRow(props) { + var message = props.message; + var turn = props.turn; + var translated = props.translated; + var duration = + turn && turn.completedAt + ? promptDuration(turn.startedAt, turn.completedAt, translated) + : ""; + var favorite = props.conversation.history.useMessageFavorite(props.sessionId, message.id); + var isExpanded = props.expanded === message.id; + return jsx( + "article", + { + "data-testid": "fixture-prompt-history-row", + "data-message-id": message.id, + "data-favorite": favorite ? "true" : "false", + className: "border-b p-3", + }, + jsx( + "button", + { + type: "button", + className: "min-h-11 w-full text-left", + onClick: function () { + props.setExpanded(isExpanded ? null : message.id); + }, + }, + translated.t("prompt", { values: { index: message.promptIndex || 0 } }), + message.senderTaskId ? " · " + translated.t("sentByAgent") : "", + duration + ? jsx("span", { "data-testid": "fixture-prompt-history-duration" }, " · " + duration) + : "", + ), + isExpanded + ? jsx( + "div", + { "data-testid": "fixture-prompt-history-detail" }, + jsx(ui.PromptMentionText, { text: message.content, interactive: true }), + jsx( + "button", + { + type: "button", + className: "min-h-11", + onClick: function () { + props.conversation.openMessage(message.id); + }, + }, + translated.t("openPrompt"), + ), + ) + : null, + ); + } + + function PromptHistoryPluginPanel(props) { + var translated = host.i18n.useTranslation(); + var messages = props.conversation.history.useSessionMessages({ + sessionId: props.sessionId, + taskId: props.taskId, + authorTypes: ["user"], + sort: "desc", + pageSize: 2, + }); + var turns = props.conversation.history.useSessionTurns(props.sessionId, props.taskId); + var expandedState = React.useState(null); + var expanded = expandedState[0]; + var setExpanded = expandedState[1]; + + if (messages.loading || turns.loading) { + return jsx( + "div", + { "data-testid": "fixture-prompt-history-loading" }, + translated.t("loading"), + ); + } + if (messages.error || turns.error) { + return jsx( + "button", + { + type: "button", + "data-testid": "fixture-prompt-history-error", + onClick: function () { + messages.retry(); + turns.retry(); + }, + }, + translated.t("retry"), + ); + } + if (messages.messages.length === 0) { + return jsx( + "div", + { "data-testid": "fixture-prompt-history-empty" }, + translated.t("empty"), + ); + } + var rows = messages.messages.map(function (message) { + var turn = turns.turns.find(function (candidate) { + return candidate.id === message.turnId; + }); + return jsx(PromptHistoryPluginRow, { + key: message.id, + message: message, + turn: turn, + translated: translated, + conversation: props.conversation, + sessionId: props.sessionId, + expanded: expanded, + setExpanded: setExpanded, + }); + }); + if (messages.hasMore) { + rows.push( + jsx( + "button", + { + key: "load-more", + type: "button", + className: "min-h-11 w-full", + "data-testid": "fixture-prompt-history-load-more", + disabled: messages.loadingMore, + onClick: function () { + messages.loadMore(); + }, + }, + translated.t("loadOlder"), + ), + ); + } + return jsx( + "div", + { + "data-testid": "fixture-prompt-history-panel", + "data-removed": messages.removed ? "true" : "false", + "data-presentation": props.presentation, + className: "h-full min-w-0 overflow-y-auto overflow-x-hidden", + }, + rows, + ); + } + + function CardIndicator(props) { + var slotProps = props.slotProps || {}; + return jsx( + "span", + { "data-testid": "e2e-card-indicator", "data-task-id": slotProps.taskId }, + "N", + ); + } + + function CardTags(props) { + var slotProps = props.slotProps || {}; + return jsx( + "span", + { "data-testid": "e2e-card-tags", "data-task-id": slotProps.taskId }, + "tags", + ); + } + + function RowMetadata(props) { + var slotProps = props.slotProps || {}; + return jsx( + "span", + { + "data-testid": "e2e-row-metadata", + "data-task-id": slotProps.taskId, + "data-surface": slotProps.surface, + }, + "fixture metadata", + ); + } + + function WorkspaceActionsSlot(props) { + var slotProps = props.slotProps || {}; + return jsx( + "button", + { + type: "button", + "data-testid": "e2e-sidebar-workspace-actions", + "data-workspace-id": slotProps.workspaceId, + "data-workspace-label": slotProps.workspaceLabel || "", + "data-presentation": slotProps.presentation || "unknown", + "aria-label": "Fixture workspace action", + className: + slotProps.presentation === "mobile" + ? "flex h-11 w-11 cursor-pointer items-center justify-center rounded-md border" + : "flex h-6 w-6 cursor-pointer items-center justify-center rounded-md border", + onClick: function (event) { + event.currentTarget.setAttribute("data-clicked", "true"); + }, + }, + "W", + ); + } + + function StatusSlot(props) { + var slotProps = props.slotProps || {}; + var id = slotProps.placement === "left" ? "hello-status-left" : "hello-status-right"; + return jsx( + "span", + { id: id }, + "Hello status " + + String(slotProps.presentation || "unknown") + + " " + + String(slotProps.activeTaskId || "no-task"), + ); + } + + // Drives PluginComposerCapability through native composers. Capturing + // the capability and using it after later renders mirrors a real voice + // integration whose recording completes asynchronously. + var COMPOSER_BUTTON_STYLE = { + fontSize: "9px", + lineHeight: "1", + padding: "2px 3px", + minWidth: "16px", + }; + + function ComposerAction(props) { + var slotProps = props.slotProps || {}; + var composer = slotProps.composer; + var statusState = React.useState(""); + var status = statusState[0]; + var setStatus = statusState[1]; + var capturedRef = React.useRef(null); + + function record(result) { + setStatus(result && result.status ? result.status : String(result)); + } + + return jsx( + "span", + { + "data-testid": "e2e-composer-action", + "data-surface": String(slotProps.surface), + "data-presentation": String(slotProps.presentation), + "data-task-id": String(slotProps.taskId || ""), + "data-session-id": String(slotProps.activeSessionId || ""), + "data-disabled": String(Boolean(slotProps.disabled)), + "data-submittable": String(Boolean(slotProps.submittable)), + "data-status": status, + }, + jsx( + "button", + { + type: "button", + "data-testid": "e2e-composer-insert", + style: COMPOSER_BUTTON_STYLE, + onClick: function () { + record(composer.insertText("DICTATED")); + }, + }, + "insert", + ), + jsx( + "button", + { + type: "button", + "data-testid": "e2e-composer-capture", + style: COMPOSER_BUTTON_STYLE, + onClick: function () { + capturedRef.current = composer; + setStatus("captured"); + }, + }, + "capture", + ), + jsx( + "button", + { + type: "button", + "data-testid": "e2e-composer-insert-captured", + style: COMPOSER_BUTTON_STYLE, + onClick: function () { + record( + capturedRef.current + ? capturedRef.current.insertText("DICTATED") + : { status: "not-captured" }, + ); + }, + }, + "insert captured", + ), + jsx( + "button", + { + type: "button", + "data-testid": "e2e-composer-submit", + style: COMPOSER_BUTTON_STYLE, + onClick: function () { + var target = capturedRef.current || composer; + target.submit().then(record); + }, + }, + "submit", + ), + jsx( + "button", + { + type: "button", + "data-testid": "e2e-composer-focus", + style: COMPOSER_BUTTON_STYLE, + onClick: function () { + record(composer.focus()); + }, + }, + "focus", + ), + ); + } + + function FixtureBitbucketIcon(props) { + return jsx( + "svg", + { + className: props.className, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "aria-hidden": props["aria-hidden"] || true, + "data-testid": "fixture-bitbucket-icon", + }, + jsx("path", { d: "M4 5h16l-2.5 14h-11z" }), + jsx("path", { d: "M9 9h6l-1 6h-4z" }), + ); + } + + registry.registerNavItem({ + id: "e2e-hello", + label: "Hello E2E", + path: "/plugins/e2e-hello", + section: "main", + }); + registry.registerNavItem({ + id: "e2e-insights-tools", + label: "E2E Insights Tools", + path: "/plugins/e2e-hello", + section: "sidebar-footer", + }); + // Three more sidebar-footer items so this one plugin install alone + // produces P = 4 (budget MAX_INLINE_PLUGIN_FOOTER_ITEMS = 3, plus one + // over-budget item) — enough to drive the desktop footer's overflow + // trigger and menu with the real Radix DropdownMenu in a browser (see + // plugins.spec.ts's overflow test). The budget counts destinations, + // not distinct plugins, so one plugin registering 4 items exercises + // the same partition as 4 plugins registering 1 each. Labeled + // "E2E Overflow Item N" rather than a numbered suffix of the first + // item's own label ("E2E Insights Tools") so Playwright's default + // substring name matching can't accidentally match more than one of + // these from an existing test written against the first item's label. + registry.registerNavItem({ + id: "e2e-insights-tools-2", + label: "E2E Overflow Item 2", + path: "/plugins/e2e-hello", + section: "sidebar-footer", + }); + registry.registerNavItem({ + id: "e2e-insights-tools-3", + label: "E2E Overflow Item 3", + path: "/plugins/e2e-hello", + section: "sidebar-footer", + }); + registry.registerNavItem({ + id: "e2e-insights-tools-4", + label: "E2E Overflow Item 4", + path: "/plugins/e2e-hello", + section: "sidebar-footer", + }); + registry.registerRoute("/plugins/e2e-hello", PluginPage); + registry.registerComponent("task-sidebar", SidebarSlot); + registry.registerComponent("main-top-bar", MainTopBarSlot); + registry.registerComponent("app-status-bar-left", StatusSlot); + registry.registerComponent("app-status-bar-right", StatusSlot); + registry.registerWsHandler("task.created", function () { + incrementCount(); + }); + + registry.registerRepositoryProvider({ + id: PROVIDER_ID, + label: "Bitbucket", + icon: FixtureBitbucketIcon, + listRepositories: function () { + return Promise.resolve([fixtureRepository()]); + }, + matchesURL: function (url) { + if (typeof url !== "string") return false; + try { + return new URL(url).hostname === "bitbucket.example.test"; + } catch (_error) { + return false; + } + }, + listBranches: function (_context) { + return Promise.resolve([{ name: "main" }, { name: "feature/provider-contract" }]); + }, + inspectURL: function (_context) { + return Promise.resolve({ + providerId: PROVIDER_ID, + providerHost: "bitbucket.example.test", + ownerOrProject: "TEAM", + repositoryId: "fixture-repository", + repositoryName: "fixture", + cloneUrl: REPOSITORY_URL, + defaultBranch: "main", + baseBranch: "main", + headBranch: "feature/provider-contract", + pullRequest: { number: 42, title: "Provider-neutral contract" }, + }); + }, + }); + + registry.registerTaskAction({ + id: "link-bitbucket-pull-request", + label: "Bitbucket Pull Request", + icon: FixtureBitbucketIcon, + placement: "link", + group: "Link", + run: openLinkResult, + }); + + registry.registerReviewProvider({ + id: PROVIDER_ID, + label: "Bitbucket", + icon: FixtureBitbucketIcon, + changeRequestNoun: "Pull Request", + order: 50, + getSnapshot: function (taskId) { + return [ + { + providerId: PROVIDER_ID, + reviewKey: "pull-request-42", + title: "Bitbucket Pull Request #42", + url: PULL_REQUEST_URL, + connectionScope: "https://bitbucket.example.test", + repositoryId: "fixture-repository", + changeRequestNumber: 42, + state: "OPEN", + statusBadge: { label: "Open" }, + taskId: taskId, + }, + { + providerId: PROVIDER_ID, + reviewKey: "pull-request-43", + title: "Bitbucket Pull Request #43", + url: "https://bitbucket.example.test/projects/TEAM/repos/fixture/pull-requests/43", + connectionScope: "https://bitbucket.example.test", + repositoryId: "fixture-repository", + changeRequestNumber: 43, + state: "OPEN", + statusBadge: { label: "Open" }, + taskId: taskId, + }, + ]; + }, + subscribe: function () { + return function () {}; + }, + refresh: function (_taskId, signal) { + return abortableRefresh(signal); + }, + ReviewPanel: FixtureReviewPanel, + Selector: FixtureReviewSelector, + }); + + registry.registerTranslations({ + en: { + promptHistoryTitle: "Prompt history fixture", + loading: "Loading prompt history", + retry: "Retry", + empty: "No prompts yet", + prompt: "Prompt {{index}}", + sentByAgent: "Sent by agent", + openPrompt: "Open prompt", + loadOlder: "Load older prompts", + durationSeconds: "s", + durationMinutes: "m", + durationHours: "h", + }, + pseudo: { + promptHistoryTitle: "Ƥřǿɱƥŧ ħīşŧǿřẏ ƒīẋŧŭřḗ", + loading: "Ŀǿȧḓīƞɠ ƥřǿɱƥŧ ħīşŧǿřẏ", + retry: "Řḗŧřẏ", + empty: "Ƞǿ ƥřǿɱƥŧş ẏḗŧ", + prompt: "Ƥřǿɱƥŧ {{index}}", + sentByAgent: "Şḗƞŧ ƀẏ ȧɠḗƞŧ", + openPrompt: "Ǿƥḗƞ ƥřǿɱƥŧ", + loadOlder: "Ŀǿȧḓ ǿŀḓḗř ƥřǿɱƥŧş", + durationSeconds: "ş", + durationMinutes: "ɱ", + durationHours: "ħ", + }, + }); + registry.registerTaskPanel({ + id: "prompt-history-plugin", + title: "Prompt history fixture", + titleKey: "promptHistoryTitle", + icon: "history", + Component: PromptHistoryPluginPanel, + mobileEnabled: true, + visible: function (context) { + return context.sessionKind === "managed"; + }, + }); + + registry.registerTaskPanel({ + id: "notes", + title: "Notes", + icon: "book", + Component: NotesPanel, + mobileEnabled: true, + }); + registry.registerTaskListFacet({ + id: "fixture-tags", + label: "Fixture tag", + getValues: function (context) { + var byTask = window.__e2eFacetValues || {}; + if (byTask.__throwFor === context.taskId) throw new Error("fixture facet boom"); + return byTask[context.taskId] || []; + }, + subscribe: function (listener) { + facetListeners.add(listener); + window.__e2eFacetNotify = function () { + facetListeners.forEach(function (fn) { + fn(); + }); + }; + return function () { + facetListeners.delete(listener); + }; + }, + }); + registry.registerComponent("chat-input-actions", ComposerAction); + registry.registerComponent("task-create-input-actions", ComposerAction); + registry.registerComponent("new-session-input-actions", ComposerAction); + registry.registerComponent("task-card-indicators", CardIndicator); + registry.registerComponent("task-card-tags", CardTags); + registry.registerComponent("task-row-metadata", RowMetadata); + registry.registerComponent("sidebar-workspace-actions", WorkspaceActionsSlot); + registry.registerTaskMenuAction({ + id: "enhance-notes", + label: "Enhance notes", + group: "edit", + run: function (context) { + return host.storage + .set("task", context.taskId, "note", "Enhanced via plugin action") + .then(function () { + // Keep the received responsive context observable to the E2E + // fixture without changing the plugin-facing action contract. + return host.storage.set( + "task", + context.taskId, + "menu-presentation", + context.presentation, + ); + }); + }, + }); + registry.registerTaskMenuAction({ + id: "inspect-task-metadata", + label: "Inspect task metadata", + group: "primary", + run: function (context) { + return host.storage.set( + "task", + context.taskId, + "primary-menu-presentation", + context.presentation, + ); + }, + }); + + registry.registerKeybinding("open-demo", function () { + function DemoModalContent() { + var completedState = React.useState(false); + var completed = completedState[0]; + var setCompleted = completedState[1]; + var longRows = []; + for (var index = 0; index < 32; index += 1) { + longRows.push( + jsx( + "p", + { key: "long-modal-row-" + index }, + "Long plugin modal content row " + + String(index + 1) + + " keeps the opaque plugin surface growing beyond the viewport.", + ), + ); + } + + // The Tooltip is the point of this modal in e2e: PluginModalHost + // mounts outside AppShell, so without its own TooltipProvider a + // Tooltip here throws on render and the error boundary swallows the + // whole modal. jsdom cannot open a Radix tooltip from synthetic + // hover, so real pointer hover is only assertable here. + return jsx( + "div", + { + id: "hello-demo-modal", + "data-testid": "hello-demo-modal", + style: { display: "grid", gap: "8px" }, + }, + "Hello from the plugin modal", + jsx( + ui.Tooltip, + null, + jsx(ui.TooltipTrigger, { "data-testid": "hello-modal-tooltip-trigger" }, "hover me"), + jsx(ui.TooltipContent, null, "Tooltip inside a plugin modal"), + ), + jsx("div", { "data-testid": "hello-long-modal-content" }, longRows), + jsx( + "button", + { + type: "button", + "data-testid": "hello-long-modal-final-action", + style: { minHeight: "44px", padding: "8px 12px" }, + onClick: function () { + setCompleted(true); + }, + }, + completed ? "Plugin modal action complete" : "Complete plugin modal action", + ), + ); + } + host.openModal({ + title: "Demo Modal", + content: DemoModalContent, + size: "md", + }); + }); + }, + }); +})(); diff --git a/apps/web/e2e/global-setup.test.ts b/apps/web/e2e/global-setup.test.ts index 52649deca9..db5d97f5ed 100644 --- a/apps/web/e2e/global-setup.test.ts +++ b/apps/web/e2e/global-setup.test.ts @@ -3,7 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import globalSetup, { assertBackendArtifactsFresh, isContainerRun } from "./global-setup"; +import { assertBackendArtifactsFresh, isContainerRun, runGlobalSetup } from "./global-setup"; let backendDir: string; let binPath: string; @@ -182,6 +182,20 @@ describe("assertBackendBinaryFresh", () => { expect(() => assertBackendBinaryFresh(backendDir, [binPath], {})).not.toThrow(); }); + it("ignores generated plugin fixture UI rebuilt after the backend binary", () => { + touch( + path.join(backendDir, "internal", "task", "service.go"), + new Date("2025-01-01T00:00:00Z"), + ); + touch(binPath, new Date("2026-01-01T00:00:00Z")); + const generated = path.join(backendDir, "cmd", "plugin-fixture", "fixture-package", "ui"); + fs.mkdirSync(generated, { recursive: true }); + fs.writeFileSync(path.join(generated, "bundle.js"), "generated"); + touch(path.join(generated, "bundle.js"), new Date("2026-06-01T00:00:00Z")); + + expect(() => assertBackendBinaryFresh(backendDir, [binPath], {})).not.toThrow(); + }); + it("ignores Go coverage and compiled-test artifacts left in the source tree", () => { // `make -C apps/backend test-coverage` writes these next to go.mod. They are // gitignored build outputs, so a test run must not demand a backend rebuild. @@ -345,7 +359,7 @@ describe("globalSetup", () => { vi.stubEnv("KANDEV_E2E_SKIP_FRESHNESS", "1"); const existsSync = vi.spyOn(fs, "existsSync").mockReturnValue(true); - (globalSetup as () => void)(); + runGlobalSetup({ verifyPluginFixtureIdentity: false }); expect(existsSync).toHaveBeenCalledWith(customBackend); expect(existsSync).not.toHaveBeenCalledWith(path.join(backendDirForTest(), "bin", "kandev")); @@ -358,7 +372,9 @@ describe("globalSetup", () => { .spyOn(fs, "existsSync") .mockImplementation((artifactPath) => artifactPath !== customBackend); - expect(() => (globalSetup as () => void)()).toThrow(/KANDEV_E2E_BIN file does not exist/); + expect(() => runGlobalSetup({ verifyPluginFixtureIdentity: false })).toThrow( + /KANDEV_E2E_BIN file does not exist/, + ); expect(existsSync).toHaveBeenCalledWith(customBackend); }); @@ -376,7 +392,7 @@ describe("globalSetup", () => { vi.stubEnv(marker, value); const existsSync = vi.spyOn(fs, "existsSync").mockReturnValue(true); - (globalSetup as () => void)(); + runGlobalSetup({ verifyPluginFixtureIdentity: false }); expect(existsSync).toHaveBeenCalledWith( path.join(backendDirForTest(), "bin", "mock-agent-linux-amd64"), @@ -393,7 +409,7 @@ describe("globalSetup", () => { process.argv = [...originalArgv, "--project=containers"]; const existsSync = vi.spyOn(fs, "existsSync").mockReturnValue(true); - (globalSetup as () => void)(); + runGlobalSetup({ verifyPluginFixtureIdentity: false }); expect(existsSync).toHaveBeenCalledWith( path.join(backendDirForTest(), "bin", "mock-agent-linux-amd64"), @@ -410,7 +426,7 @@ describe("globalSetup", () => { process.argv = [...originalArgv, "--project=kubernetes-compat"]; const existsSync = vi.spyOn(fs, "existsSync").mockReturnValue(true); - (globalSetup as () => void)(); + runGlobalSetup({ verifyPluginFixtureIdentity: false }); expect(existsSync).toHaveBeenCalledWith( path.join(backendDirForTest(), "bin", "mock-agent-linux-amd64"), @@ -426,7 +442,7 @@ describe("globalSetup", () => { vi.stubEnv("KANDEV_E2E_DOCKER", ""); const existsSync = vi.spyOn(fs, "existsSync").mockReturnValue(true); - (globalSetup as () => void)(); + runGlobalSetup({ verifyPluginFixtureIdentity: false }); expect(existsSync).not.toHaveBeenCalledWith( path.join(backendDirForTest(), "bin", "mock-agent-linux-amd64"), diff --git a/apps/web/e2e/global-setup.ts b/apps/web/e2e/global-setup.ts index d45095a26c..0505a5ae99 100644 --- a/apps/web/e2e/global-setup.ts +++ b/apps/web/e2e/global-setup.ts @@ -10,7 +10,15 @@ export type BackendArtifact = { rebuildTarget: string; }; +export type GlobalSetupOptions = { + verifyPluginFixtureIdentity?: boolean; +}; + export default function globalSetup() { + runGlobalSetup(); +} + +export function runGlobalSetup(options: GlobalSetupOptions = {}): void { const customKandevBin = process.env.KANDEV_E2E_BIN; const kandevBin = customKandevBin ?? path.join(BACKEND_DIR, "bin", "kandev"); const artifacts: BackendArtifact[] = [ @@ -44,6 +52,11 @@ export default function globalSetup() { assertArtifactExists(artifact.path, artifact.rebuildTarget); } assertBackendArtifactsFresh(BACKEND_DIR, artifacts); + const pluginIdentityPath = path.join(BACKEND_DIR, ".build", "e2e-plugin-identity.json"); + assertArtifactExists(pluginIdentityPath, "e2e-plugin-package"); + if (options.verifyPluginFixtureIdentity !== false) { + assertPluginFixtureIdentity(pluginIdentityPath); + } const spaIndex = path.join(WEB_DIR, "dist", "index.html"); if (!fs.existsSync(spaIndex)) { @@ -136,6 +149,7 @@ const BACKEND_SOURCE_SKIP_FILE_PATTERNS = [ * pointless backend rebuild over a bundle the tests ignore. */ const EMBEDDED_WEB_DIR = path.join("internal", "webapp", "embedded", "generated"); +const GENERATED_PLUGIN_FIXTURE_UI_DIR = path.join("cmd", "plugin-fixture", "fixture-package", "ui"); /** * Every file counts, not just `*.go`: the binary `//go:embed`s a large asset @@ -160,6 +174,7 @@ function findNewestBackendSource( if (entry.isDirectory()) { if (BACKEND_SOURCE_SKIP_DIRS.has(entry.name)) continue; if (path.relative(backendDir, entryPath) === EMBEDDED_WEB_DIR) continue; + if (path.relative(backendDir, entryPath) === GENERATED_PLUGIN_FIXTURE_UI_DIR) continue; walk(entryPath); continue; } @@ -276,6 +291,73 @@ function validateE2EBuildIdentity( return true; } +type PluginFixtureIdentity = { + schemaVersion: number; + pluginId: string; + pluginVersion: string; + bundlePath: string; + sourceHash: string; + generatedOutputHash: string; + manifestHash: string; + archiveSha256: string; + manifestCapability: string; + minKandevVersion: string; + panelKey: string; +}; + +function assertPluginFixtureIdentity(identityPath: string): void { + const identity = JSON.parse(fs.readFileSync(identityPath, "utf8")) as PluginFixtureIdentity; + if ( + identity.schemaVersion !== 2 || + identity.pluginId !== "kandev-plugin-e2e" || + identity.pluginVersion !== "1.0.0" || + identity.bundlePath !== "ui/bundle.js" || + identity.manifestCapability !== "messages" || + identity.minKandevVersion !== "0.91.1" || + identity.panelKey !== "prompt-history-plugin" + ) { + throw new Error(`Unsupported E2E plugin identity schema in ${identityPath}`); + } + const source = path.join( + WEB_DIR, + "e2e", + "fixtures", + "plugins", + "prompt-history-plugin", + "bundle.js", + ); + const output = path.join( + BACKEND_DIR, + "cmd", + "plugin-fixture", + "fixture-package", + "ui", + "bundle.js", + ); + const manifest = path.join( + BACKEND_DIR, + "cmd", + "plugin-fixture", + "fixture-package", + "manifest.yaml", + ); + const archive = path.join(BACKEND_DIR, ".build", "kandev-plugin-e2e-1.0.0.tar.gz"); + for (const [file, expected] of [ + [source, identity.sourceHash], + [output, identity.generatedOutputHash], + [manifest, identity.manifestHash], + [archive, identity.archiveSha256], + ]) { + assertArtifactExists(file); + const actual = sha256File(file); + if (actual !== expected) { + throw new Error( + `E2E plugin identity checksum mismatch for ${file}: got ${actual}, expected ${expected}`, + ); + } + } +} + /** * Fail fast when the coverage oracle is asked to run against a bundle that has * no pseudo catalog. diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 1ee3c51775..af83f20727 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -1519,7 +1519,7 @@ export class ApiClient { turnStartedAt?: string; turnCompletedAt?: string; }, - ): Promise { + ): Promise<{ messageId: string; turnId: string | null }> { const body: Record = { session_id: sessionId, type: opts.type }; if (opts.content !== undefined) body.content = opts.content; if (opts.metadata !== undefined) body.metadata = opts.metadata; @@ -1529,7 +1529,24 @@ export class ApiClient { if (opts.newTurn !== undefined) body.new_turn = opts.newTurn; if (opts.turnStartedAt !== undefined) body.turn_started_at = opts.turnStartedAt; if (opts.turnCompletedAt !== undefined) body.turn_completed_at = opts.turnCompletedAt; - await this.request("POST", "/api/v1/_test/messages", body); + const result = await this.request<{ message_id: string; turn_id?: string | null }>( + "POST", + "/api/v1/_test/messages", + body, + ); + return { messageId: result.message_id, turnId: result.turn_id ?? null }; + } + + async updateSessionMessage(messageId: string, content: string): Promise { + await this.request("PATCH", `/api/v1/_test/messages/${messageId}`, { content }); + } + + async deleteSessionMessage(messageId: string): Promise { + await this.request("DELETE", `/api/v1/_test/messages/${messageId}`); + } + + async completeSessionTurn(turnId: string): Promise { + await this.request("POST", `/api/v1/_test/turns/${turnId}/complete`); } async seedToolCallMessages( @@ -2599,7 +2616,7 @@ export class ApiClient { } async deleteSession(sessionId: string): Promise { - await this.request("DELETE", `/api/v1/task-sessions/${sessionId}`); + await this.request("DELETE", `/api/v1/_test/task-sessions/${sessionId}`); } async getTask(taskId: string): Promise<{ diff --git a/apps/web/e2e/helpers/causal-waits.test.ts b/apps/web/e2e/helpers/causal-waits.test.ts index f9823901c9..a075677c9a 100644 --- a/apps/web/e2e/helpers/causal-waits.test.ts +++ b/apps/web/e2e/helpers/causal-waits.test.ts @@ -150,6 +150,23 @@ describe("watchWs().waitForEvent", () => { payload: { task_id: "t1", status: "cancelled" }, }); }); + it("resolves on an ordered session event using its legacy action alias", async () => { + const { page, fake } = fakePage(); + const ws = watchWs(page); + const socket = fake.openSocket(GATEWAY); + const pending = ws.waitForEvent("session.message.added", { + where: (payload) => payload.session_id === "session-1", + }); + socket.emit("framereceived", { + type: "session.event", + event_type: "message.added", + payload: { session_id: "session-1", message_id: "message-1" }, + }); + await expect(pending).resolves.toMatchObject({ + eventType: "message.added", + payload: { session_id: "session-1", message_id: "message-1" }, + }); + }); it("skips notifications that fail the `where` predicate", async () => { const { page, fake } = fakePage(); diff --git a/apps/web/e2e/helpers/causal-waits.ts b/apps/web/e2e/helpers/causal-waits.ts index 2b9cb36912..60f49310c5 100644 --- a/apps/web/e2e/helpers/causal-waits.ts +++ b/apps/web/e2e/helpers/causal-waits.ts @@ -124,6 +124,7 @@ export type WsFrame = { id?: string; type?: string; action?: string; + eventType?: string; payload: Record; }; @@ -160,6 +161,7 @@ function decodeFrame(payload: string | Buffer | Uint8Array): WsFrame | null { id: typeof parsed.id === "string" ? parsed.id : undefined, type: typeof parsed.type === "string" ? parsed.type : undefined, action: typeof parsed.action === "string" ? parsed.action : undefined, + eventType: typeof parsed.event_type === "string" ? parsed.event_type : undefined, payload: body && typeof body === "object" ? (body as Record) : {}, }; } catch { @@ -214,6 +216,12 @@ export function watchWs(page: Page): WsWatcher { }; } +function matchesEventAction(frame: WsFrame, action: string): boolean { + if (frame.action === action) return true; + if (frame.type !== "session.event") return false; + const orderedEventType = action.startsWith("session.") ? action.slice("session.".length) : action; + return frame.eventType === action || frame.eventType === orderedEventType; +} function waitForEvent( channels: Channels, action: string, @@ -227,14 +235,9 @@ function waitForEvent( reject, ); wait.listen(channels.received, (frame) => { - if (frame.action !== action) return; - // Strict, not `frame.type && ...`: a frame carrying the right `action` but - // no `type` is not a confirmed server push, and resolving on one would be - // the exact false-positive this module exists to remove. Matches the - // sibling guard in `waitForResponse`. Observed gateway traffic only ever - // carries `notification` / `response` / `request`, so nothing legitimate - // relies on the loose form. - if (frame.type !== "notification") return; + if (!matchesEventAction(frame, action)) return; + // Strictly require a server-push frame, including ordered session events. + if (frame.type !== "notification" && frame.type !== "session.event") return; if (where && !where(frame.payload)) return; wait.dispose(); resolve(frame); diff --git a/apps/web/e2e/helpers/plugin-fixture.ts b/apps/web/e2e/helpers/plugin-fixture.ts index a72a470c28..9a09326015 100644 --- a/apps/web/e2e/helpers/plugin-fixture.ts +++ b/apps/web/e2e/helpers/plugin-fixture.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { type Page, expect } from "@playwright/test"; +import type { ApiClient } from "./api-client"; export const PLUGIN_ID = "kandev-plugin-e2e"; export const PACKAGE_PATH = path.resolve( @@ -7,6 +8,10 @@ export const PACKAGE_PATH = path.resolve( "../../../backend/.build/kandev-plugin-e2e-1.0.0.tar.gz", ); +export async function uninstallFixturePlugin(apiClient: ApiClient): Promise { + await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined); +} + export async function installFixturePlugin(page: Page): Promise { await page.goto("/settings/plugins"); await page.getByTestId("install-plugin-trigger").click(); diff --git a/apps/web/e2e/helpers/session-stream-overload.ts b/apps/web/e2e/helpers/session-stream-overload.ts index c362b2ff3a..65bea0e944 100644 --- a/apps/web/e2e/helpers/session-stream-overload.ts +++ b/apps/web/e2e/helpers/session-stream-overload.ts @@ -60,7 +60,10 @@ export function noisyReceivedFrames( frame.sessionId === sessionId && // A subscriber present before the burst can receive its coalesced state // as an add; a subscriber joining mid-burst observes subsequent updates. - (frame.action === "session.message.added" || frame.action === "session.message.updated"), + (frame.action === "session.message.added" || + frame.action === "session.message.updated" || + (frame.type === "session.event" && + (frame.eventType === "message.added" || frame.eventType === "message.updated"))), ); } diff --git a/apps/web/e2e/helpers/ws-drop.ts b/apps/web/e2e/helpers/ws-drop.ts index 853a80c959..2aeee69ad8 100644 --- a/apps/web/e2e/helpers/ws-drop.ts +++ b/apps/web/e2e/helpers/ws-drop.ts @@ -16,6 +16,12 @@ type MessageAddResponseDropController = { droppedCount: () => number; }; +type ExpiredPluginSnapshotController = { + expireNextPluginSnapshot: () => void; + modifiedCount: () => number; + pluginSubscribeCount: () => number; +}; + export type QueueAdmissionDropController = { dropNextQueueAddRequest: () => void; dropNextQueueAddResponse: (count?: number) => void; @@ -44,18 +50,34 @@ function parseJSONFrames(message: string | Buffer): Array | null { + const envelope = asRecord(message); + const payload = asRecord(envelope?.payload); + const isLegacy = envelope?.action === "session.message.added" && payload?.author_type === "user"; + const isOrdered = + envelope?.type === "session.event" && + envelope?.event_type === "message.added" && + payload?.author_type === "user"; + if ( + (!isLegacy && !isOrdered) || + typeof payload?.content !== "string" || + !payload.content.includes(prompt) + ) { + return null; + } + return payload; +} + function isTargetUserMessageAdded( message: unknown, prompt: string, ): message is { payload: { content: string } } { + return targetMessagePayload(message, prompt) !== null; +} + +function targetAction(message: unknown): string { const envelope = asRecord(message); - const payload = asRecord(envelope?.payload); - return ( - envelope?.action === "session.message.added" && - payload?.author_type === "user" && - typeof payload.content === "string" && - payload.content.includes(prompt) - ); + return envelope?.type === "session.event" ? "session.event" : "session.message.added"; } function filterServerFrame( @@ -77,7 +99,7 @@ function filterServerFrame( const parsed = JSON.parse(trimmed) as unknown; if (isTargetUserMessageAdded(parsed, prompt)) { didDrop = true; - dropped.push({ action: "session.message.added", content: parsed.payload.content }); + dropped.push({ action: targetAction(parsed), content: parsed.payload.content }); continue; } } catch { @@ -95,12 +117,14 @@ export async function routeMainWebSocketWithPromptDrop(page: Page): Promise(); + const orderedRecoveryRequestIDs = new Set(); let recoveryResponses = 0; await page.routeWebSocket(/\/ws$/, (ws) => { const server = ws.connectToServer(); ws.onMessage((message) => { for (const frame of parseJSONFrames(message)) { + const payload = asRecord(frame.payload); if ( promptToDrop !== null && frame.type === "request" && @@ -109,6 +133,16 @@ export async function routeMainWebSocketWithPromptDrop(page: Page): Promise dropped.length, @@ -364,3 +407,85 @@ export async function routeMainWebSocketWithQueueAdmissionDrops( droppedResponseCount: () => state.droppedResponses.value, }; } + +/** + * Rewrites one plugin subscription expiry in the browser transport. The + * signed token remains server-valid, so the panel must exercise its normal + * fresh rebind path instead of relying on a relaxed backend validation rule. + */ +export async function routeMainWebSocketWithExpiredPluginSnapshot( + page: Page, +): Promise { + const requestIDs = new Set(); + let armed = false; + let modified = 0; + let pluginSubscribeRequests = 0; + + await page.routeWebSocket(/\/ws$/, (ws) => { + const server = ws.connectToServer(); + ws.onMessage((message) => { + for (const frame of parseJSONFrames(message)) { + const payload = asRecord(frame.payload); + if ( + frame.type === "request" && + frame.action === "session.subscribe" && + typeof frame.id === "string" && + payload?.consumer_kind === "plugin" + ) { + pluginSubscribeRequests += 1; + if (armed) requestIDs.add(frame.id); + } + } + server.send(message); + }); + server.onMessage((message) => { + if (typeof message !== "string") { + ws.send(message); + return; + } + const rewritten: string[] = []; + let didRewrite = false; + for (const part of message.split("\n")) { + const trimmed = part.trim(); + if (!trimmed) { + rewritten.push(part); + continue; + } + let frame: Record | null = null; + try { + frame = asRecord(JSON.parse(trimmed)); + } catch { + // Preserve non-JSON frames. + } + if ( + armed && + frame?.type === "response" && + frame.action === "session.subscribe" && + typeof frame.id === "string" && + requestIDs.delete(frame.id) + ) { + const payload = asRecord(frame.payload); + if (payload?.success === true) { + frame.payload = { ...payload, expires_at: "2000-01-01T00:00:00Z" }; + rewritten.push(JSON.stringify(frame)); + armed = false; + modified += 1; + didRewrite = true; + continue; + } + } + rewritten.push(part); + } + ws.send(didRewrite ? rewritten.join("\n") : message); + }); + }); + + return { + expireNextPluginSnapshot: () => { + requestIDs.clear(); + armed = true; + }, + modifiedCount: () => modified, + pluginSubscribeCount: () => pluginSubscribeRequests, + }; +} diff --git a/apps/web/e2e/helpers/ws-traffic.ts b/apps/web/e2e/helpers/ws-traffic.ts index 1d0892dd00..a8e661b81c 100644 --- a/apps/web/e2e/helpers/ws-traffic.ts +++ b/apps/web/e2e/helpers/ws-traffic.ts @@ -4,6 +4,7 @@ export type GatewayTrafficFrame = { direction: "sent" | "received"; action?: string; type?: string; + eventType?: string; sessionId?: string; taskId?: string; bytes: number; @@ -74,6 +75,7 @@ function frameFromPayload( return { direction, action: stringField(message?.action), + eventType: stringField((message as Record | null)?.event_type), type: stringField(message?.type), sessionId: stringField(messagePayload?.session_id) ?? diff --git a/apps/web/e2e/scripts/run-e2e.sh b/apps/web/e2e/scripts/run-e2e.sh index ebefac5b4b..c39120b262 100755 --- a/apps/web/e2e/scripts/run-e2e.sh +++ b/apps/web/e2e/scripts/run-e2e.sh @@ -167,15 +167,12 @@ build_backend_host() { make -C "$BACKEND_DIR" "${targets[@]}" >/dev/null || die "backend build failed" } -# Packages the plugin-fixture SDK plugin (cmd/plugin-fixture) into -# .build/kandev-plugin-e2e-1.0.0.tar.gz for tests/plugins/plugins.spec.ts. -# Always built on the host — like mock-agent, e2e/global-setup.ts only checks -# this file exists (not that it's current), so it must be rebuilt any time -# cmd/plugin-fixture changes (this runner always rebuilds it via build_fe's -# sibling call below, unless --no-build is set). +# Builds the external-style fixture UI from its sole source, then packages the +# backend and UI with their immutable identity for every host/container run. build_plugin_package() { - log "packaging e2e fixture plugin" - make -C "$BACKEND_DIR" e2e-plugin-package >/dev/null || die "e2e plugin package build failed" + log "building and packaging e2e fixture plugin" + make -C "$BACKEND_DIR" e2e-plugin-ui e2e-plugin-package >/dev/null \ + || die "e2e plugin package build failed" } backend_runs_in() { # $1=image — does the host-built binary run there? (glibc check, ~2s) diff --git a/apps/web/e2e/tests/plugins/conversation-recovery.spec.ts b/apps/web/e2e/tests/plugins/conversation-recovery.spec.ts new file mode 100644 index 0000000000..24137f6c1f --- /dev/null +++ b/apps/web/e2e/tests/plugins/conversation-recovery.spec.ts @@ -0,0 +1,190 @@ +import { expect, test } from "../../fixtures/test-base"; +import type { SeedData } from "../../fixtures/test-base"; +import type { ApiClient } from "../../helpers/api-client"; +import { + routeMainWebSocketWithExpiredPluginSnapshot, + routeMainWebSocketWithPromptDrop, +} from "../../helpers/ws-drop"; +import { installFixturePlugin, PLUGIN_ID } from "../../helpers/plugin-fixture"; +import { SessionPage } from "../../pages/session-page"; + +const PANEL_KEY = "prompt-history-plugin"; + +async function createSeededConversation( + apiClient: ApiClient, + seedData: SeedData, + title: string, +): Promise<{ taskId: string; sessionId: string }> { + const task = await apiClient.createTask(seedData.workspaceId, title, { + description: "conversation recovery fixture", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + const { session_id: sessionId } = await apiClient.seedTaskSession(task.id, { + state: "IDLE", + agentProfileId: seedData.agentProfileId, + repositoryId: seedData.repositoryId, + }); + return { taskId: task.id, sessionId }; +} + +async function uninstallFixturePlugin(apiClient: ApiClient): Promise { + await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined); +} + +test.describe("Conversation recovery", () => { + test.afterEach(async ({ apiClient }) => uninstallFixturePlugin(apiClient)); + + test("repairs core messages and turns after a multi-event gap on the live socket", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(90_000); + const wsDrop = await routeMainWebSocketWithPromptDrop(testPage); + let gatewayCloseCount = 0; + testPage.on("websocket", (socket) => { + if (!socket.url().endsWith("/ws")) return; + socket.on("close", () => { + gatewayCloseCount += 1; + }); + }); + + const { taskId, sessionId } = await createSeededConversation( + apiClient, + seedData, + "Core conversation recovery", + ); + await testPage.goto(`/t/${taskId}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 30_000 }); + + const skippedPrompt = "core recovery skipped prompt"; + const replayedPrompt = "core recovery replayed prompt"; + wsDrop.dropPrompt(skippedPrompt); + const skipped = await apiClient.seedSessionMessage(sessionId, { + type: "message", + content: skippedPrompt, + authorType: "user", + newTurn: true, + turnStartedAt: "2026-09-14T12:00:00Z", + turnCompletedAt: "2026-09-14T12:00:02Z", + }); + const replayed = await apiClient.seedSessionMessage(sessionId, { + type: "message", + content: replayedPrompt, + authorType: "user", + newTurn: true, + turnStartedAt: "2026-09-14T12:01:00Z", + turnCompletedAt: "2026-09-14T12:01:03Z", + }); + + await expect + .poll(wsDrop.droppedCount, { + timeout: 10_000, + message: "expected the first durable message event to be dropped", + }) + .toBeGreaterThan(0); + await expect + .poll(wsDrop.recoveryResponseCount, { + timeout: 15_000, + message: "expected core recovery snapshots after the ordered gap", + }) + .toBeGreaterThan(0); + + const chat = session.activeChat(); + await expect(chat.locator(`#msg-${skipped.messageId}`)).toBeVisible({ timeout: 15_000 }); + await expect(chat.locator(`#msg-${replayed.messageId}`)).toBeVisible({ timeout: 15_000 }); + await expect( + chat.locator(`#msg-${skipped.messageId}`).getByTestId("message-turn-duration"), + ).toBeVisible(); + await expect( + chat.locator(`#msg-${replayed.messageId}`).getByTestId("message-turn-duration"), + ).toBeVisible(); + + const laterLive = await apiClient.seedSessionMessage(sessionId, { + type: "message", + content: "core recovery later live update", + authorType: "user", + newTurn: true, + turnStartedAt: "2026-09-14T12:02:00Z", + turnCompletedAt: "2026-09-14T12:02:01Z", + }); + await expect(chat.locator(`#msg-${laterLive.messageId}`)).toBeVisible({ timeout: 15_000 }); + expect(gatewayCloseCount, "core recovery must keep the gateway socket connected").toBe(0); + }); + + test("rebinds an expired plugin continuation and keeps paging without remount", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(90_000); + await installFixturePlugin(testPage); + const expiry = await routeMainWebSocketWithExpiredPluginSnapshot(testPage); + const renewalRequests: string[] = []; + testPage.on("request", (request) => { + if (request.url().includes("/conversation/continuation/renew")) { + renewalRequests.push(request.url()); + } + }); + + const { taskId, sessionId } = await createSeededConversation( + apiClient, + seedData, + "Plugin continuation recovery", + ); + for (const [index, content] of [ + "plugin recovery oldest prompt", + "plugin recovery middle prompt", + "plugin recovery newest prompt", + ].entries()) { + await apiClient.seedSessionMessage(sessionId, { + type: "message", + content, + authorType: "user", + newTurn: true, + turnStartedAt: `2026-09-14T12:0${index}:00Z`, + turnCompletedAt: `2026-09-14T12:0${index}:01Z`, + }); + } + + await testPage.goto(`/t/${taskId}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + expiry.expireNextPluginSnapshot(); + await session.addPanelButton().click(); + const panelMenuItem = session.addPanelPluginItem(PLUGIN_ID, PANEL_KEY); + await expect(panelMenuItem).toBeVisible(); + await panelMenuItem.click(); + + const panel = testPage.getByTestId("fixture-prompt-history-panel"); + await expect(panel).toBeVisible({ timeout: 15_000 }); + await expect(panel.getByTestId("fixture-prompt-history-row")).toHaveCount(2); + await expect.poll(expiry.modifiedCount).toBe(1); + + await panel.getByTestId("fixture-prompt-history-load-more").click(); + await expect + .poll(expiry.pluginSubscribeCount, { + timeout: 15_000, + message: "expected a fresh plugin subscription after expiry", + }) + .toBeGreaterThan(1); + await expect(panel.getByTestId("fixture-prompt-history-row")).toHaveCount(3); + expect(renewalRequests).toHaveLength(0); + + const laterLive = await apiClient.seedSessionMessage(sessionId, { + type: "message", + content: "plugin recovery later live prompt", + authorType: "user", + newTurn: true, + turnStartedAt: "2026-09-14T12:03:00Z", + turnCompletedAt: "2026-09-14T12:03:01Z", + }); + await expect(panel.locator(`[data-message-id="${laterLive.messageId}"]`)).toBeVisible({ + timeout: 15_000, + }); + }); +}); diff --git a/apps/web/e2e/tests/plugins/mobile-prompt-history-plugin.spec.ts b/apps/web/e2e/tests/plugins/mobile-prompt-history-plugin.spec.ts new file mode 100644 index 0000000000..1b0cd5d493 --- /dev/null +++ b/apps/web/e2e/tests/plugins/mobile-prompt-history-plugin.spec.ts @@ -0,0 +1,87 @@ +// Routing: /t/{taskId}. The mobile- prefix selects the mobile-chrome project. +import { expect, test } from "../../fixtures/test-base"; +import { installFixturePlugin, uninstallFixturePlugin } from "../../helpers/plugin-fixture"; +import { SessionPage } from "../../pages/session-page"; + +const PANEL_OPTION = "mobile-plugin-panel-option-kandev-plugin-e2e-prompt-history-plugin"; + +test.describe("Mobile prompt-history fixture", () => { + test.afterEach(async ({ apiClient }) => uninstallFixturePlugin(apiClient)); + + test("pages, previews aliases, and navigates through the Host facade", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(90_000); + await installFixturePlugin(testPage); + await apiClient.createPrompt("daily-mobile", "Mobile saved prompt preview"); + + const task = await apiClient.createTask(seedData.workspaceId, "Mobile prompt history parity", { + description: "mobile fixture initial prompt", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + const { session_id: sessionId } = await apiClient.seedTaskSession(task.id, { + state: "IDLE", + agentProfileId: seedData.agentProfileId, + repositoryId: seedData.repositoryId, + }); + for (let index = 0; index < 3; index += 1) { + await apiClient.seedSessionMessage(sessionId, { + type: "message", + content: index === 2 ? "mobile latest @daily-mobile" : `mobile older ${index}`, + authorType: "user", + newTurn: true, + turnStartedAt: `2026-09-07T12:0${index}:00Z`, + turnCompletedAt: `2026-09-07T12:0${index}:01Z`, + }); + } + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + + const panelsButton = testPage.getByRole("button", { name: "Panels" }); + await expect(panelsButton).toBeVisible({ timeout: 15_000 }); + expect((await panelsButton.boundingBox())?.height).toBeGreaterThanOrEqual(44); + await panelsButton.tap(); + const option = testPage.getByTestId(PANEL_OPTION); + await expect(option).toBeVisible({ timeout: 10_000 }); + expect((await option.boundingBox())?.height).toBeGreaterThanOrEqual(44); + await option.tap(); + + const panel = testPage.getByTestId("fixture-prompt-history-panel"); + if (!(await panel.isVisible())) { + await panelsButton.tap(); + await option.tap(); + } + await expect(panel).toBeVisible({ timeout: 10_000 }); + await expect(panel).toHaveAttribute("data-presentation", "mobile"); + expect( + await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + ).toBe(true); + + const loadOlder = panel.getByTestId("fixture-prompt-history-load-more"); + await expect(loadOlder).toBeVisible(); + expect((await loadOlder.boundingBox())?.height).toBeGreaterThanOrEqual(44); + await loadOlder.tap(); + await expect(panel.getByTestId("fixture-prompt-history-row")).toHaveCount(3); + + await panel.getByTestId("fixture-prompt-history-row").first().getByRole("button").first().tap(); + const alias = panel.getByTestId("custom-prompt-mention"); + await expect(alias).toHaveAttribute("data-prompt-name", "daily-mobile"); + expect((await alias.boundingBox())?.height).toBeGreaterThanOrEqual(44); + await alias.tap(); + await expect(testPage.getByText("Mobile saved prompt preview")).toBeVisible(); + await testPage.keyboard.press("Escape"); + await expect(testPage.getByText("Mobile saved prompt preview")).toBeHidden(); + + const openPrompt = panel.getByRole("button", { name: "Open prompt" }); + await expect(openPrompt).toBeVisible(); + expect((await openPrompt.boundingBox())?.height).toBeGreaterThanOrEqual(44); + await openPrompt.tap(); + await expect(session.activeChat().getByText("mobile latest", { exact: false })).toBeVisible(); + }); +}); diff --git a/apps/web/e2e/tests/plugins/prompt-history-plugin.spec.ts b/apps/web/e2e/tests/plugins/prompt-history-plugin.spec.ts new file mode 100644 index 0000000000..afddd0bc21 --- /dev/null +++ b/apps/web/e2e/tests/plugins/prompt-history-plugin.spec.ts @@ -0,0 +1,136 @@ +import { expect, test } from "../../fixtures/test-base"; +import { + installFixturePlugin, + PLUGIN_ID, + uninstallFixturePlugin, +} from "../../helpers/plugin-fixture"; +import { SessionPage } from "../../pages/session-page"; + +const PANEL_KEY = "prompt-history-plugin"; + +test.describe("Packaged prompt-history fixture", () => { + test.afterEach(async ({ apiClient }) => uninstallFixturePlugin(apiClient)); + + test("uses only Host conversation capabilities for paging, live transitions, and navigation", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(90_000); + await installFixturePlugin(testPage); + await apiClient.createPrompt("daily", "Saved daily prompt preview"); + const task = await apiClient.createTask(seedData.workspaceId, "Prompt history plugin parity", { + description: "fixture initial prompt", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + const { session_id: sessionId } = await apiClient.seedTaskSession(task.id, { + state: "IDLE", + agentProfileId: seedData.agentProfileId, + repositoryId: seedData.repositoryId, + }); + await apiClient.seedSessionMessage(sessionId, { + type: "message", + content: "fixture oldest prompt", + authorType: "user", + newTurn: true, + turnStartedAt: "2026-09-07T11:59:00Z", + turnCompletedAt: "2026-09-07T11:59:01Z", + }); + const older = await apiClient.seedSessionMessage(sessionId, { + type: "message", + content: "fixture older prompt", + authorType: "user", + newTurn: true, + turnStartedAt: "2026-09-07T12:00:00Z", + turnCompletedAt: "2026-09-07T12:00:01Z", + }); + const live = await apiClient.seedSessionMessage(sessionId, { + type: "message", + content: "fixture live prompt @daily", + authorType: "user", + metadata: { sender_task_id: "fixture-agent-task" }, + newTurn: true, + turnStartedAt: "2026-09-07T12:01:00Z", + }); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await session.addPanelButton().click(); + const menuItem = session.addPanelPluginItem(PLUGIN_ID, PANEL_KEY); + await expect(menuItem).toHaveText(/Prompt history fixture/); + await menuItem.click(); + + const panel = testPage.getByTestId("fixture-prompt-history-panel"); + await expect(panel).toBeVisible({ timeout: 10_000 }); + await expect(panel.getByTestId("fixture-prompt-history-row").first()).toContainText("Prompt"); + const loadOlder = panel.getByTestId("fixture-prompt-history-load-more"); + await expect(loadOlder).toBeVisible(); + await loadOlder.click(); + await expect(panel.locator(`[data-message-id="${live.messageId}"]`)).toContainText( + "Sent by agent", + ); + if (!live.turnId) throw new Error("live fixture message did not create a turn"); + await apiClient.completeSessionTurn(live.turnId); + await expect( + panel + .locator(`[data-message-id="${live.messageId}"]`) + .getByTestId("fixture-prompt-history-duration"), + ).toBeVisible(); + await expect(panel.getByText("Prompt 1", { exact: false }).first()).toBeVisible(); + await apiClient.updateSessionMessage(live.messageId, "fixture updated prompt @daily"); + await panel + .getByTestId("fixture-prompt-history-row") + .first() + .getByRole("button") + .first() + .click(); + await expect(panel.getByTestId("fixture-prompt-history-detail")).toContainText( + "fixture updated prompt", + ); + const alias = panel.getByTestId("custom-prompt-mention"); + await expect(alias).toHaveAttribute("data-prompt-name", "daily"); + await alias.click(); + await expect(testPage.getByText("Saved daily prompt preview")).toBeVisible(); + await testPage.keyboard.press("Escape"); + await expect(testPage.getByText("Saved daily prompt preview")).toBeHidden(); + + await panel.getByRole("button", { name: "Open prompt" }).click(); + await expect(session.activeChat().locator(`#msg-${live.messageId}`)).toBeAttached(); + const chatMessage = session.activeChat().locator(`#msg-${live.messageId}`); + await chatMessage.hover(); + await chatMessage.getByRole("button", { name: "Mark message as favorite" }).click(); + await expect(panel.locator(`[data-message-id="${live.messageId}"]`)).toHaveAttribute( + "data-favorite", + "true", + ); + + await apiClient.deleteSessionMessage(older.messageId); + await expect(panel.locator(`[data-message-id="${older.messageId}"]`)).toHaveCount(0); + + await testPage.evaluate(() => { + document.cookie = "kandev_locale=pseudo; path=/; max-age=31536000; SameSite=Lax"; + }); + await testPage.reload(); + await session.waitForLoad(); + await session.addPanelButton().click(); + const pseudoMenuItem = session.addPanelPluginItem(PLUGIN_ID, PANEL_KEY); + await expect(pseudoMenuItem).toHaveText(/Ƥřǿɱƥŧ ħīşŧǿřẏ ƒīẋŧŭřḗ/); + await pseudoMenuItem.click(); + await expect(testPage.getByTestId("fixture-prompt-history-panel")).toContainText(/Ƥřǿɱƥŧ/); + await testPage.evaluate(() => { + document.cookie = "kandev_locale=en; path=/; max-age=31536000; SameSite=Lax"; + }); + await testPage.reload(); + await session.waitForLoad(); + await session.addPanelButton().click(); + await session.addPanelPluginItem(PLUGIN_ID, PANEL_KEY).click(); + const finalPanel = testPage.getByTestId("fixture-prompt-history-panel"); + await expect(finalPanel).toBeVisible({ timeout: 10_000 }); + await apiClient.deleteSession(sessionId); + await expect(finalPanel).toHaveAttribute("data-removed", "true"); + await expect(finalPanel.locator(`[data-message-id="${live.messageId}"]`)).toBeVisible(); + }); +}); diff --git a/apps/web/hooks/domains/session/message-window-reconciliation.test.ts b/apps/web/hooks/domains/session/message-window-reconciliation.test.ts index 19138eff6c..c5834238b7 100644 --- a/apps/web/hooks/domains/session/message-window-reconciliation.test.ts +++ b/apps/web/hooks/domains/session/message-window-reconciliation.test.ts @@ -68,6 +68,22 @@ describe("reconcileLatestMessageWindow", () => { expect(result.oldestCursor).toBe("fetched-3"); }); + it("removes stale rows from an authoritative window while retaining older pages", () => { + const older = message("older-1", 1); + const stale = message("stale-3", 3); + const fetched = [message("fresh-3", 3), message("fresh-4", 4)]; + + const result = reconcileLatestMessageWindow({ + cachedAtRequest: [older, stale], + cachedAtResponse: [older, stale], + fetched, + authoritative: true, + }); + + expect(result.messages.map(({ id }) => id)).toEqual(["older-1", "fresh-3", "fresh-4"]); + expect(result.oldestCursor).toBe("older-1"); + }); + it("sorts timestamps using sub-millisecond precision before the id tie-breaker", () => { const earlier = message("z-earlier", 0, ".000001"); const later = message("a-later", 0, ".000002"); diff --git a/apps/web/hooks/domains/session/message-window-reconciliation.ts b/apps/web/hooks/domains/session/message-window-reconciliation.ts index eb6b7b0673..71dc3c7f9e 100644 --- a/apps/web/hooks/domains/session/message-window-reconciliation.ts +++ b/apps/web/hooks/domains/session/message-window-reconciliation.ts @@ -42,6 +42,10 @@ function joinMessages(base: Message[], extras: Message[]): Message[] { return [...base, ...extras.filter((message) => !baseIds.has(message.id))].sort(compareMessages); } +function isPendingLocalMessage(message: Message): boolean { + return message.metadata?.client_queue_id !== undefined; +} + /** * Reconcile a bounded newest-page response with the session cache while * preserving one contiguous pagination interval. @@ -50,9 +54,17 @@ export function reconcileLatestMessageWindow(params: { cachedAtRequest: Message[]; cachedAtResponse: Message[]; fetched: Message[]; + authoritative?: boolean; }): LatestMessageWindow { - const { cachedAtRequest, cachedAtResponse, fetched } = params; + const { cachedAtRequest, cachedAtResponse, fetched, authoritative = false } = params; if (fetched.length === 0) { + if (authoritative) { + const pendingLocal = cachedAtResponse.filter(isPendingLocalMessage); + return { + messages: pendingLocal, + oldestCursor: pendingLocal[0]?.id ?? null, + }; + } return { messages: cachedAtResponse, oldestCursor: cachedAtResponse[0]?.id ?? null, @@ -64,6 +76,18 @@ export function reconcileLatestMessageWindow(params: { const fetchedIds = new Set(orderedFetched.map((message) => message.id)); const overlapsCachedWindow = cachedAtRequest.some((message) => fetchedIds.has(message.id)); + if (authoritative) { + const cachedAtRequestIds = new Set(cachedAtRequest.map((message) => message.id)); + const retained = cachedAtResponse.filter((message) => { + if (fetchedIds.has(message.id)) return false; + if (isPendingLocalMessage(message)) return true; + if (compareMessages(message, fetchedBoundary) < 0) return true; + return !cachedAtRequestIds.has(message.id); + }); + const messages = joinMessages(orderedFetched, retained); + return { messages, oldestCursor: messages[0]?.id ?? null }; + } + if (overlapsCachedWindow) { const messages = joinMessages(orderedFetched, cachedAtResponse); return { messages, oldestCursor: messages[0]?.id ?? null }; diff --git a/apps/web/hooks/domains/session/use-session-message-fetch.ts b/apps/web/hooks/domains/session/use-session-message-fetch.ts index a1849deef4..a9798a84fb 100644 --- a/apps/web/hooks/domains/session/use-session-message-fetch.ts +++ b/apps/web/hooks/domains/session/use-session-message-fetch.ts @@ -44,13 +44,22 @@ export function useInitialMessageLoadingState( ]); } -export function getHydratedMessagesForGeneration( - hydrationRef: SessionHydrationRef | undefined, - sessionId: string, - readiness: Promise, - hydrationKey: string | undefined, - store: SessionMessageStore, -): Message[] | undefined { +export function getHydratedMessagesForGeneration({ + hydrationRef, + sessionId, + readiness, + hydrationKey, + store, + force = false, +}: { + hydrationRef: SessionHydrationRef | undefined; + sessionId: string; + readiness: Promise; + hydrationKey: string | undefined; + store: SessionMessageStore; + force?: boolean; +}): Message[] | undefined { + if (force) return undefined; const generation = hydrationRef?.current; if ( !hydrationKey || @@ -116,6 +125,7 @@ type DoFetchMessagesParams = { hydrationRef?: SessionHydrationRef, hydrationKey?: string, onRetry?: () => void, + options?: MessageFetchOptions, ) => Promise; onError?: (error: unknown) => void; isActive?: () => boolean; @@ -123,6 +133,12 @@ type DoFetchMessagesParams = { canFinalizeLoading?: () => boolean; hydrationRef?: SessionHydrationRef; hydrationKey?: string; + options?: MessageFetchOptions; +}; + +export type MessageFetchOptions = { + force?: boolean; + authoritative?: boolean; }; export async function doFetchMessages({ @@ -140,8 +156,9 @@ export async function doFetchMessages({ canFinalizeLoading, hydrationRef, hydrationKey, -}: DoFetchMessagesParams): Promise { - if (isInactive(isActive)) return; + options, +}: DoFetchMessagesParams): Promise { + if (isInactive(isActive)) return false; beginSessionFetch(taskSessionId); setIsLoading(true); const isInitialHistoryFetch = lastFetchedSessionIdRef.current !== taskSessionId; @@ -155,20 +172,28 @@ export async function doFetchMessages({ setIsWaitingForInitialMessages(true); } try { - await fetchAndStoreMessages(taskSessionId, store, isActive, hydrationRef, hydrationKey, () => - setHistoryStatus("retrying"), + await fetchAndStoreMessages( + taskSessionId, + store, + isActive, + hydrationRef, + hydrationKey, + () => setHistoryStatus("retrying"), + options, ); - if (isInactive(isActive)) return; + if (isInactive(isActive)) return false; lastFetchedSessionIdRef.current = taskSessionId; setHistoryStatus("ready"); setHistoryError(null); setIsWaitingForInitialMessages(false); + return true; } catch (error) { - if (isInactive(isActive)) return; + if (isInactive(isActive)) return false; if (onError) onError(error); else console.error("Failed to fetch messages:", error); setHistoryStatus("unavailable"); setHistoryError(error); + return false; } finally { const active = !isInactive(isActive); if (endSessionFetch(taskSessionId)) { diff --git a/apps/web/hooks/domains/session/use-session-messages.test.ts b/apps/web/hooks/domains/session/use-session-messages.test.ts index 1a718ddc4f..51b8fb9227 100644 --- a/apps/web/hooks/domains/session/use-session-messages.test.ts +++ b/apps/web/hooks/domains/session/use-session-messages.test.ts @@ -10,6 +10,8 @@ const mockWebSocketClient = { request: vi.fn(), subscribeSession: vi.fn(), subscribeSessionWithReady: vi.fn(), + registerCoreSessionRecovery: vi.fn(() => vi.fn()), + retryCoreSessionRecovery: vi.fn(() => undefined), }; const mockState = { diff --git a/apps/web/hooks/domains/session/use-session-messages.ts b/apps/web/hooks/domains/session/use-session-messages.ts index 6b22fb4f8b..841a692642 100644 --- a/apps/web/hooks/domains/session/use-session-messages.ts +++ b/apps/web/hooks/domains/session/use-session-messages.ts @@ -20,6 +20,7 @@ import { doFetchMessages, getHydratedMessagesForGeneration, recordHydratedGeneration, + type MessageFetchOptions, type SessionHydrationRef, } from "./use-session-message-fetch"; import { useMessageFetchState } from "./use-message-fetch-state"; @@ -35,6 +36,11 @@ const INITIAL_FETCH_LIMIT = 100; const RUNNING_BACKFILL_INITIAL_DELAY_MS = 1200; const RUNNING_BACKFILL_INTERVAL_MS = 5000; const MAX_HISTORY_FETCH_ATTEMPTS = 2; +const TERMINAL_SESSION_STATES: Partial> = { + WAITING_FOR_INPUT: true, + COMPLETED: true, + FAILED: true, +}; // Monotonic guard against stale concurrent fetches. Each fetch claims an // increasing sequence number before awaiting; a completion only merges if no @@ -204,9 +210,10 @@ function requestSessionMessages( sessionId: string, readiness: Promise, cachedAtRequest: Message[], + force = false, ): InFlightMessageRequest { const existing = inFlightMessageRequests.get(sessionId); - if (existing?.readiness === readiness && !existing.settled) return existing; + if (!force && existing?.readiness === readiness && !existing.settled) return existing; const requestParams = { session_id: sessionId, @@ -242,13 +249,22 @@ function requestSessionMessages( } /** Fetch latest messages via WS and merge with any that arrived via live notifications. */ -async function fetchAndStoreMessagesAttempt( - sessionId: string, - store: ReturnType, - isActive?: () => boolean, - hydrationRef?: SessionHydrationRef, - hydrationKey?: string, -): Promise { +// eslint-disable-next-line complexity -- The ordered snapshot path keeps its guards adjacent to the commit fence. +async function fetchAndStoreMessagesAttempt({ + sessionId, + store, + isActive, + hydrationRef, + hydrationKey, + options, +}: { + sessionId: string; + store: ReturnType; + isActive?: () => boolean; + hydrationRef?: SessionHydrationRef; + hydrationKey?: string; + options?: MessageFetchOptions; +}): Promise { const client = getWebSocketClient(); if (!client) { return []; @@ -260,25 +276,32 @@ async function fetchAndStoreMessagesAttempt( const readiness = client.getSessionSubscriptionReadiness(sessionId); await readiness; if (isActive && !isActive()) return []; - const hydratedMessages = getHydratedMessagesForGeneration( + const hydratedMessages = getHydratedMessagesForGeneration({ hydrationRef, sessionId, readiness, hydrationKey, store, - ); + force: options?.force, + }); if (hydratedMessages !== undefined) return hydratedMessages; // The messages fetch is the session-entry chokepoint: any path that opens a // session's transcript must also make its turns resolvable. Start it only // after subscription acknowledgement so the REST snapshot cannot race the // initial WebSocket subscription registration. - void ensureSessionTurnsLoaded(sessionId, store, { readiness }); + if (!options?.force) void ensureSessionTurnsLoaded(sessionId, store, { readiness }); const cachedAtRequest = store.getState().messages.bySession[sessionId] ?? []; const seq = nextFetchSeq(); // Keep the snapshot on the deduplicated request. Concurrent callers may // observe different cache contents, but reconciliation must use the // baseline captured by the caller that actually issued the network request. - const request = requestSessionMessages(client, sessionId, readiness, cachedAtRequest); + const request = requestSessionMessages( + client, + sessionId, + readiness, + cachedAtRequest, + options?.force, + ); const response = await request.promise; if (isActive && !isActive()) return []; const fetched = [...(response.messages ?? [])].reverse(); @@ -287,6 +310,7 @@ async function fetchAndStoreMessagesAttempt( cachedAtRequest: request.cachedAtRequest, cachedAtResponse: store.getState().messages.bySession[sessionId] ?? [], fetched, + authoritative: options?.authoritative, }); // Stale-fetch guard: if a newer fetch for this session already merged while // this one was in flight, skip the merge so the older snapshot can't drop @@ -331,16 +355,18 @@ async function fetchAndStoreMessages( hydrationRef?: SessionHydrationRef, hydrationKey?: string, onRetry?: () => void, + options?: MessageFetchOptions, ): Promise { for (let attempt = 0; attempt < MAX_HISTORY_FETCH_ATTEMPTS; attempt += 1) { try { - return await fetchAndStoreMessagesAttempt( + return await fetchAndStoreMessagesAttempt({ sessionId, store, isActive, hydrationRef, hydrationKey, - ); + options, + }); } catch (error) { const retryable = isWebSocketRequestTimeoutError(error) && @@ -352,6 +378,7 @@ async function fetchAndStoreMessages( } return []; } + /** * When the initial fetch window contains no user/agent message rows (common * when the latest turn produced hundreds of tool calls), the chat would render @@ -387,9 +414,8 @@ function useTerminalStateFetch( active = false; }; if (!taskSessionId || connectionStatus !== "connected") return deactivate; - if (!taskSessionState || hasAgentMessage) return deactivate; - const terminalStates = new Set(["WAITING_FOR_INPUT", "COMPLETED", "FAILED"]); - if (!terminalStates.has(taskSessionState)) return deactivate; + if (!taskSessionState) return deactivate; + if (!TERMINAL_SESSION_STATES[taskSessionState]) return deactivate; const key = `${taskSessionId}:${taskSessionState}`; if (lastFetchStateKeyRef.current === key) return deactivate; lastFetchStateKeyRef.current = key; @@ -896,6 +922,13 @@ function useSessionHistoryRecoveryState({ return; } const generation = sessionFetchGenerationRef.current; + const clientRecovery = getWebSocketClient()?.retryCoreSessionRecovery(retrySessionId); + if (clientRecovery) { + hydrationRef.current = null; + fetchState.lastFetchedSessionIdRef.current = null; + void clientRecovery; + return; + } fetchState.lastFetchedSessionIdRef.current = null; fetchState.cachedRefreshGenerationRef.current += 1; hydrationRef.current = null; @@ -928,6 +961,74 @@ function useSessionHistoryRecoveryState({ return { retryHistory, sessionFetchGenerationRef }; } +function useCoreSessionRecovery({ + taskSessionId, + connectionStatus, + store, + fetchRefs, + hydrationRef, + hydrationKey, + sessionFetchGenerationRef, +}: { + taskSessionId: string | null; + connectionStatus: string; + store: ReturnType; + fetchRefs: ReturnType["refs"]; + hydrationRef: SessionHydrationRef; + hydrationKey: string; + sessionFetchGenerationRef: MutableRefObject; +}): void { + useEffect(() => { + if (!taskSessionId || connectionStatus !== "connected") return; + const client = getWebSocketClient(); + if (!client) return; + const generation = sessionFetchGenerationRef.current; + let active = true; + const isActive = () => + active && + sessionFetchGenerationRef.current === generation && + store.getState().taskSessions.items[taskSessionId] !== undefined; + + const unregister = client.registerCoreSessionRecovery(taskSessionId, async () => { + if (!isActive()) return false; + fetchRefs.lastFetchedSessionIdRef.current = null; + hydrationRef.current = null; + const readiness = client.getSessionSubscriptionReadiness(taskSessionId); + const [messagesReady, turnsReady] = await Promise.all([ + doFetchMessages({ + taskSessionId, + ...fetchRefs, + fetchAndStoreMessages, + isActive, + canFinalizeLoading: () => isActive(), + hydrationRef, + hydrationKey, + options: { force: true, authoritative: true }, + }), + ensureSessionTurnsLoaded(taskSessionId, store, { + readiness, + force: true, + replace: true, + }), + ]); + return isActive() && messagesReady && turnsReady; + }); + + return () => { + active = false; + unregister(); + }; + }, [ + connectionStatus, + fetchRefs, + hydrationKey, + hydrationRef, + sessionFetchGenerationRef, + store, + taskSessionId, + ]); +} + export function useSessionMessages(taskSessionId: string | null): UseSessionMessagesReturn { const store = useAppStoreApi(); const { messages, messagesMeta, taskSessionState, activeTurnId, connectionStatus } = @@ -962,6 +1063,15 @@ export function useSessionMessages(taskSessionId: string | null): UseSessionMess hydrationRef, hydrationKey, }); + useCoreSessionRecovery({ + taskSessionId, + connectionStatus, + store, + fetchRefs, + hydrationRef, + hydrationKey, + sessionFetchGenerationRef, + }); useSessionLifecycleSubscriptions({ taskSessionId, taskSessionState, diff --git a/apps/web/hooks/domains/session/use-session-recovery.test.tsx b/apps/web/hooks/domains/session/use-session-recovery.test.tsx new file mode 100644 index 0000000000..b861bb7f2e --- /dev/null +++ b/apps/web/hooks/domains/session/use-session-recovery.test.tsx @@ -0,0 +1,165 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Message, Turn } from "@/lib/types/http"; +import { sessionId, taskId } from "@/lib/types/ids"; + +const listSessionTurns = vi.fn(); +const recoveryHandlers = new Map Promise>(); +const webSocketClient = { + getSessionSubscriptionReadiness: vi.fn(() => Promise.resolve()), + request: vi.fn(), + subscribeSessionWithReady: vi.fn(() => ({ + ready: Promise.resolve(), + unsubscribe: vi.fn(), + })), + registerCoreSessionRecovery: vi.fn((id: string, handler: () => Promise) => { + recoveryHandlers.set(id, handler); + return () => recoveryHandlers.delete(id); + }), + retryCoreSessionRecovery: vi.fn(() => undefined), +}; + +const staleMessage = { + id: "message-stale", + task_id: taskId("task-1"), + session_id: sessionId("sess-1"), + author_type: "agent", + content: "old content", + type: "message", + created_at: "2026-09-14T12:00:00Z", +} as Message; +const repairedMessage = { ...staleMessage, content: "repaired content" }; +const repairedTurn = { + id: "turn-1", + task_id: taskId("task-1"), + session_id: sessionId("sess-1"), + started_at: "2026-09-14T11:59:00Z", + completed_at: "2026-09-14T12:01:00Z", + created_at: "2026-09-14T11:59:00Z", + updated_at: "2026-09-14T12:01:00Z", + metadata: { model: "repaired" }, +} as Turn; + +const state = { + messages: { + bySession: { "sess-1": [staleMessage] as Message[] }, + metaBySession: { + "sess-1": { + historyInitialized: true, + hasMore: false, + oldestCursor: staleMessage.id, + isLoading: false, + isLoadingMore: false, + }, + }, + }, + taskSessions: { items: { "sess-1": { id: "sess-1", state: "IDLE" } } }, + turns: { + bySession: { "sess-1": [] as Turn[] }, + activeBySession: { "sess-1": null as string | null }, + loadedBySession: { "sess-1": true }, + reconcileEpochBySession: { "sess-1": 0 }, + settledBoundaryBySession: {}, + }, + connection: { status: "connected" }, + mergeMessages: vi.fn(), + setMessagesLoading: vi.fn(), + mergeTurnsSnapshot: vi.fn(), + addTurn: vi.fn(), + markTurnsLoaded: vi.fn(), + reconcileActiveTurnAfterHydration: vi.fn(), + setActiveTurn: vi.fn(), +}; +const store = { getState: () => state }; + +vi.mock("@/lib/api/domains/session-api", () => ({ + listSessionTurns: (...args: unknown[]) => listSessionTurns(...args), +})); + +vi.mock("@/lib/ws/connection", () => ({ + getWebSocketClient: () => webSocketClient, +})); + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (value: typeof state) => unknown) => selector(state), + useAppStoreApi: () => store, +})); + +import { useSessionMessages } from "./use-session-messages"; + +beforeEach(() => { + vi.clearAllMocks(); + recoveryHandlers.clear(); + state.messages.bySession["sess-1"] = [staleMessage]; + state.messages.metaBySession["sess-1"] = { + historyInitialized: true, + hasMore: false, + oldestCursor: staleMessage.id, + isLoading: false, + isLoadingMore: false, + }; + state.turns.bySession["sess-1"] = []; + state.turns.loadedBySession["sess-1"] = true; + state.turns.activeBySession["sess-1"] = null; + state.connection.status = "connected"; + listSessionTurns.mockImplementation(() => + Promise.resolve({ turns: repairedTurn.id === "turn-1" ? [repairedTurn] : [] }), + ); + let messageRequest = 0; + webSocketClient.request.mockImplementation(() => { + messageRequest += 1; + return Promise.resolve({ + messages: messageRequest === 1 ? [staleMessage] : [repairedMessage], + has_more: false, + }); + }); + state.mergeMessages.mockImplementation( + (sessionId: string, messages: Message[], meta?: Record) => { + const messagesBySession = state.messages.bySession as Record; + const metaBySession = state.messages.metaBySession as Record>; + messagesBySession[sessionId] = messages; + Object.assign(metaBySession[sessionId], meta); + }, + ); + state.mergeTurnsSnapshot.mockImplementation( + (sessionId: string, turns: Turn[], _epoch: number, options?: { replace?: boolean }) => { + const turnsBySession = state.turns.bySession as Record; + turnsBySession[sessionId] = options?.replace + ? turns + : [...turnsBySession[sessionId], ...turns]; + (state.turns.loadedBySession as Record)[sessionId] = true; + }, + ); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("core session recovery", () => { + it("rehydrates both core snapshots before resuming after invalid resume", async () => { + const { unmount } = renderHook(() => useSessionMessages("sess-1")); + + await waitFor(() => expect(recoveryHandlers.has("sess-1")).toBe(true)); + const recover = recoveryHandlers.get("sess-1"); + if (!recover) throw new Error("Recovery owner was not registered"); + + await act(async () => { + await expect(recover()).resolves.toBe(true); + }); + + expect(state.messages.bySession["sess-1"]).toEqual([repairedMessage]); + expect(state.turns.bySession["sess-1"]).toEqual([repairedTurn]); + expect(state.mergeMessages).toHaveBeenCalledWith( + "sess-1", + [repairedMessage], + expect.objectContaining({ historyInitialized: true }), + ); + expect(state.mergeTurnsSnapshot).toHaveBeenCalledWith("sess-1", [repairedTurn], 0, { + replace: true, + }); + + unmount(); + }); +}); diff --git a/apps/web/hooks/domains/session/use-session-turns-hydration.test.ts b/apps/web/hooks/domains/session/use-session-turns-hydration.test.ts index 20f09e969d..f2025d16f7 100644 --- a/apps/web/hooks/domains/session/use-session-turns-hydration.test.ts +++ b/apps/web/hooks/domains/session/use-session-turns-hydration.test.ts @@ -211,7 +211,7 @@ describe("ensureSessionTurnsLoaded — hydration and marker", () => { sessionId: string, store: unknown, options: { readiness: Promise }, - ) => Promise + ) => Promise )(SESSION_ID, store, { readiness }); expect(mockListSessionTurns).toHaveBeenCalledTimes(1); @@ -219,6 +219,30 @@ describe("ensureSessionTurnsLoaded — hydration and marker", () => { expect.objectContaining({ id: "turn-after-reconnect" }), ); }); + + it("forces an authoritative replacement even when the current generation is loaded", async () => { + const store = makeStore(); + store.getState().turns.loadedBySession[SESSION_ID] = true; + mockListSessionTurns.mockResolvedValue({ + turns: [makeTurn("turn-repaired")], + total: 1, + }); + + await expect( + ensureSessionTurnsLoaded(SESSION_ID, store as never, { + readiness: Promise.resolve(), + force: true, + replace: true, + }), + ).resolves.toBe(true); + + expect(store.mergeTurnsSnapshot).toHaveBeenCalledWith( + SESSION_ID, + [expect.objectContaining({ id: "turn-repaired" })], + 0, + { replace: true }, + ); + }); }); describe("ensureSessionTurnsLoaded — guards and races", () => { @@ -311,7 +335,7 @@ describe("ensureSessionTurnsLoaded — guards and races", () => { mockListSessionTurns.mockRejectedValue(new Error(BACKEND_DOWN)); const store = makeStore(); - await ensureSessionTurnsLoaded(SESSION_ID, store as never); + await expect(ensureSessionTurnsLoaded(SESSION_ID, store as never)).resolves.toBe(false); // Bounded: no unbounded hammering of a failing endpoint. expect(mockListSessionTurns).toHaveBeenCalledTimes(3); diff --git a/apps/web/hooks/domains/session/use-session-turns-hydration.ts b/apps/web/hooks/domains/session/use-session-turns-hydration.ts index 52ec062c1a..82ef98eef8 100644 --- a/apps/web/hooks/domains/session/use-session-turns-hydration.ts +++ b/apps/web/hooks/domains/session/use-session-turns-hydration.ts @@ -8,7 +8,7 @@ const debug = createDebugLogger("messages:fetch:turns"); // loaded marker lives in the store (`turns.loadedBySession`) because it must // be seeded by SSR hydration too; the module maps deduplicate in-flight work // and associate a successful load with the current subscription readiness. -const inFlightTurnsLoad = new Map>(); +const inFlightTurnsLoad = new Map>(); const lastHydrationReadiness = new Map | undefined>(); // Transient failures (network blip, backend restart) must not leave a @@ -81,17 +81,18 @@ async function fetchAndReconcileSessionTurns( store: ReturnType, sessionId: string, hydrationEpoch: number, + replace: boolean, ): Promise { try { const { turns } = await listSessionTurns(sessionId, { cache: "no-store" }); // Re-check before merging: the session may have been removed while the // request was in flight. const state = store.getState(); - if (!state.taskSessions.items[sessionId]) return true; + if (!state.taskSessions.items[sessionId]) return false; // Merge rows, reconcile the active marker, and record the loaded marker in // one store transaction. This keeps a live WS update from landing between // separate row/marker writes and avoids one Zustand notification per row. - store.getState().mergeTurnsSnapshot(sessionId, turns, hydrationEpoch); + store.getState().mergeTurnsSnapshot(sessionId, turns, hydrationEpoch, { replace }); return true; } catch (err) { debug("turn fetch failed", { sessionId, err }); @@ -124,14 +125,16 @@ async function fetchAndReconcileSessionTurns( export async function ensureSessionTurnsLoaded( sessionId: string, store: ReturnType, - options?: { readiness?: Promise }, -): Promise { + options?: { readiness?: Promise; force?: boolean; replace?: boolean }, +): Promise { const readiness = options?.readiness; + const force = options?.force ?? false; + const replace = options?.replace ?? false; while (true) { const state = store.getState(); if (!state.taskSessions.items[sessionId]) { lastHydrationReadiness.delete(sessionId); - return; + return false; } const loaded = state.turns.loadedBySession[sessionId]; // A caller without a subscription-generation token keeps the historical @@ -139,9 +142,10 @@ export async function ensureSessionTurnsLoaded( // WebSocket subscription, so a reconnect gets one fresh REST snapshot. if ( loaded && + !force && (readiness === undefined || lastHydrationReadiness.get(sessionId) === readiness) ) { - return; + return true; } const inFlight = inFlightTurnsLoad.get(sessionId); @@ -160,16 +164,16 @@ export async function ensureSessionTurnsLoaded( const promise = (async () => { try { for (let attempt = 1; ; attempt++) { - if (await fetchAndReconcileSessionTurns(store, sessionId, hydrationEpoch)) { + if (await fetchAndReconcileSessionTurns(store, sessionId, hydrationEpoch, replace)) { if (store.getState().taskSessions.items[sessionId]) { lastHydrationReadiness.set(sessionId, readiness); } - return; + return true; } debug("turn fetch attempt failed", { sessionId, attempt }); if (attempt >= TURN_HYDRATION_MAX_ATTEMPTS) { scheduleTurnHydrationRecovery(sessionId, store); - return; + return false; } const delay = Math.min( TURN_HYDRATION_RETRY_BASE_MS * 2 ** (attempt - 1), @@ -182,7 +186,6 @@ export async function ensureSessionTurnsLoaded( } })(); inFlightTurnsLoad.set(sessionId, promise); - await promise; - return; + return await promise; } } diff --git a/apps/web/hooks/processed-message-filtering.ts b/apps/web/hooks/processed-message-filtering.ts index fd07501a09..65c0e4af7b 100644 --- a/apps/web/hooks/processed-message-filtering.ts +++ b/apps/web/hooks/processed-message-filtering.ts @@ -317,7 +317,9 @@ function findActiveClarification( messages: Message[], scope?: PendingClarificationScope, ): Message | undefined { - return findPendingClarification(messages, scope) ?? undefined; + const pending = findPendingClarification(messages, scope); + const metadata = pending?.metadata as ClarificationRequestMetadata | undefined; + return metadata?.agent_disconnected === true ? undefined : (pending ?? undefined); } function isClarificationVisible( diff --git a/apps/web/hooks/use-processed-messages.ts b/apps/web/hooks/use-processed-messages.ts index fe8d08cdc4..aa3b8d80d5 100644 --- a/apps/web/hooks/use-processed-messages.ts +++ b/apps/web/hooks/use-processed-messages.ts @@ -151,6 +151,7 @@ export type ProcessedMessagesOptions = { hasOlderMessages?: boolean; lastAgentError?: LastAgentError | null; currentTurnId?: string | null; + currentTurnCompleted?: boolean; pendingAction?: TaskPendingAction | null; }; @@ -475,9 +476,13 @@ function usePendingClarificationState(messages: Message[], options: ProcessedMes const scope = useMemo( () => hasScopeKeys - ? { currentTurnId: options.currentTurnId, pendingAction: options.pendingAction } + ? { + currentTurnId: options.currentTurnId, + currentTurnCompleted: options.currentTurnCompleted, + pendingAction: options.pendingAction, + } : undefined, - [hasScopeKeys, options.currentTurnId, options.pendingAction], + [hasScopeKeys, options.currentTurnCompleted, options.currentTurnId, options.pendingAction], ); return { scope, diff --git a/apps/web/lib/plugins/conversation-event-projection.ts b/apps/web/lib/plugins/conversation-event-projection.ts new file mode 100644 index 0000000000..2caa87e6e1 --- /dev/null +++ b/apps/web/lib/plugins/conversation-event-projection.ts @@ -0,0 +1,104 @@ +import type { RawSessionEvent } from "@/lib/ws/client"; +import type { PluginConversationMessage, PluginConversationTurn } from "./types"; + +export function eventField(event: RawSessionEvent, key: string): unknown { + if (!event.payload || typeof event.payload !== "object" || !(key in event.payload)) { + return undefined; + } + return Reflect.get(event.payload, key); +} + +export function eventString(event: RawSessionEvent, key: string): string | null { + const value = eventField(event, key); + return typeof value === "string" && value !== "" ? value : null; +} + +function compareConversationTimestamps(left: string, right: string): number { + const leftMilliseconds = Date.parse(left); + const rightMilliseconds = Date.parse(right); + if (Number.isFinite(leftMilliseconds) && Number.isFinite(rightMilliseconds)) { + if (leftMilliseconds !== rightMilliseconds) + return leftMilliseconds < rightMilliseconds ? -1 : 1; + const leftFraction = left.match(/\.(\d+)(?:Z|[+-]\d{2}:\d{2})$/)?.[1] ?? ""; + const rightFraction = right.match(/\.(\d+)(?:Z|[+-]\d{2}:\d{2})$/)?.[1] ?? ""; + const leftNanoseconds = leftFraction.padEnd(9, "0").slice(0, 9); + const rightNanoseconds = rightFraction.padEnd(9, "0").slice(0, 9); + if (leftNanoseconds !== rightNanoseconds) { + return leftNanoseconds < rightNanoseconds ? -1 : 1; + } + return 0; + } + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +function compareConversationIds(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +export function compareConversationMessages( + left: Pick, + right: Pick, + sort: "asc" | "desc", +): number { + const created = compareConversationTimestamps(left.createdAt, right.createdAt); + const ordered = created === 0 ? compareConversationIds(left.id, right.id) : created; + return sort === "asc" ? ordered : -ordered; +} + +export function eventMatchesTask(event: RawSessionEvent, taskId: string | null): boolean { + return taskId === null || event.task_id === taskId; +} + +export function messageFromEvent(event: RawSessionEvent): PluginConversationMessage | null { + const id = eventString(event, "message_id"); + const authorType = eventString(event, "author_type"); + const contentValue = eventField(event, "content"); + const content = typeof contentValue === "string" ? contentValue : null; + const createdAt = eventString(event, "created_at"); + const explicitUpdatedAt = eventString(event, "updated_at"); + const updatedAt = explicitUpdatedAt ?? (event.event_type === "message.added" ? createdAt : null); + if ( + !id || + (authorType !== "user" && authorType !== "agent") || + content === null || + !createdAt || + !updatedAt + ) { + return null; + } + const promptIndex = eventField(event, "prompt_index"); + return { + id, + taskId: event.task_id, + sessionId: event.session_id, + turnId: eventString(event, "turn_id") ?? undefined, + authorType, + type: eventString(event, "message_type") ?? "message", + content, + createdAt, + updatedAt, + promptIndex: typeof promptIndex === "number" ? promptIndex : undefined, + senderTaskId: eventString(event, "sender_task_id") ?? undefined, + }; +} + +export function turnFromEvent(event: RawSessionEvent): PluginConversationTurn | null { + const id = eventString(event, "turn_id") ?? eventString(event, "id"); + const startedAt = eventString(event, "started_at"); + const explicitUpdatedAt = eventString(event, "updated_at"); + const updatedAt = + explicitUpdatedAt ?? (event.event_type === "session.turn.started" ? startedAt : null); + if (!id || !startedAt || !updatedAt) return null; + return { + id, + taskId: event.task_id, + sessionId: event.session_id, + startedAt, + completedAt: eventString(event, "completed_at") ?? undefined, + updatedAt, + }; +} diff --git a/apps/web/lib/plugins/conversation-host-isolation.test.tsx b/apps/web/lib/plugins/conversation-host-isolation.test.tsx new file mode 100644 index 0000000000..47fe0df77c --- /dev/null +++ b/apps/web/lib/plugins/conversation-host-isolation.test.tsx @@ -0,0 +1,592 @@ +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RawSessionEvent } from "@/lib/ws/client"; +import type { PluginSessionMessagesState } from "./types"; +import { PluginConversationScopeProvider, pluginConversationApi } from "./conversation-host"; + +const transport = vi.hoisted(() => ({ + request: vi.fn(), + listener: null as ((event: RawSessionEvent) => void) | null, +})); + +vi.mock("@/lib/config", () => ({ getBackendConfig: () => ({ apiBaseUrl: "http://host" }) })); +vi.mock("@/lib/ws/connection", () => ({ + getWebSocketClient: () => ({ + request: transport.request, + onRawSessionEvent(listener: (event: RawSessionEvent) => void) { + transport.listener = listener; + return () => { + if (transport.listener === listener) transport.listener = null; + }; + }, + onConnectionStatus(_listener: (status: "connected" | "disconnected") => void) { + return () => undefined; + }, + }), +})); +const BINDING_PATH_SUFFIX = "/conversation/binding"; +const FAR_FUTURE = "2099-01-01T00:00:00Z"; +const SNAPSHOT_CREATED_AT = "2026-09-07T12:00:00Z"; + +function ScopedHarness({ sessionId, testId }: { sessionId: string; testId: string }) { + const state = pluginConversationApi.useSessionMessages({ sessionId }); + return {String(state.hydrated)}; +} + +let userQueryState: PluginSessionMessagesState | null = null; +let agentQueryState: PluginSessionMessagesState | null = null; +let ascendingState: PluginSessionMessagesState | null = null; + +function AscendingHarness() { + ascendingState = pluginConversationApi.useSessionMessages({ + sessionId: "session-1", + authorTypes: ["agent"], + sort: "asc", + pageSize: 1, + }); + return ( + + {ascendingState.messages.map((item) => item.content).join("|")} + + ); +} + +function TwoQueryHarness() { + userQueryState = pluginConversationApi.useSessionMessages({ + sessionId: "session-1", + authorTypes: ["user"], + sort: "desc", + pageSize: 1, + }); + agentQueryState = pluginConversationApi.useSessionMessages({ + sessionId: "session-1", + authorTypes: ["agent"], + sort: "asc", + pageSize: 2, + }); + return ( + <> + + {userQueryState.messages.map((message) => message.content).join("|")} + + + {agentQueryState.messages.map((message) => message.content).join("|")} + + + ); +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function message(id: string, content: string, createdAt: string, authorType: "user" | "agent") { + return { + id, + taskId: "task-1", + sessionId: "session-1", + authorType, + type: "message", + content, + createdAt, + updatedAt: createdAt, + }; +} + +function liveAgentEvent(sequence: number): RawSessionEvent { + return { + type: "session.event", + protocol_version: 1, + event_type: "message.added", + session_id: "session-1", + task_id: "task-1", + sequence, + event_id: `event-${sequence}`, + payload: { + type: "message.added", + session_id: "session-1", + task_id: "task-1", + message_id: "message-live", + author_type: "agent", + content: "live", + message_type: "message", + created_at: "2026-09-07T12:02:00Z", + }, + }; +} + +function liveMessageDeletedEvent(sequence: number, messageId: string): RawSessionEvent { + return { + type: "session.event", + protocol_version: 1, + event_type: "message.deleted", + session_id: "session-1", + task_id: "task-1", + sequence, + event_id: `event-${sequence}`, + payload: { + type: "message.deleted", + session_id: "session-1", + task_id: "task-1", + message_id: messageId, + }, + }; +} + +function response(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +function renderScope(taskId: string, sessionId: string, testId: string) { + return ( + + + + ); +} + +function resetHarness() { + transport.request.mockReset(); + transport.listener = null; + userQueryState = null; + agentQueryState = null; + ascendingState = null; + transport.request.mockResolvedValue({ + success: true, + snapshot_token: "snapshot-1", + resume_token: "resume-1", + consumer_id: "consumer-1", + event_watermark: 0, + expires_at: FAR_FUTURE, + result: "fresh", + }); + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + if (String(input).endsWith(BINDING_PATH_SUFFIX)) { + return Promise.resolve( + response({ + bindingToken: "binding-1", + generation: 7, + expiresAt: FAR_FUTURE, + }), + ); + } + return Promise.resolve(response({ messages: [], hasMore: false, cursor: null })); + }), + ); +} + +function registerIsolationLifecycle() { + beforeEach(resetHarness); + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); +} + +describe("plugin conversation panel isolation", () => { + registerIsolationLifecycle(); + + it("gives concurrent panels independent consumer identities and snapshots", async () => { + render( + <> + {renderScope("task-1", "session-1", "panel-one")} + {renderScope("task-2", "session-2", "panel-two")} + , + ); + + await waitFor(() => expect(screen.getByTestId("panel-one").textContent).toBe("true")); + await waitFor(() => expect(screen.getByTestId("panel-two").textContent).toBe("true")); + const subscriptions = transport.request.mock.calls.filter( + ([action]) => action === "session.subscribe", + ); + expect(subscriptions).toHaveLength(2); + expect(subscriptions[0][1].consumer_id).not.toBe(subscriptions[1][1].consumer_id); + const fetchMock = vi.mocked(fetch); + expect(fetchMock.mock.calls.some(([input]) => String(input).includes("session-1"))).toBe(true); + expect(fetchMock.mock.calls.some(([input]) => String(input).includes("session-2"))).toBe(true); + }); +}); + +describe("message query snapshot isolation", () => { + registerIsolationLifecycle(); + + it("buffers live events until every distinct message query commits its snapshot", async () => { + const userPage = deferred(); + const agentPage = deferred(); + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith(BINDING_PATH_SUFFIX)) { + return Promise.resolve( + response({ + bindingToken: "binding-1", + generation: 7, + expiresAt: FAR_FUTURE, + }), + ); + } + return url.includes("author_type=user") ? userPage.promise : agentPage.promise; + }), + ); + render( + + + , + ); + await waitFor(() => expect(vi.mocked(fetch)).toHaveBeenCalledTimes(3)); + + await act(async () => { + userPage.resolve( + response({ + messages: [message("message-user", "user snapshot", SNAPSHOT_CREATED_AT, "user")], + hasMore: false, + cursor: null, + }), + ); + await userPage.promise; + }); + act(() => transport.listener?.(liveAgentEvent(1))); + + expect(screen.getByTestId("agent-query").textContent).toBe(""); + expect(transport.request.mock.calls.filter(([action]) => action === "session.ack")).toEqual([]); + + await act(async () => { + agentPage.resolve( + response({ + messages: [message("message-agent", "agent snapshot", "2026-09-07T12:01:00Z", "agent")], + hasMore: false, + cursor: null, + }), + ); + await agentPage.promise; + }); + expect(screen.getByTestId("agent-query").textContent).toBe("agent snapshot|live"); + await waitFor(() => + expect(transport.request.mock.calls.some(([action]) => action === "session.ack")).toBe(true), + ); + }); +}); + +// eslint-disable-next-line max-lines-per-function -- This describe block keeps concurrent query recovery fixtures together. +describe("message continuation isolation", () => { + registerIsolationLifecycle(); + + // eslint-disable-next-line max-lines-per-function -- This scenario covers concurrent queries across the complete expiry rebind. + it("renews concurrent continuations independently for distinct message queries", async () => { + transport.request.mockImplementation((action: string) => + Promise.resolve( + action === "session.subscribe" + ? { + success: true, + snapshot_token: "snapshot-expiring", + resume_token: "resume-1", + consumer_id: "consumer-1", + event_watermark: 0, + expires_at: new Date(Date.now() + 30_000).toISOString(), + result: "fresh", + } + : { success: true }, + ), + ); + const renewalCursors: string[] = []; + const requestedPageCursors: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith(BINDING_PATH_SUFFIX)) { + return Promise.resolve( + response({ + bindingToken: "binding-1", + generation: 7, + expiresAt: FAR_FUTURE, + }), + ); + } + if (url.endsWith("/conversation/continuation/renew")) { + const body = JSON.parse(String(init?.body)) as { cursor: string }; + renewalCursors.push(body.cursor); + return Promise.resolve( + response({ + cursor: `renewed-${body.cursor}`, + snapshot_token: `snapshot-${body.cursor}`, + expires_at: FAR_FUTURE, + }), + ); + } + const parsed = new URL(url); + const author = parsed.searchParams.get("author_type"); + const cursor = parsed.searchParams.get("cursor"); + if (cursor) requestedPageCursors.push(cursor); + return Promise.resolve( + response({ + messages: cursor + ? [] + : [ + message( + `message-${author}`, + `${author} snapshot`, + SNAPSHOT_CREATED_AT, + author === "agent" ? "agent" : "user", + ), + ], + hasMore: !cursor, + cursor: cursor ? null : `cursor-${author}`, + }), + ); + }), + ); + render( + + + , + ); + await waitFor(() => { + expect(userQueryState?.hasMore).toBe(true); + expect(agentQueryState?.hasMore).toBe(true); + }); + + await act(async () => { + await Promise.all([userQueryState!.loadMore(), agentQueryState!.loadMore()]); + }); + + expect(renewalCursors.sort()).toEqual(["cursor-agent", "cursor-user"]); + expect(requestedPageCursors.sort()).toEqual(["renewed-cursor-agent", "renewed-cursor-user"]); + }); + + it("joins expiry recovery while preserving each query's continuation", async () => { + let subscribeCount = 0; + transport.request.mockImplementation((action: string) => { + if (action === "session.subscribe") { + subscribeCount += 1; + return Promise.resolve({ + success: true, + snapshot_token: `snapshot-${subscribeCount}`, + resume_token: `resume-${subscribeCount}`, + consumer_id: `consumer-${subscribeCount}`, + event_watermark: 0, + expires_at: subscribeCount === 1 ? "2020-01-01T00:00:00Z" : FAR_FUTURE, + result: "fresh", + }); + } + return Promise.resolve({ success: true }); + }); + const pageCount = new Map(); + const continuationCursors: string[] = []; + const fetchMock = vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith(BINDING_PATH_SUFFIX)) { + return Promise.resolve( + response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE }), + ); + } + const parsed = new URL(url); + const author = parsed.searchParams.get("author_type") ?? "none"; + const cursor = parsed.searchParams.get("cursor"); + if (cursor) continuationCursors.push(cursor); + const count = (pageCount.get(author) ?? 0) + 1; + pageCount.set(author, count); + if (cursor) { + return Promise.resolve( + response({ + messages: [ + message( + `older-${author}`, + `${author} older`, + SNAPSHOT_CREATED_AT, + author === "agent" ? "agent" : "user", + ), + ], + hasMore: false, + cursor: null, + }), + ); + } + return Promise.resolve( + response({ + messages: [ + message( + `current-${author}`, + `${author} current ${count}`, + SNAPSHOT_CREATED_AT, + author === "agent" ? "agent" : "user", + ), + ], + hasMore: true, + cursor: `${author}-cursor-${count === 1 ? "expired" : "fresh"}`, + }), + ); + }); + vi.stubGlobal("fetch", fetchMock); + render( + + + , + ); + await waitFor(() => { + expect(userQueryState?.hasMore).toBe(true); + expect(agentQueryState?.hasMore).toBe(true); + }); + + await act(async () => { + await Promise.all([userQueryState!.loadMore(), agentQueryState!.loadMore()]); + }); + + expect(subscribeCount).toBe(2); + expect(continuationCursors.sort()).toEqual(["agent-cursor-fresh", "user-cursor-fresh"]); + expect(screen.getByTestId("user-query").textContent).toContain("user older"); + expect(screen.getByTestId("agent-query").textContent).toContain("agent older"); + expect( + fetchMock.mock.calls.some(([input]) => String(input).includes("continuation/renew")), + ).toBe(false); + }); +}); + +describe("ascending message pagination", () => { + registerIsolationLifecycle(); + + it("deduplicates and re-sorts an ascending list after live delivery then loadMore", async () => { + let messagePage = 0; + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith(BINDING_PATH_SUFFIX)) { + return Promise.resolve( + response({ + bindingToken: "binding-1", + generation: 7, + expiresAt: FAR_FUTURE, + }), + ); + } + messagePage += 1; + return Promise.resolve( + response( + messagePage === 1 + ? { + messages: [message("message-base", "base", SNAPSHOT_CREATED_AT, "agent")], + hasMore: true, + cursor: "cursor-1", + } + : { + messages: [ + message("message-live", "stale page", "2026-09-07T12:02:00Z", "agent"), + message("message-middle", "middle", "2026-09-07T12:01:00Z", "agent"), + ], + hasMore: false, + cursor: null, + }, + ), + ); + }), + ); + render( + + + , + ); + await waitFor(() => expect(ascendingState?.hasMore).toBe(true)); + act(() => transport.listener?.(liveAgentEvent(1))); + expect(screen.getByTestId("ascending-query").textContent).toBe("base|live"); + + await act(async () => { + await ascendingState!.loadMore(); + }); + + expect(screen.getByTestId("ascending-query").textContent).toBe("base|middle|live"); + }); +}); +describe("message continuation deletion isolation", () => { + registerIsolationLifecycle(); + + it("replays deletions received during continuation before committing the page", async () => { + const continuationPage = deferred(); + let messagePage = 0; + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith(BINDING_PATH_SUFFIX)) { + return Promise.resolve( + response({ + bindingToken: "binding-1", + generation: 7, + expiresAt: FAR_FUTURE, + }), + ); + } + messagePage += 1; + if (messagePage === 1) { + return Promise.resolve( + response({ + messages: [message("message-base", "base", SNAPSHOT_CREATED_AT, "agent")], + hasMore: true, + cursor: "cursor-1", + }), + ); + } + return continuationPage.promise; + }), + ); + render( + + + , + ); + await waitFor(() => expect(ascendingState?.hasMore).toBe(true)); + + let loadMore!: Promise; + await act(async () => { + loadMore = ascendingState!.loadMore(); + await waitFor(() => expect(messagePage).toBe(2)); + }); + act(() => transport.listener?.(liveMessageDeletedEvent(1, "message-page"))); + continuationPage.resolve( + response({ + messages: [message("message-page", "deleted page row", "2026-09-07T12:01:00Z", "agent")], + hasMore: false, + cursor: null, + }), + ); + await act(async () => { + await loadMore; + }); + + expect(screen.getByTestId("ascending-query").textContent).toBe("base"); + }); +}); diff --git a/apps/web/lib/plugins/conversation-host.test.tsx b/apps/web/lib/plugins/conversation-host.test.tsx new file mode 100644 index 0000000000..760b12178c --- /dev/null +++ b/apps/web/lib/plugins/conversation-host.test.tsx @@ -0,0 +1,1272 @@ +/* eslint-disable max-lines,sonarjs/no-duplicate-string,max-lines-per-function -- This integration fixture covers the full conversation lifecycle with many fixture literals. */ +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RawSessionEvent } from "@/lib/ws/client"; +import type { PluginSessionMessagesState, PluginSessionTurnsState } from "./types"; +import { PluginConversationScopeProvider, pluginConversationApi } from "./conversation-host"; + +const transport = vi.hoisted(() => ({ + request: vi.fn(), + listener: null as ((event: RawSessionEvent) => void) | null, + statusListener: null as ((status: "connected" | "disconnected") => void) | null, +})); + +vi.mock("@/lib/config", () => ({ getBackendConfig: () => ({ apiBaseUrl: "http://host" }) })); +vi.mock("@/lib/ws/connection", () => ({ + getWebSocketClient: () => ({ + request: transport.request, + onRawSessionEvent(listener: (event: RawSessionEvent) => void) { + transport.listener = listener; + return () => { + if (transport.listener === listener) transport.listener = null; + }; + }, + onConnectionStatus(listener: (status: "connected" | "disconnected") => void) { + transport.statusListener = listener; + return () => { + if (transport.statusListener === listener) transport.statusListener = null; + }; + }, + }), +})); + +let currentState: PluginSessionMessagesState | null = null; +let currentTurnsState: PluginSessionTurnsState | null = null; +const SESSION_SUBSCRIBE_ACTION = "session.subscribe"; +const SESSION_ACK_ACTION = "session.ack"; +const BINDING_PATH_SUFFIX = "/conversation/binding"; +const FAR_FUTURE_EXPIRY = "2099-01-01T00:00:00Z"; +const MESSAGE_CREATED_AT = "2026-09-07T12:00:00Z"; +const SESSION_REMOVED_EVENT = "session.removed"; + +function Harness({ sessionId, taskId }: { sessionId: string | null; taskId?: string | null }) { + currentState = pluginConversationApi.useSessionMessages({ + sessionId, + taskId, + authorTypes: ["user"], + sort: "desc", + pageSize: 1, + }); + return ( +
+ + {currentState.messages.map((message) => message.content).join("|")} + + {String(currentState.removed)} + {currentState.error?.code ?? ""} +
+ ); +} + +function TurnsHarness({ sessionId, taskId }: { sessionId: string | null; taskId?: string | null }) { + currentTurnsState = pluginConversationApi.useSessionTurns(sessionId, taskId); + return ( +
+ {currentTurnsState.turns.map((turn) => turn.id).join("|")} + {String(currentTurnsState.removed)} + {currentTurnsState.error?.code ?? ""} +
+ ); +} + +function renderTurnsHarness(sessionId: string | null, taskId?: string | null) { + return render( + + + , + ); +} + +function renderHarness(sessionId: string | null, taskId?: string | null) { + return render( + + + , + ); +} + +function response(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} +const TEST_MESSAGE_ID = "message-1"; +const MESSAGE_ADDED_EVENT = "message.added"; + +function requiredEventPayload(eventType: string): Record { + switch (eventType) { + case MESSAGE_ADDED_EVENT: + return { + message_id: TEST_MESSAGE_ID, + author_type: "user", + content: "", + created_at: MESSAGE_CREATED_AT, + }; + case "message.updated": + return { + message_id: TEST_MESSAGE_ID, + author_type: "user", + content: "", + created_at: MESSAGE_CREATED_AT, + updated_at: MESSAGE_CREATED_AT, + }; + case "message.deleted": + return { message_id: TEST_MESSAGE_ID }; + case "session.turn.started": + return { id: "turn-1", started_at: MESSAGE_CREATED_AT }; + case "session.turn.completed": + return { + id: "turn-1", + started_at: MESSAGE_CREATED_AT, + completed_at: MESSAGE_CREATED_AT, + updated_at: MESSAGE_CREATED_AT, + }; + default: + return {}; + } +} + +function event( + sequence: number, + eventType: string, + payload: Record, + taskId: string | null = "task-1", +): RawSessionEvent { + const requiredPayload = requiredEventPayload(eventType); + return { + type: "session.event", + protocol_version: 1, + event_type: eventType, + session_id: "session-1", + task_id: taskId, + sequence, + event_id: `event-${sequence}`, + payload: { + session_id: "session-1", + ...(taskId !== null ? { task_id: taskId } : {}), + ...requiredPayload, + ...payload, + type: eventType, + }, + }; +} + +beforeEach(() => { + currentState = null; + currentTurnsState = null; + transport.listener = null; + transport.statusListener = null; + transport.request.mockReset(); + transport.request.mockImplementation((action: string) => { + if (action === SESSION_SUBSCRIBE_ACTION) { + return Promise.resolve({ + success: true, + snapshot_token: "snapshot-1", + resume_token: "resume-0", + consumer_id: "consumer-1", + event_watermark: 0, + expires_at: FAR_FUTURE_EXPIRY, + result: "fresh", + }); + } + if (action === SESSION_ACK_ACTION) { + return Promise.resolve({ success: true, resume_token: "resume-next" }); + } + return Promise.resolve({ success: true }); + }); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); +describe("plugin conversation Host facade", () => { + it("keeps nullable sessions empty without transport activity", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + renderHarness(null); + + expect(screen.getByTestId("messages").textContent).toBe(""); + expect(currentState?.hasMore).toBe(false); + expect(currentState?.hydrated).toBe(false); + expect(await currentState?.loadMore()).toBe(0); + expect(fetchMock).not.toHaveBeenCalled(); + expect(transport.request).not.toHaveBeenCalled(); + }); + + it("retries initial binding setup after a transient failure", async () => { + let bindingFetches = 0; + const fetchMock = vi.fn((input: RequestInfo | URL) => { + if (String(input).endsWith(BINDING_PATH_SUFFIX)) { + bindingFetches += 1; + if (bindingFetches === 1) { + return Promise.resolve( + response( + { error: { code: "upstream_failure", message: "temporary", retryable: true } }, + 503, + ), + ); + } + return Promise.resolve( + response({ bindingToken: "binding-2", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }), + ); + } + return Promise.resolve(response({ messages: [], hasMore: false, cursor: null })); + }); + vi.stubGlobal("fetch", fetchMock); + renderHarness("session-1"); + await waitFor(() => expect(currentState?.error?.retryable).toBe(true)); + + act(() => currentState?.retry()); + + await waitFor(() => expect(currentState?.hydrated).toBe(true)); + expect(bindingFetches).toBe(2); + }); + + it("retries initial ordered subscription after a transient failure", async () => { + const fetchMock = vi.fn((input: RequestInfo | URL) => + String(input).endsWith(BINDING_PATH_SUFFIX) + ? Promise.resolve( + response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }), + ) + : Promise.resolve(response({ messages: [], hasMore: false, cursor: null })), + ); + vi.stubGlobal("fetch", fetchMock); + transport.request.mockRejectedValueOnce(new Error("temporary subscribe failure")); + renderHarness("session-1"); + await waitFor(() => expect(currentState?.error?.retryable).toBe(true)); + + act(() => currentState?.retry()); + + await waitFor(() => expect(currentState?.hydrated).toBe(true)); + expect( + transport.request.mock.calls.filter(([action]) => action === SESSION_SUBSCRIBE_ACTION), + ).toHaveLength(2); + }); + + it("commits the authorized snapshot before projecting and acknowledging ordered live events", async () => { + const fetchMock = vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith(BINDING_PATH_SUFFIX)) { + return Promise.resolve( + response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }), + ); + } + return Promise.resolve( + response({ + messages: [ + { + id: "message-1", + taskId: "task-1", + sessionId: "session-1", + authorType: "user", + type: "message", + content: "original", + createdAt: MESSAGE_CREATED_AT, + updatedAt: MESSAGE_CREATED_AT, + }, + ], + hasMore: false, + cursor: null, + }), + ); + }); + vi.stubGlobal("fetch", fetchMock); + + renderHarness("session-1"); + await waitFor(() => expect(screen.getByTestId("messages").textContent).toBe("original")); + expect(currentState?.hydrated).toBe(true); + + act(() => { + transport.listener?.( + event(1, "message.updated", { + message_id: "message-1", + author_type: "user", + content: "updated", + message_type: "message", + created_at: MESSAGE_CREATED_AT, + updated_at: "2026-09-07T12:01:00Z", + }), + ); + }); + expect(screen.getByTestId("messages").textContent).toBe("updated"); + await waitFor(() => + expect(transport.request).toHaveBeenCalledWith( + SESSION_ACK_ACTION, + expect.objectContaining({ sequence: 1 }), + ), + ); + await act(async () => { + await Promise.resolve(); + }); + const initialConsumerId = "consumer-1"; + + act(() => { + transport.statusListener?.("disconnected"); + }); + act(() => { + transport.statusListener?.("connected"); + }); + await waitFor(() => + expect(transport.request).toHaveBeenCalledWith( + SESSION_SUBSCRIBE_ACTION, + expect.objectContaining({ + consumer_id: initialConsumerId, + last_seen_sequence: 1, + resume_token: "resume-next", + }), + ), + ); + act(() => { + transport.listener?.(event(2, "message.deleted", { message_id: "message-1" })); + transport.listener?.(event(3, SESSION_REMOVED_EVENT, {}, null)); + }); + expect(screen.getByTestId("messages").textContent).toBe("updated"); + expect(screen.getByTestId("removed").textContent).toBe("true"); + expect(currentState?.hasMore).toBe(false); + }); + + it("recovers an expired continuation without remount and continues the load", async () => { + let subscribeCount = 0; + transport.request.mockImplementation((action: string) => { + if (action === SESSION_SUBSCRIBE_ACTION) { + subscribeCount += 1; + return Promise.resolve({ + success: true, + snapshot_token: `snapshot-${subscribeCount}`, + resume_token: `resume-${subscribeCount}`, + consumer_id: `consumer-${subscribeCount}`, + event_watermark: 0, + expires_at: subscribeCount === 1 ? "2020-01-01T00:00:00Z" : FAR_FUTURE_EXPIRY, + result: "fresh", + }); + } + if (action === SESSION_ACK_ACTION) { + return Promise.resolve({ success: true, resume_token: "resume-next" }); + } + return Promise.resolve({ success: true }); + }); + + let messagePages = 0; + const fetchMock = vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith(BINDING_PATH_SUFFIX)) { + return Promise.resolve( + response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }), + ); + } + if (url.endsWith("/conversation/continuation/renew")) { + return Promise.resolve( + response({ error: { code: "invalid_query", message: "expired", retryable: false } }, 400), + ); + } + messagePages += 1; + const cursor = new URL(url).searchParams.get("cursor"); + if (!cursor) { + return Promise.resolve( + response({ + messages: [ + { + id: "message-newest", + taskId: "task-1", + sessionId: "session-1", + authorType: "user", + type: "message", + content: `newest-page-${messagePages}`, + createdAt: MESSAGE_CREATED_AT, + updatedAt: MESSAGE_CREATED_AT, + }, + ], + hasMore: true, + cursor: messagePages === 1 ? "cursor-expired" : "cursor-fresh", + }), + ); + } + expect(cursor).toBe("cursor-fresh"); + return Promise.resolve( + response({ + messages: [ + { + id: "message-older", + taskId: "task-1", + sessionId: "session-1", + authorType: "user", + type: "message", + content: "older-page", + createdAt: "2026-09-07T11:59:00Z", + updatedAt: "2026-09-07T11:59:00Z", + }, + ], + hasMore: false, + cursor: null, + }), + ); + }); + vi.stubGlobal("fetch", fetchMock); + + renderHarness("session-1"); + await waitFor(() => expect(currentState?.hasMore).toBe(true)); + await expect(currentState?.loadMore()).resolves.toBe(1); + + expect(subscribeCount).toBe(2); + expect(fetchMock).not.toHaveBeenCalledWith( + expect.stringContaining("/conversation/continuation/renew"), + expect.anything(), + ); + await waitFor(() => expect(screen.getByTestId("messages").textContent).toContain("older-page")); + }); +}); + +describe("strict ordered event validation poisons instead of projecting", () => { + function stubEmptySnapshot() { + const fetchMock = vi.fn((input: RequestInfo | URL) => + String(input).endsWith(BINDING_PATH_SUFFIX) + ? Promise.resolve( + response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }), + ) + : Promise.resolve(response({ messages: [], hasMore: false, cursor: null })), + ); + vi.stubGlobal("fetch", fetchMock); + } + + it("does not treat a removal with a mismatched payload session_id as terminal", async () => { + stubEmptySnapshot(); + renderHarness("session-1"); + await waitFor(() => expect(currentState?.hydrated).toBe(true)); + + act(() => { + transport.listener?.(event(1, SESSION_REMOVED_EVENT, { session_id: "session-other" }, null)); + }); + expect(screen.getByTestId("removed").textContent).toBe("false"); + await waitFor(() => + expect(transport.request).toHaveBeenCalledWith( + SESSION_SUBSCRIBE_ACTION, + expect.objectContaining({ + replace_cursor: true, + consumer_kind: "plugin", + plugin_id: "plugin-history", + session_id: "session-1", + }), + ), + ); + expect(screen.getByTestId("removed").textContent).toBe("false"); + }); + + it("projects attachment-only messages with empty content through add and update", async () => { + stubEmptySnapshot(); + renderHarness("session-1"); + await waitFor(() => expect(currentState?.hydrated).toBe(true)); + + act(() => { + transport.listener?.( + event(1, "message.added", { + message_id: "message-attachment-only", + content: "", + created_at: MESSAGE_CREATED_AT, + }), + ); + }); + expect(currentState?.messages).toHaveLength(1); + expect(currentState?.messages[0]?.content).toBe(""); + + act(() => { + transport.listener?.( + event(2, "message.updated", { + message_id: "message-attachment-only", + content: "", + created_at: MESSAGE_CREATED_AT, + updated_at: "2026-09-07T12:01:00Z", + }), + ); + }); + expect(currentState?.messages).toHaveLength(1); + expect(currentState?.messages[0]).toMatchObject({ + id: "message-attachment-only", + content: "", + updatedAt: "2026-09-07T12:01:00Z", + }); + }); + + it("does not let a retry page overwrite a live event projected mid-fetch", async () => { + let resolveRetryPage: ((value: Response) => void) | undefined; + const retryPage = new Promise((resolve) => { + resolveRetryPage = resolve; + }); + let messageFetches = 0; + const fetchMock = vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith(BINDING_PATH_SUFFIX)) { + return Promise.resolve( + response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }), + ); + } + messageFetches += 1; + if (messageFetches === 1) { + return Promise.resolve( + response({ + messages: [ + { + id: "message-original", + taskId: "task-1", + sessionId: "session-1", + authorType: "user", + type: "message", + content: "original", + createdAt: MESSAGE_CREATED_AT, + updatedAt: MESSAGE_CREATED_AT, + }, + ], + hasMore: true, + cursor: "cursor-1", + }), + ); + } + if (messageFetches === 2) { + return Promise.reject(new TypeError("network down")); + } + return retryPage; + }); + vi.stubGlobal("fetch", fetchMock); + renderHarness("session-1"); + await waitFor(() => expect(screen.getByTestId("messages").textContent).toBe("original")); + + await expect(currentState?.loadMore()).rejects.toBeTruthy(); + await waitFor(() => expect(currentState?.error?.retryable).toBe(true)); + + act(() => currentState?.retry()); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)); + + // A live update lands while the retry page is still in flight. + act(() => { + transport.listener?.( + event(1, "message.added", { + message_id: "message-live", + content: "live-during-retry", + created_at: "2026-09-07T12:00:01Z", + updated_at: "2026-09-07T12:00:01Z", + }), + ); + }); + expect(screen.getByTestId("messages").textContent).toBe(""); + + await act(async () => { + resolveRetryPage?.( + response({ + messages: [ + { + id: "message-original", + taskId: "task-1", + sessionId: "session-1", + authorType: "user", + type: "message", + content: "original", + createdAt: MESSAGE_CREATED_AT, + updatedAt: MESSAGE_CREATED_AT, + }, + ], + hasMore: false, + cursor: null, + }), + ); + await Promise.resolve(); + }); + await waitFor(() => + expect(screen.getByTestId("messages").textContent).toContain("live-during-retry"), + ); + expect(screen.getByTestId("messages").textContent).toContain("original"); + }); + + it("projects live events from the subscribe watermark, not sequence one", async () => { + const fetchMock = vi.fn((input: RequestInfo | URL) => + String(input).endsWith(BINDING_PATH_SUFFIX) + ? Promise.resolve( + response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }), + ) + : Promise.resolve(response({ messages: [], hasMore: false, cursor: null })), + ); + vi.stubGlobal("fetch", fetchMock); + // An existing non-empty session subscribes at event_watermark 3; live + // frames therefore start at sequence 4 and must not park waiting for 1. + transport.request.mockImplementation((action: string) => { + if (action === SESSION_SUBSCRIBE_ACTION) { + return Promise.resolve({ + success: true, + snapshot_token: "snapshot-1", + resume_token: "resume-0", + consumer_id: "consumer-1", + event_watermark: 3, + expires_at: FAR_FUTURE_EXPIRY, + result: "fresh", + }); + } + if (action === SESSION_ACK_ACTION) { + return Promise.resolve({ success: true, resume_token: "resume-next" }); + } + return Promise.resolve({ success: true }); + }); + renderHarness("session-1"); + await waitFor(() => expect(currentState?.hydrated).toBe(true)); + + act(() => { + transport.listener?.( + event(4, "message.added", { message_id: "message-4", content: "live-four" }), + ); + }); + expect(screen.getByTestId("messages").textContent).toBe("live-four"); + }); + + it("accepts session.removed whose task_id differs from the session task", async () => { + stubEmptySnapshot(); + renderHarness("session-1"); + await waitFor(() => expect(currentState?.hydrated).toBe(true)); + + act(() => { + transport.listener?.(event(1, SESSION_REMOVED_EVENT, { task_id: "task-moved" }, null)); + }); + expect(screen.getByTestId("removed").textContent).toBe("true"); + }); + + it("does not resurrect rows from events delivered after terminal removal", async () => { + stubEmptySnapshot(); + renderHarness("session-1"); + await waitFor(() => expect(currentState?.hydrated).toBe(true)); + + act(() => { + transport.listener?.(event(1, SESSION_REMOVED_EVENT, {}, null)); + transport.listener?.(event(2, "message.added", { message_id: "late", content: "late" })); + }); + expect(screen.getByTestId("removed").textContent).toBe("true"); + expect(screen.getByTestId("messages").textContent).toBe(""); + }); + + it("retains projected messages when session removal follows message deletions", async () => { + stubEmptySnapshot(); + renderHarness("session-1"); + await waitFor(() => expect(currentState?.hydrated).toBe(true)); + + act(() => { + transport.listener?.( + event(1, MESSAGE_ADDED_EVENT, { + message_id: "message-terminal", + content: "retained after removal", + }), + ); + }); + await waitFor(() => + expect(screen.getByTestId("messages").textContent).toBe("retained after removal"), + ); + + act(() => { + transport.listener?.(event(2, "message.deleted", { message_id: "message-terminal" })); + transport.listener?.(event(3, SESSION_REMOVED_EVENT, {}, null)); + }); + + expect(screen.getByTestId("messages").textContent).toBe("retained after removal"); + expect(screen.getByTestId("removed").textContent).toBe("true"); + }); + + it("recovers through a durable rebind after a message with a missing required id", async () => { + stubEmptySnapshot(); + renderHarness("session-1"); + await waitFor(() => expect(currentState?.hydrated).toBe(true)); + + act(() => { + transport.listener?.(event(1, "message.added", { message_id: "", content: "no-id" })); + }); + expect(screen.getByTestId("messages").textContent).toBe(""); + await waitFor(() => + expect(transport.request).toHaveBeenCalledWith( + SESSION_SUBSCRIBE_ACTION, + expect.objectContaining({ replace_cursor: true }), + ), + ); + await act(async () => { + await Promise.resolve(); + }); + + act(() => { + transport.listener?.(event(1, SESSION_REMOVED_EVENT, {}, null)); + }); + expect(screen.getByTestId("removed").textContent).toBe("true"); + }); +}); +it("does not fetch after terminal removal even through a captured loadMore callback", async () => { + const fetchMock = vi.fn((input: RequestInfo | URL) => + String(input).endsWith(BINDING_PATH_SUFFIX) + ? Promise.resolve( + response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }), + ) + : Promise.resolve(response({ messages: [], hasMore: true, cursor: "cursor-1" })), + ); + vi.stubGlobal("fetch", fetchMock); + renderHarness("session-1"); + await waitFor(() => expect(currentState?.hasMore).toBe(true)); + const loadMore = currentState?.loadMore; + act(() => transport.listener?.(event(1, SESSION_REMOVED_EVENT, {}, null))); + await expect(loadMore?.()).resolves.toBe(0); + expect(fetchMock).toHaveBeenCalledTimes(2); +}); + +it("commits terminal removal while the initial snapshot is still pending", async () => { + let resolveSnapshot: ((value: Response) => void) | undefined; + const snapshot = new Promise((resolve) => { + resolveSnapshot = resolve; + }); + const fetchMock = vi.fn((input: RequestInfo | URL) => { + if (String(input).endsWith(BINDING_PATH_SUFFIX)) { + return Promise.resolve( + response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }), + ); + } + return snapshot; + }); + vi.stubGlobal("fetch", fetchMock); + renderHarness("session-1"); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + + act(() => { + transport.listener?.(event(1, SESSION_REMOVED_EVENT, {}, null)); + }); + expect(screen.getByTestId("removed").textContent).toBe("true"); + + await act(async () => { + resolveSnapshot?.( + response({ + messages: [ + { + id: "message-late", + taskId: "task-1", + sessionId: "session-1", + authorType: "user", + type: "message", + content: "must not resurrect", + createdAt: MESSAGE_CREATED_AT, + updatedAt: MESSAGE_CREATED_AT, + }, + ], + hasMore: true, + cursor: "late-cursor", + }), + ); + await snapshot; + }); + expect(screen.getByTestId("removed").textContent).toBe("true"); + expect(screen.getByTestId("error").textContent).toBe(""); + expect(screen.getByTestId("messages").textContent).toBe(""); +}); +it("replays add, update, and delete events after a blocked snapshot without resurrection", async () => { + let resolveSnapshot: ((value: Response) => void) | undefined; + const snapshot = new Promise((resolve) => { + resolveSnapshot = resolve; + }); + const fetchMock = vi.fn((input: RequestInfo | URL) => { + if (String(input).endsWith(BINDING_PATH_SUFFIX)) { + return Promise.resolve( + response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }), + ); + } + return snapshot; + }); + vi.stubGlobal("fetch", fetchMock); + renderHarness("session-1"); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + + act(() => { + transport.listener?.( + event(1, MESSAGE_ADDED_EVENT, { + message_id: "message-new", + author_type: "user", + content: "temporary", + message_type: "message", + created_at: "2026-09-07T12:02:00Z", + }), + ); + transport.listener?.( + event(2, "message.updated", { + message_id: "message-base", + author_type: "user", + content: "updated", + message_type: "message", + created_at: MESSAGE_CREATED_AT, + updated_at: "2026-09-07T12:03:00Z", + }), + ); + transport.listener?.(event(3, "message.deleted", { message_id: "message-new" })); + }); + expect(screen.getByTestId("messages").textContent).toBe(""); + + await act(async () => { + resolveSnapshot?.( + response({ + messages: [ + { + id: "message-base", + taskId: "task-1", + sessionId: "session-1", + authorType: "user", + type: "message", + content: "original", + createdAt: MESSAGE_CREATED_AT, + updatedAt: MESSAGE_CREATED_AT, + }, + ], + hasMore: false, + cursor: null, + }), + ); + await snapshot; + }); + + expect(screen.getByTestId("messages").textContent).toBe("updated"); + await waitFor(() => + expect(transport.request).toHaveBeenCalledWith( + SESSION_ACK_ACTION, + expect.objectContaining({ sequence: 3 }), + ), + ); +}); +it("keeps turns terminal when removal wins the initial snapshot race", async () => { + let resolveSnapshot: ((value: Response) => void) | undefined; + const snapshot = new Promise((resolve) => { + resolveSnapshot = resolve; + }); + const fetchMock = vi.fn((input: RequestInfo | URL) => { + if (String(input).endsWith(BINDING_PATH_SUFFIX)) { + return Promise.resolve( + response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }), + ); + } + return snapshot; + }); + vi.stubGlobal("fetch", fetchMock); + renderTurnsHarness("session-1"); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + + act(() => { + transport.listener?.(event(1, SESSION_REMOVED_EVENT, {}, null)); + }); + expect(screen.getByTestId("turns-removed").textContent).toBe("true"); + + await act(async () => { + resolveSnapshot?.( + response({ + turns: [ + { + id: "turn-late", + taskId: "task-1", + sessionId: "session-1", + startedAt: MESSAGE_CREATED_AT, + updatedAt: MESSAGE_CREATED_AT, + }, + ], + }), + ); + await snapshot; + }); + expect(screen.getByTestId("turns-removed").textContent).toBe("true"); + expect(screen.getByTestId("turns-error").textContent).toBe(""); + expect(screen.getByTestId("turns").textContent).toBe(""); +}); +describe("plugin conversation ordering and binding lifecycle", () => { + it("holds a forward sequence gap and drains events when the missing sequence arrives", async () => { + const fetchMock = vi.fn((input: RequestInfo | URL) => { + if (String(input).endsWith(BINDING_PATH_SUFFIX)) { + return Promise.resolve( + response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }), + ); + } + return Promise.resolve(response({ messages: [], hasMore: false, cursor: null })); + }); + vi.stubGlobal("fetch", fetchMock); + renderHarness("session-1"); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + + act(() => { + transport.listener?.( + event(2, MESSAGE_ADDED_EVENT, { + message_id: "message-two", + author_type: "user", + content: "second", + message_type: "message", + created_at: "2026-09-07T12:02:00Z", + }), + ); + }); + expect(screen.getByTestId("messages").textContent).toBe(""); + expect(transport.request.mock.calls.some(([action]) => action === SESSION_ACK_ACTION)).toBe( + false, + ); + + act(() => { + transport.listener?.( + event(1, MESSAGE_ADDED_EVENT, { + message_id: "message-one", + author_type: "user", + content: "first", + message_type: "message", + created_at: "2026-09-07T12:01:00Z", + }), + ); + }); + + expect(screen.getByTestId("messages").textContent).toBe("second|first"); + await waitFor(() => + expect(transport.request).toHaveBeenCalledWith( + SESSION_ACK_ACTION, + expect.objectContaining({ sequence: 2 }), + ), + ); + }); + + it("refreshes a loader binding and atomically replaces a superseded generation", async () => { + let bindingReads = 0; + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).endsWith(BINDING_PATH_SUFFIX)) { + bindingReads += 1; + const expiresAt = + bindingReads === 1 ? new Date(Date.now() + 60_000).toISOString() : FAR_FUTURE_EXPIRY; + return Promise.resolve( + response({ + bindingToken: `binding-${bindingReads}`, + generation: bindingReads + 6, + expiresAt, + }), + ); + } + expect(new Headers(init?.headers).get("X-Kandev-Plugin-Binding")).toBe("binding-2"); + return Promise.resolve(response({ messages: [], hasMore: false, cursor: null })); + }); + vi.stubGlobal("fetch", fetchMock); + + renderHarness("session-1"); + await waitFor(() => expect(currentState?.loading).toBe(false)); + + expect(bindingReads).toBe(2); + const subscribeGenerations = transport.request.mock.calls + .filter(([action]) => action === SESSION_SUBSCRIBE_ACTION) + .map(([, request]) => request.generation); + expect(subscribeGenerations).toEqual([7, 8]); + expect(transport.request).toHaveBeenCalledWith( + "session.unsubscribe", + expect.objectContaining({ generation: 7 }), + ); + }); +}); + +it("atomically rebinds and replaces snapshots past a poison event", async () => { + let snapshotReads = 0; + let subscribes = 0; + transport.request.mockImplementation((action: string) => { + if (action === SESSION_SUBSCRIBE_ACTION) { + subscribes += 1; + return Promise.resolve({ + success: true, + snapshot_token: `snapshot-${subscribes}`, + resume_token: `resume-${subscribes}`, + consumer_id: "consumer-1", + event_watermark: subscribes - 1, + expires_at: FAR_FUTURE_EXPIRY, + result: "fresh", + }); + } + if (action === SESSION_ACK_ACTION) { + return Promise.resolve({ success: true, resume_token: "resume-next" }); + } + return Promise.resolve({ success: true }); + }); + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + if (String(input).endsWith(BINDING_PATH_SUFFIX)) { + return Promise.resolve( + response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }), + ); + } + snapshotReads += 1; + return Promise.resolve( + response({ + messages: [ + { + id: "message-1", + taskId: "task-1", + sessionId: "session-1", + authorType: "user", + type: "message", + content: snapshotReads === 1 ? "before poison" : "after poison", + createdAt: MESSAGE_CREATED_AT, + updatedAt: MESSAGE_CREATED_AT, + }, + ], + hasMore: false, + cursor: null, + }), + ); + }), + ); + + renderHarness("session-1"); + await waitFor(() => expect(screen.getByTestId("messages").textContent).toBe("before poison")); + + act(() => transport.listener?.(event(1, "unknown.event", {}))); + + await waitFor(() => expect(screen.getByTestId("messages").textContent).toBe("after poison")); + expect(subscribes).toBe(2); + expect(transport.request.mock.calls.some(([action]) => action === SESSION_ACK_ACTION)).toBe( + false, + ); +}); + +it("surfaces terminal removal after an earlier poison event without advancing ACK", async () => { + const fetchMock = vi.fn((input: RequestInfo | URL) => { + if (String(input).endsWith(BINDING_PATH_SUFFIX)) { + return Promise.resolve( + response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }), + ); + } + return Promise.resolve(response({ messages: [], hasMore: false, cursor: null })); + }); + vi.stubGlobal("fetch", fetchMock); + renderHarness("session-1"); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + + act(() => { + transport.listener?.(event(1, "unknown.event", {})); + transport.listener?.(event(2, SESSION_REMOVED_EVENT, {}, null)); + }); + + expect(screen.getByTestId("removed").textContent).toBe("true"); + expect(transport.request.mock.calls.some(([action]) => action === SESSION_ACK_ACTION)).toBe( + false, + ); +}); + +describe("plugin conversation pagination", () => { + it("joins concurrent requests for one older-page continuation", async () => { + let page = 0; + const fetchMock = vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith(BINDING_PATH_SUFFIX)) { + return Promise.resolve( + response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }), + ); + } + page += 1; + return Promise.resolve( + response({ + messages: [ + { + id: `message-${page}`, + taskId: "task-1", + sessionId: "session-1", + authorType: "user", + type: "message", + content: `page-${page}`, + createdAt: `2026-09-07T12:0${page}:00Z`, + updatedAt: `2026-09-07T12:0${page}:00Z`, + }, + ], + hasMore: page === 1, + cursor: page === 1 ? "cursor-1" : null, + }), + ); + }); + vi.stubGlobal("fetch", fetchMock); + + renderHarness("session-1"); + await waitFor(() => expect(currentState?.hasMore).toBe(true)); + let counts: number[] = []; + const loadMore = currentState!.loadMore; + await act(async () => { + counts = await Promise.all([loadMore(), loadMore()]); + }); + + expect(page).toBe(2); + expect(counts).toEqual([1, 1]); + expect(screen.getByTestId("messages").textContent).toContain("page-2"); + }); +}); + +describe("ordered turns convergence", () => { + function stubTurnsFetch() { + const fetchMock = vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith(BINDING_PATH_SUFFIX)) { + return Promise.resolve( + response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }), + ); + } + return Promise.resolve(response({ turns: [], hasMore: false, cursor: null })); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; + } + + it("drops a turn from live state when session.turn.removed arrives", async () => { + stubTurnsFetch(); + renderTurnsHarness("session-1"); + await waitFor(() => expect(currentTurnsState?.hydrated).toBe(true)); + + act(() => { + transport.listener?.( + event(1, "session.turn.started", { + id: "turn-1", + started_at: MESSAGE_CREATED_AT, + completed_at: null, + created_at: MESSAGE_CREATED_AT, + updated_at: MESSAGE_CREATED_AT, + }), + ); + transport.listener?.( + event(2, "session.turn.completed", { + id: "turn-1", + started_at: MESSAGE_CREATED_AT, + completed_at: MESSAGE_CREATED_AT, + created_at: MESSAGE_CREATED_AT, + updated_at: MESSAGE_CREATED_AT, + }), + ); + }); + await waitFor(() => expect(currentTurnsState?.turns).toHaveLength(1)); + + act(() => { + transport.listener?.(event(3, "session.turn.removed", { id: "turn-1" })); + }); + expect(currentTurnsState?.turns).toHaveLength(0); + expect(currentTurnsState?.hydrated).toBe(true); + expect(screen.getByTestId("turns-removed").textContent).toBe("false"); + }); + + it("retains projected turns when session removal follows turn deletions", async () => { + stubTurnsFetch(); + renderTurnsHarness("session-1"); + await waitFor(() => expect(currentTurnsState?.hydrated).toBe(true)); + + act(() => { + transport.listener?.( + event(1, "session.turn.started", { + id: "turn-terminal", + started_at: MESSAGE_CREATED_AT, + updated_at: MESSAGE_CREATED_AT, + }), + ); + transport.listener?.( + event(2, "session.turn.completed", { + id: "turn-terminal", + started_at: MESSAGE_CREATED_AT, + completed_at: MESSAGE_CREATED_AT, + updated_at: MESSAGE_CREATED_AT, + }), + ); + }); + await waitFor(() => expect(currentTurnsState?.turns).toHaveLength(1)); + + act(() => { + transport.listener?.(event(3, "session.turn.removed", { id: "turn-terminal" })); + transport.listener?.(event(4, SESSION_REMOVED_EVENT, {}, null)); + }); + + expect(currentTurnsState?.turns.map((turn) => turn.id)).toEqual(["turn-terminal"]); + expect(screen.getByTestId("turns-removed").textContent).toBe("true"); + }); + + it.each([ + { name: "inherited task", selectedTaskId: undefined, removalTaskId: "task-2", removed: false }, + { name: "explicit task", selectedTaskId: "task-1", removalTaskId: "task-1", removed: true }, + { name: "session-wide scope", selectedTaskId: null, removalTaskId: "task-2", removed: true }, + ])( + "applies turn removals only within $name", + async ({ selectedTaskId, removalTaskId, removed }) => { + stubTurnsFetch(); + renderTurnsHarness("session-1", selectedTaskId); + await waitFor(() => expect(currentTurnsState?.hydrated).toBe(true)); + act(() => { + transport.listener?.( + event(1, "session.turn.started", { + id: "turn-1", + started_at: MESSAGE_CREATED_AT, + completed_at: null, + created_at: MESSAGE_CREATED_AT, + updated_at: MESSAGE_CREATED_AT, + }), + ); + }); + await waitFor(() => expect(currentTurnsState?.turns).toHaveLength(1)); + + act(() => { + transport.listener?.(event(2, "session.turn.removed", { id: "turn-1" }, removalTaskId)); + }); + + expect(currentTurnsState?.turns).toHaveLength(removed ? 0 : 1); + }, + ); + + it("does not let a retry turns page overwrite a live completion mid-fetch", async () => { + let resolveRetry: ((value: Response) => void) | undefined; + const retryPage = new Promise((resolve) => { + resolveRetry = resolve; + }); + let turnFetches = 0; + const fetchMock = vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith(BINDING_PATH_SUFFIX)) { + return Promise.resolve( + response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }), + ); + } + turnFetches += 1; + if (turnFetches === 1) { + return Promise.reject(new TypeError("network down")); + } + return retryPage; + }); + vi.stubGlobal("fetch", fetchMock); + renderTurnsHarness("session-1"); + await waitFor(() => expect(currentTurnsState?.error?.retryable).toBe(true)); + + // Retry starts a fresh page that is still in flight when live events land. + act(() => currentTurnsState?.retry()); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + + act(() => { + transport.listener?.( + event(1, "session.turn.started", { + id: "turn-1", + started_at: MESSAGE_CREATED_AT, + completed_at: null, + created_at: MESSAGE_CREATED_AT, + updated_at: MESSAGE_CREATED_AT, + }), + ); + transport.listener?.( + event(2, "session.turn.completed", { + id: "turn-1", + started_at: MESSAGE_CREATED_AT, + completed_at: MESSAGE_CREATED_AT, + created_at: MESSAGE_CREATED_AT, + updated_at: MESSAGE_CREATED_AT, + }), + ); + }); + + await act(async () => { + resolveRetry?.(response({ turns: [], hasMore: false, cursor: null })); + await Promise.resolve(); + }); + // The buffered live events drain on top of the (empty) retry page instead + // of being lost, and the completion survives the page assignment. + await waitFor(() => + expect(currentTurnsState?.turns.find((turn) => turn.id === "turn-1")?.completedAt).toBe( + MESSAGE_CREATED_AT, + ), + ); + }); +}); diff --git a/apps/web/lib/plugins/conversation-host.tsx b/apps/web/lib/plugins/conversation-host.tsx new file mode 100644 index 0000000000..0dbbe8dcbe --- /dev/null +++ b/apps/web/lib/plugins/conversation-host.tsx @@ -0,0 +1,896 @@ +/* eslint-disable max-lines -- The facade hook centralizes ordered snapshot/live state coordination. */ +"use client"; + +import * as React from "react"; +import { useMessageFavoritesStore } from "@/lib/state/slices/message-favorites"; +import { + compareConversationMessages, + eventMatchesTask, + eventString, + messageFromEvent, + turnFromEvent, +} from "./conversation-event-projection"; +import type { + PluginConversationApi, + PluginConversationError, + PluginConversationMessage, + PluginConversationTurn, + PluginSessionMessagesQuery, + PluginSessionMessagesState, + PluginSessionTurnsState, +} from "./types"; +import { + ConversationScopeContext, + parseConversationResponse, + pluginConversationUrl, + type ConversationScope, +} from "./conversation-scope"; +export { PluginConversationScopeProvider } from "./conversation-scope"; + +type MessagePage = { + messages: PluginConversationMessage[]; + hasMore: boolean; + cursor: string | null; +}; + +type TurnsPage = { turns: PluginConversationTurn[] }; + +const EMPTY_MESSAGES: PluginSessionMessagesState = { + messages: [], + loading: false, + hydrated: false, + loadingMore: false, + error: null, + hasMore: false, + removed: false, + loadMore: async () => 0, + retry: () => undefined, +}; + +const EMPTY_TURNS: PluginSessionTurnsState = { + turns: [], + loading: false, + hydrated: false, + error: null, + removed: false, + retry: () => undefined, +}; + +function conversationError(error: unknown): PluginConversationError { + if (typeof error === "object" && error !== null && "code" in error) { + return error as PluginConversationError; + } + return { + code: "upstream_failure", + // i18n-exempt: plugin-facing transport diagnostic; plugins render their own localized error UI. + message: error instanceof Error ? error.message : "Conversation request failed", + retryable: true, + }; +} + +function isRemovedConversationError(error: PluginConversationError): boolean { + return error.code === "not_found"; +} + +function resolveTaskId( + scope: ConversationScope, + taskId: string | null | undefined, +): { taskId: string | null; error: PluginConversationError | null } { + const resolved = taskId === undefined ? scope.taskId : taskId; + if (resolved === "" || (typeof resolved === "string" && resolved !== scope.taskId)) { + return { + taskId: null, + error: { + code: "invalid_query", + // i18n-exempt: plugin-facing programmer diagnostic; plugins render their own localized error UI. + message: "taskId must match the active task", + retryable: false, + }, + }; + } + return { taskId: resolved, error: null }; +} + +type MessagesState = Omit; +type MessagesSetter = React.Dispatch>; + +// Session deletion emits child tombstones before its terminal marker. Keep +// rows removed by that ordered teardown so the terminal view can retain the +// committed conversation state. +function restoreDeletedMessages( + current: readonly PluginConversationMessage[], + deleted: ReadonlyMap, + sort: "asc" | "desc", +): PluginConversationMessage[] { + const messages = [...current]; + const currentIds = new Set(messages.map((message) => message.id)); + for (const message of deleted.values()) { + if (currentIds.has(message.id)) continue; + messages.push(message); + } + messages.sort((left, right) => compareConversationMessages(left, right, sort)); + return messages; +} + +function useOrderedMessageEvents({ + scope, + sessionId, + taskId, + authorsKey, + sort, + snapshotKey, + setState, + cursorRef, + messagesRef, + deletedMessagesRef, +}: { + scope: ConversationScope | null; + sessionId: string | null; + taskId: string | null; + authorsKey: string; + sort: "asc" | "desc"; + snapshotKey: string; + setState: MessagesSetter; + cursorRef: React.MutableRefObject; + messagesRef: React.MutableRefObject; + deletedMessagesRef: React.MutableRefObject>; +}) { + React.useEffect(() => { + if (!scope || !sessionId) return; + const allowedAuthorTypes = authorsKey ? new Set(authorsKey.split(",")) : null; + return scope.subscribe( + (event) => { + if (event.event_type === "session.removed") { + setState((current) => { + const messages = restoreDeletedMessages( + current.messages, + deletedMessagesRef.current, + sort, + ); + messagesRef.current = messages; + return { ...current, messages, removed: true, hasMore: false, loading: false }; + }); + cursorRef.current = null; + return true; + } + if (!event.event_type.startsWith("message.") || !eventMatchesTask(event, taskId)) { + return true; + } + const messageId = eventString(event, "message_id"); + if (!messageId) return false; + if (event.event_type === "message.deleted") { + setState((current) => { + const deleted = current.messages.find((message) => message.id === messageId); + if (deleted) deletedMessagesRef.current.set(messageId, deleted); + const messages = current.messages.filter((message) => message.id !== messageId); + messagesRef.current = messages; + return { ...current, messages }; + }); + return true; + } + const message = messageFromEvent(event); + if (!message) return false; + if (allowedAuthorTypes && !allowedAuthorTypes.has(message.authorType)) return true; + deletedMessagesRef.current.delete(message.id); + setState((current) => { + const messages = current.messages.filter((item) => item.id !== message.id); + messages.push(message); + messages.sort((left, right) => compareConversationMessages(left, right, sort)); + messagesRef.current = messages; + return { ...current, messages }; + }); + return true; + }, + "messages", + snapshotKey, + ); + }, [ + authorsKey, + cursorRef, + deletedMessagesRef, + messagesRef, + scope, + sessionId, + setState, + snapshotKey, + sort, + taskId, + ]); +} + +function useInitialMessagePage({ + scope, + sessionId, + error, + revision, + loadPage, + setState, + cursorRef, + loadMoreRef, + messagesRef, + deletedMessagesRef, + requestRevisionRef, + snapshotKey, +}: { + scope: ConversationScope | null; + sessionId: string | null; + error: PluginConversationError | null; + revision: number; + loadPage: (cursor: string | null, append: boolean) => Promise; + setState: MessagesSetter; + cursorRef: React.MutableRefObject; + loadMoreRef: React.MutableRefObject | null>; + messagesRef: React.MutableRefObject; + deletedMessagesRef: React.MutableRefObject>; + requestRevisionRef: React.MutableRefObject; + snapshotKey: string; +}) { + React.useEffect(() => { + requestRevisionRef.current += 1; + cursorRef.current = null; + loadMoreRef.current = null; + messagesRef.current = []; + deletedMessagesRef.current.clear(); + if (!scope || !sessionId) { + setState({ ...EMPTY_MESSAGES, messages: [] }); + return; + } + if (error) { + setState({ ...EMPTY_MESSAGES, messages: [], error }); + return; + } + setState({ ...EMPTY_MESSAGES, messages: [] }); + void loadPage(null, false).catch((cause: unknown) => { + if (scope.signal.aborted || scope.isTerminal()) return; + const error = conversationError(cause); + if (isRemovedConversationError(error)) { + setState((current) => ({ + ...current, + loading: false, + loadingMore: false, + error: null, + hasMore: false, + removed: true, + })); + return; + } + setState({ ...EMPTY_MESSAGES, messages: [], error }); + }); + }, [ + cursorRef, + deletedMessagesRef, + error, + loadMoreRef, + loadPage, + messagesRef, + requestRevisionRef, + revision, + scope, + sessionId, + setState, + snapshotKey, + ]); +} +function invalidateFreshMessagesSnapshot(scope: ConversationScope, snapshotKey: string): void { + scope.invalidateSnapshot("messages", snapshotKey); +} + +function isScopeRequestable(scope: ConversationScope): boolean { + return !scope.signal.aborted && !scope.isTerminal(); +} + +function canLoadMore( + scope: ConversationScope | null, + sessionId: string | null, + state: { removed: boolean; hasMore: boolean }, + cursor: string | null, +): boolean { + return Boolean( + scope && sessionId && isScopeRequestable(scope) && !state.removed && state.hasMore && cursor, + ); +} + +type MessagePageLoaderOptions = { + scope: ConversationScope | null; + sessionId: string | null; + taskId: string | null; + error: PluginConversationError | null; + authorsKey: string; + sort: "asc" | "desc"; + limit: number; + snapshotKey: string; + setState: MessagesSetter; + cursorRef: React.MutableRefObject; + messagesRef: React.MutableRefObject; + requestRevisionRef: React.MutableRefObject; +}; + +async function fetchMessagePage({ + scope, + sessionId, + taskId, + authorsKey, + sort, + limit, + cursor, + binding, +}: { + scope: ConversationScope; + sessionId: string; + taskId: string | null; + authorsKey: string; + sort: "asc" | "desc"; + limit: number; + cursor: string | null; + binding: { bindingToken: string; snapshotToken: string }; +}): Promise { + const params = new URLSearchParams({ sort, limit: String(limit) }); + if (taskId !== null) params.set("task_id", taskId); + for (const author of authorsKey ? authorsKey.split(",") : []) { + params.append("author_type", author); + } + if (cursor) params.set("cursor", cursor); + const response = await fetch( + pluginConversationUrl( + scope.pluginId, + `/conversation/task-sessions/${encodeURIComponent(sessionId)}/messages?${params}`, + ), + { + credentials: "include", + cache: "no-store", + headers: { + "X-Kandev-Plugin-Binding": binding.bindingToken, + "X-Kandev-Snapshot-Token": binding.snapshotToken, + }, + signal: scope.signal, + }, + ); + return parseConversationResponse(response); +} + +function mergeMessagePage( + current: readonly PluginConversationMessage[], + page: readonly PluginConversationMessage[], + append: boolean, + sort: "asc" | "desc", +): { messages: PluginConversationMessage[]; additionCount: number } { + const existingIds = new Set(current.map((message) => message.id)); + const messagesById = new Map(); + if (append) { + for (const message of current) messagesById.set(message.id, message); + } + for (const message of page) { + const previous = messagesById.get(message.id); + if (!previous || previous.updatedAt.localeCompare(message.updatedAt) < 0) { + messagesById.set(message.id, message); + } + } + const messages = Array.from(messagesById.values()); + messages.sort((left, right) => compareConversationMessages(left, right, sort)); + return { + messages, + additionCount: page.filter((message) => !existingIds.has(message.id)).length, + }; +} + +function useMessagePageLoader({ + scope, + sessionId, + taskId, + error, + authorsKey, + sort, + limit, + snapshotKey, + setState, + cursorRef, + messagesRef, + requestRevisionRef, +}: MessagePageLoaderOptions) { + return React.useCallback( + async (cursor: string | null, append: boolean): Promise => { + if (!scope || !sessionId || !isScopeRequestable(scope)) return 0; + if (error) throw error; + // A fresh full page or continuation must not let a concurrent live + // update project and then be overwritten by the stale page response. + // Continuations also invalidate their committed snapshot so deletions + // cannot be acknowledged while the page is in flight and resurrected + invalidateFreshMessagesSnapshot(scope, snapshotKey); + const continuationSnapshotInvalidated = append; + try { + let capturedRevision = requestRevisionRef.current; + let binding = await scope.ready(); + let pageCursor = cursor; + if (pageCursor) { + const renewal = await scope.renewContinuation(pageCursor, snapshotKey); + if (renewal.recovered) { + capturedRevision = requestRevisionRef.current; + pageCursor = cursorRef.current; + if (!pageCursor) return 0; + } else { + pageCursor = renewal.cursor; + } + binding = renewal.binding; + } + const page = await fetchMessagePage({ + scope, + sessionId, + taskId, + authorsKey, + sort, + limit, + cursor: pageCursor, + binding, + }); + if ( + requestRevisionRef.current !== capturedRevision || + scope.signal.aborted || + scope.isTerminal() + ) { + if (continuationSnapshotInvalidated) scope.commitSnapshot("messages", snapshotKey); + return 0; + } + const { messages, additionCount } = mergeMessagePage( + messagesRef.current, + page.messages, + append, + sort, + ); + messagesRef.current = messages; + setState((current) => ({ + ...current, + messages, + loading: false, + hydrated: true, + loadingMore: false, + error: null, + hasMore: page.hasMore, + })); + scope.commitSnapshot("messages", snapshotKey); + cursorRef.current = page.cursor; + return additionCount; + } catch (cause) { + if (continuationSnapshotInvalidated) scope.commitSnapshot("messages", snapshotKey); + throw cause; + } + }, + [ + authorsKey, + error, + limit, + messagesRef, + requestRevisionRef, + scope, + snapshotKey, + sessionId, + setState, + sort, + taskId, + ], + ); +} + +function useMessageRebind({ + scope, + sessionId, + error, + loadPage, + setState, + cursorRef, + deletedMessagesRef, + requestRevisionRef, +}: { + scope: ConversationScope | null; + sessionId: string | null; + error: PluginConversationError | null; + loadPage: (cursor: string | null, append: boolean) => Promise; + setState: MessagesSetter; + cursorRef: React.MutableRefObject; + deletedMessagesRef: React.MutableRefObject>; + requestRevisionRef: React.MutableRefObject; +}) { + React.useEffect(() => { + if (!scope || !sessionId || error) return; + return scope.subscribeRebind(async () => { + requestRevisionRef.current += 1; + cursorRef.current = null; + deletedMessagesRef.current.clear(); + setState((current) => ({ ...current, loading: true, loadingMore: false, error: null })); + try { + await loadPage(null, false); + } catch (cause) { + if (scope.signal.aborted || scope.isTerminal()) return; + setState((current) => ({ + ...current, + loading: false, + error: conversationError(cause), + })); + throw cause; + } + }); + }, [ + cursorRef, + deletedMessagesRef, + error, + loadPage, + requestRevisionRef, + scope, + sessionId, + setState, + ]); +} + +function useMessageControls({ + scope, + sessionId, + state, + setState, + loadPage, + cursorRef, + loadMoreRef, + setRevision, +}: { + scope: ConversationScope | null; + sessionId: string | null; + state: MessagesState; + setState: MessagesSetter; + loadPage: (cursor: string | null, append: boolean) => Promise; + cursorRef: React.MutableRefObject; + loadMoreRef: React.MutableRefObject | null>; + setRevision: React.Dispatch>; +}): PluginSessionMessagesState { + const loadMore = React.useCallback((): Promise => { + if (!canLoadMore(scope, sessionId, state, cursorRef.current)) return Promise.resolve(0); + if (loadMoreRef.current) return loadMoreRef.current; + setState((current) => ({ ...current, loadingMore: true })); + const request = loadPage(cursorRef.current, true) + .catch((error: unknown) => { + setState((current) => ({ + ...current, + loadingMore: false, + error: conversationError(error), + })); + throw conversationError(error); + }) + .finally(() => { + if (loadMoreRef.current === request) loadMoreRef.current = null; + }); + loadMoreRef.current = request; + return request; + }, [cursorRef, loadMoreRef, loadPage, scope, sessionId, setState, state.hasMore, state.removed]); + + const retry = React.useCallback(() => { + if (!state.error?.retryable || state.removed || !scope || scope.isTerminal() || !sessionId) + return; + setRevision((value) => value + 1); + }, [scope, sessionId, setRevision, state.error, state.removed]); + + return React.useMemo(() => ({ ...state, loadMore, retry }), [loadMore, retry, state]); +} + +function useSessionMessages(query: PluginSessionMessagesQuery): PluginSessionMessagesState { + const scope = React.useContext(ConversationScopeContext); + const [revision, setRevision] = React.useState(0); + const [state, setState] = React.useState>({ + ...EMPTY_MESSAGES, + }); + const cursorRef = React.useRef(null); + const loadMoreRef = React.useRef | null>(null); + const messagesRef = React.useRef([]); + const deletedMessagesRef = React.useRef(new Map()); + const requestRevisionRef = React.useRef(0); + const resolved = React.useMemo( + () => (scope ? resolveTaskId(scope, query.taskId) : { taskId: null, error: null }), + [query.taskId, scope], + ); + const authorsKey = [...(query.authorTypes ?? [])].join(","); + const sort = query.sort ?? "desc"; + const limit = query.pageSize ?? 20; + const snapshotKey = JSON.stringify([query.sessionId, resolved.taskId, authorsKey, sort, limit]); + + const loadPage = useMessagePageLoader({ + scope, + sessionId: query.sessionId, + taskId: resolved.taskId, + error: resolved.error, + authorsKey, + sort, + limit, + snapshotKey, + setState, + cursorRef, + messagesRef, + requestRevisionRef, + }); + useOrderedMessageEvents({ + scope: resolved.error ? null : scope, + sessionId: query.sessionId, + taskId: resolved.taskId, + authorsKey, + sort, + snapshotKey, + setState, + cursorRef, + messagesRef, + deletedMessagesRef, + }); + + useInitialMessagePage({ + scope, + sessionId: query.sessionId, + error: resolved.error, + revision, + loadPage, + setState, + cursorRef, + loadMoreRef, + messagesRef, + deletedMessagesRef, + requestRevisionRef, + snapshotKey, + }); + + useMessageRebind({ + scope, + sessionId: query.sessionId, + error: resolved.error, + loadPage, + setState, + cursorRef, + deletedMessagesRef, + requestRevisionRef, + }); + return useMessageControls({ + scope, + sessionId: query.sessionId, + state, + setState, + loadPage, + cursorRef, + loadMoreRef, + setRevision, + }); +} + +type TurnsState = Omit; +type TurnsSetter = React.Dispatch>; + +function compareConversationTurns( + left: PluginConversationTurn, + right: PluginConversationTurn, +): number { + const started = left.startedAt.localeCompare(right.startedAt); + return started === 0 ? left.id.localeCompare(right.id) : started; +} + +function restoreDeletedTurns( + current: readonly PluginConversationTurn[], + deleted: ReadonlyMap, +): PluginConversationTurn[] { + const turns = [...current]; + const currentIds = new Set(turns.map((turn) => turn.id)); + for (const turn of deleted.values()) { + if (currentIds.has(turn.id)) continue; + turns.push(turn); + } + turns.sort(compareConversationTurns); + return turns; +} + +function useOrderedTurnEvents({ + scope, + sessionId, + taskId, + error, + snapshotKey, + setState, + deletedTurnsRef, +}: { + scope: ConversationScope | null; + sessionId: string | null; + taskId: string | null; + error: PluginConversationError | null; + snapshotKey: string; + setState: TurnsSetter; + deletedTurnsRef: React.MutableRefObject>; +}) { + React.useEffect(() => { + if (!scope || !sessionId || error) return; + return scope.subscribe( + (event) => { + if (event.event_type === "session.removed") { + setState((current) => ({ + ...current, + turns: restoreDeletedTurns(current.turns, deletedTurnsRef.current), + removed: true, + loading: false, + })); + return true; + } + if (!eventMatchesTask(event, taskId)) return true; + if (event.event_type === "session.turn.removed") { + const payload = event.payload; + const turnId = + payload && + typeof payload === "object" && + "id" in payload && + typeof payload.id === "string" + ? payload.id + : undefined; + if (!turnId) return false; + setState((current) => { + const deleted = current.turns.find((turn) => turn.id === turnId); + if (deleted) deletedTurnsRef.current.set(turnId, deleted); + return { + ...current, + turns: current.turns.filter((item) => item.id !== turnId), + }; + }); + return true; + } + if (!event.event_type.startsWith("session.turn.")) return true; + const turn = turnFromEvent(event); + if (!turn) return false; + deletedTurnsRef.current.delete(turn.id); + setState((current) => { + const turns = current.turns.filter((item) => item.id !== turn.id); + turns.push(turn); + turns.sort(compareConversationTurns); + return { ...current, turns, hydrated: true }; + }); + return true; + }, + "turns", + snapshotKey, + ); + }, [deletedTurnsRef, error, scope, sessionId, setState, snapshotKey, taskId]); +} +// eslint-disable-next-line max-lines-per-function -- keeps turn snapshot lifecycle in one hook. +function useSessionTurns( + sessionId: string | null, + taskId?: string | null, +): PluginSessionTurnsState { + const scope = React.useContext(ConversationScopeContext); + const [revision, setRevision] = React.useState(0); + const [state, setState] = React.useState>({ + ...EMPTY_TURNS, + turns: [], + }); + const resolved = React.useMemo( + () => (scope ? resolveTaskId(scope, taskId) : { taskId: null, error: null }), + [scope, taskId], + ); + const snapshotKey = JSON.stringify([sessionId, resolved.taskId]); + const turnsRequestRef = React.useRef(0); + const deletedTurnsRef = React.useRef(new Map()); + + useOrderedTurnEvents({ + scope, + sessionId, + taskId: resolved.taskId, + error: resolved.error, + snapshotKey, + setState, + deletedTurnsRef, + }); + + const loadTurns = React.useCallback(async (): Promise => { + deletedTurnsRef.current.clear(); + if (!scope || !sessionId) { + setState({ ...EMPTY_TURNS, turns: [] }); + return; + } + if (resolved.error) { + setState({ ...EMPTY_TURNS, turns: [], error: resolved.error }); + return; + } + const requestId = ++turnsRequestRef.current; + // A fresh turns page (initial load, retry, or binding refresh) must not + // let a concurrent live turn event project and then be overwritten by the + // stale page response: buffer live events until the new snapshot commits, + // then drain them on top. + scope.invalidateSnapshot("turns", snapshotKey); + setState((previous) => + revision === 0 + ? { ...EMPTY_TURNS, turns: [], loading: true } + : { ...previous, loading: true, error: null }, + ); + try { + const binding = await scope.ready(); + const params = new URLSearchParams(); + if (resolved.taskId !== null) params.set("task_id", resolved.taskId); + const queryString = params.size ? `?${params}` : ""; + const response = await fetch( + pluginConversationUrl( + scope.pluginId, + `/conversation/task-sessions/${encodeURIComponent(sessionId)}/turns${queryString}`, + ), + { + credentials: "include", + cache: "no-store", + headers: { + "X-Kandev-Plugin-Binding": binding.bindingToken, + "X-Kandev-Snapshot-Token": binding.snapshotToken, + }, + signal: scope.signal, + }, + ); + const page = await parseConversationResponse(response); + if (turnsRequestRef.current !== requestId || scope.signal.aborted || scope.isTerminal()) { + return; + } + setState((previous) => ({ + ...previous, + turns: page.turns, + loading: false, + hydrated: true, + error: null, + })); + scope.commitSnapshot("turns", snapshotKey); + } catch (cause) { + if (turnsRequestRef.current !== requestId || scope.signal.aborted || scope.isTerminal()) { + return; + } + const error = conversationError(cause); + if (isRemovedConversationError(error)) { + setState((previous) => ({ + ...previous, + loading: false, + error: null, + removed: true, + })); + return; + } + setState((previous) => ({ + ...(revision === 0 ? { ...EMPTY_TURNS, turns: [] } : previous), + loading: false, + error, + })); + throw cause; + } + }, [ + deletedTurnsRef, + resolved.error, + resolved.taskId, + revision, + scope, + sessionId, + setState, + snapshotKey, + ]); + + React.useEffect(() => { + void loadTurns().catch(() => {}); + return () => { + turnsRequestRef.current += 1; + }; + }, [loadTurns]); + + React.useEffect(() => { + if (!scope || !sessionId || resolved.error) return; + return scope.subscribeRebind(() => loadTurns()); + }, [loadTurns, resolved.error, scope, sessionId]); + + const retry = React.useCallback(() => { + if (!state.error?.retryable || state.removed || !scope || !sessionId) return; + setRevision((value) => value + 1); + }, [scope, sessionId, state.error, state.removed]); + + return React.useMemo(() => ({ ...state, retry }), [retry, state]); +} + +function useMessageFavorite(sessionId: string | null, messageId: string): boolean { + const hydrateSession = useMessageFavoritesStore((state) => state.hydrateSession); + React.useEffect(() => { + if (sessionId) hydrateSession(sessionId); + }, [hydrateSession, sessionId]); + return useMessageFavoritesStore((state) => + sessionId ? Boolean(state.bySession[sessionId]?.[messageId]) : false, + ); +} + +export const pluginConversationApi: PluginConversationApi = { + useSessionMessages, + useSessionTurns, + useMessageFavorite, +}; diff --git a/apps/web/lib/plugins/conversation-scope.tsx b/apps/web/lib/plugins/conversation-scope.tsx new file mode 100644 index 0000000000..7e0b2e78ae --- /dev/null +++ b/apps/web/lib/plugins/conversation-scope.tsx @@ -0,0 +1,780 @@ +/* eslint-disable max-lines -- The scope owns transport, ordering, and snapshot lifecycle state. */ +"use client"; + +import * as React from "react"; +import { getBackendConfig } from "@/lib/config"; +import { parseTurnTimestamp } from "@/lib/state/slices/session/turn-actions"; +import { generateUUID } from "@/lib/uuid"; +import { isRawSessionEvent } from "@/lib/ws/ordered-session-events"; +import { getWebSocketClient } from "@/lib/ws/connection"; +import type { RawSessionEvent } from "@/lib/ws/client"; +import type { PluginConversationError } from "./types"; + +export type OrderedReady = { + bindingToken: string; + generation: number; + expiresAt: string; + snapshotExpiresAt: string; + snapshotToken: string; + resumeToken: string; + consumerId: string; + watermark: number; +}; + +type Binding = Pick; +type SnapshotKind = "messages" | "turns"; +type ConversationEventListener = (event: RawSessionEvent) => boolean; +type SnapshotKey = string; +type OrderedSubscribeAck = + | { + success: true; + snapshot_token: string; + resume_token: string; + consumer_id: string; + event_watermark: number; + expires_at: string; + result: "fresh" | "replay" | "invalid_resume"; + replay_from?: number; + } + | { + success: false; + error: + | PluginConversationError + | { code: "session_removed"; message: string; retryable: false }; + }; +type ContinuationRenewal = { + cursor: string; + snapshot_token: string; + expires_at: string; +}; +type ErrorEnvelope = { error?: PluginConversationError }; +type RebindListener = () => void | Promise; +type ContinuationResult = { cursor: string; binding: OrderedReady; recovered?: boolean }; + +export type ConversationScope = { + pluginId: string; + taskId: string; + sessionId: string | null; + signal: AbortSignal; + ready(): Promise; + renewContinuation(cursor: string, queryIdentity?: string): Promise; + subscribe( + listener: ConversationEventListener, + kind: SnapshotKind, + snapshotKey: SnapshotKey, + ): () => void; + subscribeRebind(listener: RebindListener): () => void; + isTerminal(): boolean; + commitSnapshot(kind: SnapshotKind, snapshotKey?: SnapshotKey): void; + invalidateSnapshot(kind: SnapshotKind, snapshotKey?: SnapshotKey): void; + reconnect(): void; + accept(event: RawSessionEvent): void; + close(): void; +}; + +export const ConversationScopeContext = React.createContext(null); + +export function pluginConversationUrl(pluginId: string, path: string): string { + const { apiBaseUrl } = getBackendConfig(); + return `${apiBaseUrl}/api/plugins/${encodeURIComponent(pluginId)}${path}`; +} + +export async function parseConversationResponse(response: Response): Promise { + if (response.ok) return (await response.json()) as T; + const body = (await response.json().catch(() => ({}))) as ErrorEnvelope; + throw ( + body.error ?? { + code: "upstream_failure", + // i18n-exempt: plugin-facing transport diagnostic; plugins render their own localized error UI. + message: `Conversation request failed with status ${response.status}`, + retryable: response.status >= 500, + } + ); +} + +async function fetchBinding(pluginId: string, signal: AbortSignal): Promise { + const response = await fetch(pluginConversationUrl(pluginId, "/conversation/binding"), { + credentials: "include", + cache: "no-store", + signal, + }); + return parseConversationResponse(response); +} + +const SESSION_REMOVED_EVENT = "session.removed"; +const SESSION_SUBSCRIBE_ACTION = "session.subscribe"; + +const ORDERED_EVENT_TYPES: Record = { + "message.added": true, + "message.updated": true, + "message.deleted": true, + "session.turn.started": true, + "session.turn.completed": true, + "session.turn.removed": true, + [SESSION_REMOVED_EVENT]: true, +}; + +function isNonEmptyPayloadString(payload: Record, key: string): boolean { + return typeof payload[key] === "string" && (payload[key] as string).length > 0; +} + +function isValidPayloadTime(payload: Record, key: string): boolean { + if (!isNonEmptyPayloadString(payload, key)) return false; + const value = payload[key]; + return typeof value === "string" && parseTurnTimestamp(value) !== null; +} + +function isValidMessagePayload(payload: Record, updated: boolean): boolean { + return ( + isNonEmptyPayloadString(payload, "message_id") && + (payload.author_type === "user" || payload.author_type === "agent") && + typeof payload.content === "string" && + isValidPayloadTime(payload, "created_at") && + (!updated || isValidPayloadTime(payload, "updated_at")) + ); +} + +function isValidTurnPayload(payload: Record, completed: boolean): boolean { + return ( + isNonEmptyPayloadString(payload, "id") && + isValidPayloadTime(payload, "started_at") && + (!completed || + (isValidPayloadTime(payload, "completed_at") && isValidPayloadTime(payload, "updated_at"))) + ); +} +// eventPayloadValidators mirrors backend ProjectSessionEvent's per-event +// required-field checks. session.removed is intentionally absent: a removal is +// accepted for the selected session regardless of its task_id. +const eventPayloadValidators: Record) => boolean> = { + "message.added": (payload) => isValidMessagePayload(payload, false), + "message.updated": (payload) => isValidMessagePayload(payload, true), + "message.deleted": (payload) => isNonEmptyPayloadString(payload, "message_id"), + "session.turn.started": (payload) => isValidTurnPayload(payload, false), + "session.turn.completed": (payload) => isValidTurnPayload(payload, true), + "session.turn.removed": (payload) => isNonEmptyPayloadString(payload, "id"), +}; + +function isCompatibleConversationEvent(event: RawSessionEvent, sessionId: string): boolean { + if (!isRawSessionEvent(event) || event.session_id !== sessionId) return false; + if ( + !(event.event_type in ORDERED_EVENT_TYPES) || + !event.payload || + typeof event.payload !== "object" + ) { + return false; + } + const payload = event.payload as Record; + if (payload.type !== event.event_type || payload.session_id !== sessionId) return false; + if (event.event_type === SESSION_REMOVED_EVENT) { + return true; + } + // Every non-removal event must match the session's task identity exactly. + if (event.task_id !== null && payload.task_id !== event.task_id) return false; + const validator = eventPayloadValidators[event.event_type]; + return validator ? validator(payload) : false; +} + +function snapshotKindForEvent(eventType: string): SnapshotKind | null { + if (eventType.startsWith("message.")) return "messages"; + if (eventType.startsWith("session.turn.")) return "turns"; + return null; +} + +class OrderedConversationScope implements ConversationScope { + readonly signal: AbortSignal; + private bindingPromise: Promise | null = null; + private readyPromise: Promise | null = null; + private readonly renewalPromises = new Map>(); + private bindingRefreshPromise: Promise | null = null; + private continuationRecoveryPromise: Promise | null = null; + private stateGeneration = 0; + private currentResumeToken = ""; + private readonly committedSnapshots = new Set(); + private acknowledgedSequence = 0; + private nextSequence = 1; + private sequenceBlocked = false; + private terminal = false; + private closed = false; + private ackChain = Promise.resolve(); + private readonly listeners = new Set(); + private readonly listenerSnapshotKeys = new Map(); + private readonly listenerSnapshotKinds = new Map(); + private readonly rebindListeners = new Set<() => void>(); + private poisonRebindPromise: Promise | null = null; + private readonly buffered: RawSessionEvent[] = []; + private readonly pendingBySequence = new Map(); + private consumerId = generateUUID(); + + constructor( + readonly pluginId: string, + readonly taskId: string, + readonly sessionId: string | null, + private readonly controller: AbortController, + ) { + this.signal = controller.signal; + } + + ready(): Promise { + const pending = (this.readyPromise ??= this.initializeReady()); + return pending + .then((current) => this.refreshBindingIfNeeded(current)) + .catch((error: unknown) => { + if (this.readyPromise === pending) this.readyPromise = null; + throw error; + }); + } + + async renewContinuation(cursor: string, queryIdentity = ""): Promise { + const current = await this.ready(); + const snapshotExpiresAt = new Date(current.snapshotExpiresAt).getTime(); + if (snapshotExpiresAt - Date.now() > 2 * 60 * 1000) { + return { cursor, binding: current }; + } + if (snapshotExpiresAt <= Date.now()) { + const binding = await this.recoverExpiredContinuation(current); + return { cursor, binding, recovered: true }; + } + const renewalKey = JSON.stringify([queryIdentity, cursor]); + const existing = this.renewalPromises.get(renewalKey); + if (existing) return existing; + const capturedGeneration = this.stateGeneration; + const pending = fetch( + pluginConversationUrl(this.pluginId, "/conversation/continuation/renew"), + { + method: "POST", + credentials: "include", + cache: "no-store", + headers: { + "Content-Type": "application/json", + "X-Kandev-Plugin-Binding": current.bindingToken, + }, + body: JSON.stringify({ cursor, snapshot_token: current.snapshotToken }), + signal: this.signal, + }, + ) + .then((response) => parseConversationResponse(response)) + .then((renewed) => { + if (this.stateGeneration !== capturedGeneration) { + return this.ready().then((binding) => ({ cursor, binding, recovered: true })); + } + const next = { + ...current, + snapshotToken: renewed.snapshot_token, + snapshotExpiresAt: renewed.expires_at, + }; + this.readyPromise = Promise.resolve(next); + return { cursor: renewed.cursor, binding: next }; + }) + .catch((cause: unknown) => { + if (snapshotExpiresAt <= Date.now()) { + return this.recoverExpiredContinuation(current).then((binding) => ({ + cursor, + binding, + recovered: true, + })); + } + throw cause; + }) + .finally(() => { + if (this.renewalPromises.get(renewalKey) === pending) { + this.renewalPromises.delete(renewalKey); + } + }); + this.renewalPromises.set(renewalKey, pending); + return pending; + } + + subscribe(listener: ConversationEventListener, kind: SnapshotKind, snapshotKey: SnapshotKey) { + this.listeners.add(listener); + this.listenerSnapshotKeys.set(listener, snapshotKey); + this.listenerSnapshotKinds.set(listener, kind); + return () => { + this.listeners.delete(listener); + this.listenerSnapshotKeys.delete(listener); + this.listenerSnapshotKinds.delete(listener); + this.drainBuffered(); + }; + } + + subscribeRebind(listener: RebindListener) { + this.rebindListeners.add(listener); + return () => this.rebindListeners.delete(listener); + } + isTerminal() { + return this.terminal; + } + + invalidateSnapshot(kind: SnapshotKind, snapshotKey = "") { + if (this.closed) return; + this.committedSnapshots.delete(`${kind}:${snapshotKey}`); + } + + commitSnapshot(kind: SnapshotKind, snapshotKey = "") { + if (this.closed || this.terminal) return; + this.committedSnapshots.add(`${kind}:${snapshotKey}`); + this.drainBuffered(); + } + + private drainBuffered() { + const pending = this.buffered.splice(0).sort((left, right) => left.sequence - right.sequence); + for (let index = 0; index < pending.length; index += 1) { + const event = pending[index]; + if (!event) continue; + if (this.terminal) return; + if (this.shouldBuffer(event)) { + this.buffered.push(...pending.slice(index)); + return; + } + if (!this.project(event)) { + this.buffered.unshift(...pending.slice(index + 1)); + this.blockForPoison(); + return; + } + } + } + + accept(event: RawSessionEvent) { + const sessionId = this.sessionId; + if (!sessionId || event.session_id !== sessionId) return; + if (!isRawSessionEvent(event)) { + this.blockForPoison(); + return; + } + if (!this.acceptsScope(event, sessionId) || event.sequence < this.nextSequence) return; + if (this.sequenceBlocked && event.event_type === SESSION_REMOVED_EVENT) { + if (isCompatibleConversationEvent(event, sessionId)) this.project(event, false); + return; + } + const existing = this.pendingBySequence.get(event.sequence); + if (existing) { + if (existing.event_id !== event.event_id || existing.event_type !== event.event_type) { + this.blockForPoison(); + } + return; + } + this.pendingBySequence.set(event.sequence, event); + this.drainPending(sessionId); + } + + private acceptsScope(event: RawSessionEvent, sessionId: string) { + return !this.closed && !this.terminal && event.session_id === sessionId; + } + + private drainPending(sessionId: string) { + while (!this.sequenceBlocked && !this.terminal) { + const event = this.pendingBySequence.get(this.nextSequence); + if (!event) return; + if (!isCompatibleConversationEvent(event, sessionId)) { + this.blockForPoison(); + return; + } + this.pendingBySequence.delete(this.nextSequence); + this.nextSequence += 1; + if (event.event_type === SESSION_REMOVED_EVENT) { + if (!this.project(event)) this.sequenceBlocked = true; + return; + } + if (this.shouldBuffer(event)) { + this.buffered.push(event); + continue; + } + if (!this.project(event)) { + this.sequenceBlocked = true; + return; + } + } + } + + private shouldBuffer(event: RawSessionEvent) { + if (this.listeners.size === 0) return true; + const eventKind = snapshotKindForEvent(event.event_type); + if (eventKind === null) return false; + for (const listener of this.listeners) { + if (this.listenerSnapshotKinds.get(listener) !== eventKind) continue; + const snapshotKey = this.listenerSnapshotKeys.get(listener) ?? ""; + if (!this.committedSnapshots.has(`${eventKind}:${snapshotKey}`)) return true; + } + return false; + } + + reconnect() { + if (this.closed || this.terminal || !this.sessionId || !this.readyPromise) return; + void this.resume().catch(() => undefined); + } + + close() { + this.closed = true; + this.buffered.length = 0; + this.rebindListeners.clear(); + this.listeners.clear(); + this.listenerSnapshotKeys.clear(); + this.listenerSnapshotKinds.clear(); + this.pendingBySequence.clear(); + if (!this.sessionId || !this.readyPromise) return; + void this.readyPromise + .then((ready) => + getWebSocketClient()?.request("session.unsubscribe", { + session_id: this.sessionId, + consumer_kind: "plugin", + consumer_id: this.consumerId, + plugin_id: this.pluginId, + generation: ready.generation, + }), + ) + .catch(() => undefined); + } + + private getBinding() { + const pending = (this.bindingPromise ??= fetchBinding(this.pluginId, this.signal)); + return pending.catch((error: unknown) => { + if (this.bindingPromise === pending) this.bindingPromise = null; + throw error; + }); + } + private async initializeReady(): Promise { + const binding = await this.getBinding(); + if (!this.sessionId) { + return { + ...binding, + snapshotToken: "", + snapshotExpiresAt: binding.expiresAt, + resumeToken: "", + consumerId: this.consumerId, + watermark: 0, + }; + } + const client = getWebSocketClient(); + if (!client) { + // i18n-exempt: plugin-facing transport diagnostic; plugins render their own localized error UI. + throw new Error("WebSocket unavailable"); + } + const ack = await client.request(SESSION_SUBSCRIBE_ACTION, { + session_id: this.sessionId, + consumer_kind: "plugin", + consumer_id: this.consumerId, + plugin_id: this.pluginId, + generation: binding.generation, + binding_token: binding.bindingToken, + }); + if (!ack.success) throw ack.error; + this.consumerId = ack.consumer_id; + this.currentResumeToken = ack.resume_token; + // The subscribe ACK's watermark is the last durable sequence the server + // already holds; live projection starts at watermark+1. Seeding the + // cursor here (not at 1) is what lets an existing non-empty session drain + // its first live event instead of parking it forever waiting for + // sequence 1. + this.acknowledgedSequence = ack.event_watermark; + this.nextSequence = ack.event_watermark + 1; + return { + ...binding, + snapshotToken: ack.snapshot_token, + snapshotExpiresAt: ack.expires_at || binding.expiresAt, + resumeToken: ack.resume_token, + consumerId: ack.consumer_id, + watermark: ack.event_watermark, + }; + } + + private refreshBindingIfNeeded(current: OrderedReady): Promise { + if (new Date(current.expiresAt).getTime() - Date.now() > 2 * 60 * 1000) { + return Promise.resolve(current); + } + if (this.bindingRefreshPromise) return this.bindingRefreshPromise; + this.bindingPromise = fetchBinding(this.pluginId, this.signal); + const pending = this.bindingPromise + .then(async (binding) => { + if (binding.generation !== current.generation) { + return this.rebindGeneration(current, binding); + } + const next = { ...current, ...binding }; + this.readyPromise = Promise.resolve(next); + return next; + }) + .finally(() => { + if (this.bindingRefreshPromise === pending) this.bindingRefreshPromise = null; + }); + this.bindingRefreshPromise = pending; + return pending; + } + + private project(event: RawSessionEvent, acknowledge = true): boolean { + let projected = true; + for (const listener of this.listeners) { + try { + projected = listener(event) && projected; + } catch { + projected = false; + } + } + if (!projected) return false; + if (event.event_type === SESSION_REMOVED_EVENT) this.terminal = true; + if (acknowledge) { + this.ackChain = this.ackChain + .then(() => this.acknowledge(event.sequence)) + .catch(() => { + this.sequenceBlocked = true; + }); + } + return true; + } + + private async acknowledge(sequence: number) { + const ready = await this.ready(); + const client = getWebSocketClient(); + if (!client || this.closed || !this.sessionId) return; + const ack = await client.request<{ success: true; resume_token: string } | { success: false }>( + "session.ack", + { + session_id: this.sessionId, + consumer_kind: "plugin", + consumer_id: this.consumerId, + plugin_id: this.pluginId, + generation: ready.generation, + sequence, + resume_token: this.currentResumeToken, + }, + ); + if (!ack.success) { + this.sequenceBlocked = true; + return; + } + this.currentResumeToken = ack.resume_token; + this.acknowledgedSequence = sequence; + } + + private async rebindGeneration(current: OrderedReady, binding: Binding): Promise { + return this.rebindSubscription(current, binding); + } + + private recoverExpiredContinuation(current: OrderedReady): Promise { + if (this.continuationRecoveryPromise) return this.continuationRecoveryPromise; + const pending = this.rebindSubscription(current, current).finally(() => { + if (this.continuationRecoveryPromise === pending) { + this.continuationRecoveryPromise = null; + } + }); + this.continuationRecoveryPromise = pending; + return pending; + } + + private async rebindSubscription(current: OrderedReady, binding: Binding): Promise { + const client = getWebSocketClient(); + if (!client || this.closed || this.terminal || !this.sessionId) { + const next = { ...current, ...binding }; + this.readyPromise = Promise.resolve(next); + return next; + } + const previousConsumerId = this.consumerId; + this.consumerId = generateUUID(); + const ack = await client.request(SESSION_SUBSCRIBE_ACTION, { + session_id: this.sessionId, + consumer_kind: "plugin", + consumer_id: this.consumerId, + plugin_id: this.pluginId, + generation: binding.generation, + binding_token: binding.bindingToken, + }); + if (!ack.success) { + this.consumerId = previousConsumerId; + throw ack.error; + } + this.consumerId = ack.consumer_id; + await client.request("session.unsubscribe", { + session_id: this.sessionId, + consumer_kind: "plugin", + consumer_id: previousConsumerId, + plugin_id: this.pluginId, + generation: current.generation, + }); + const next: OrderedReady = { + ...binding, + snapshotToken: ack.snapshot_token, + snapshotExpiresAt: ack.expires_at || binding.expiresAt, + resumeToken: ack.resume_token, + consumerId: ack.consumer_id, + watermark: ack.event_watermark, + }; + this.currentResumeToken = ack.resume_token; + this.acknowledgedSequence = ack.event_watermark; + this.nextSequence = ack.event_watermark + 1; + this.stateGeneration += 1; + for (const sequence of this.pendingBySequence.keys()) { + if (sequence <= ack.event_watermark) this.pendingBySequence.delete(sequence); + } + this.buffered.length = 0; + this.committedSnapshots.clear(); + this.readyPromise = Promise.resolve(next); + await this.notifyRebindListeners(); + this.drainPending(this.sessionId); + return next; + } + + private async notifyRebindListeners(): Promise { + await Promise.all( + [...this.rebindListeners].map((listener) => Promise.resolve().then(() => listener())), + ); + } + + private blockForPoison() { + this.sequenceBlocked = true; + this.poisonRebindPromise ??= this.rebindPastPoison() + .catch(() => undefined) + .finally(() => { + this.poisonRebindPromise = null; + }); + } + private async rebindPastPoison() { + const ready = await this.ready(); + const client = getWebSocketClient(); + if (!client) return; + if (this.closed || this.terminal || !this.sessionId) return; + const ack = await client.request(SESSION_SUBSCRIBE_ACTION, { + session_id: this.sessionId, + consumer_kind: "plugin", + consumer_id: this.consumerId, + plugin_id: this.pluginId, + generation: ready.generation, + binding_token: ready.bindingToken, + last_seen_sequence: this.acknowledgedSequence, + resume_token: this.currentResumeToken, + replace_cursor: true, + }); + if (!ack.success) { + if (ack.error.code === "session_removed") this.projectTerminalRemoval(); + return; + } + this.consumerId = ack.consumer_id; + this.currentResumeToken = ack.resume_token; + this.acknowledgedSequence = ack.event_watermark; + this.nextSequence = ack.event_watermark + 1; + for (const sequence of this.pendingBySequence.keys()) { + if (sequence <= ack.event_watermark) this.pendingBySequence.delete(sequence); + } + this.buffered.length = 0; + this.committedSnapshots.clear(); + this.stateGeneration += 1; + this.sequenceBlocked = true; + this.readyPromise = Promise.resolve({ + ...ready, + snapshotToken: ack.snapshot_token, + snapshotExpiresAt: ack.expires_at || ready.snapshotExpiresAt, + resumeToken: ack.resume_token, + watermark: ack.event_watermark, + }); + await this.notifyRebindListeners(); + this.sequenceBlocked = false; + this.drainPending(this.sessionId); + } + + private projectTerminalRemoval() { + if (!this.sessionId || this.terminal) return; + this.terminal = true; + const removedEvent: RawSessionEvent = { + type: "session.event", + protocol_version: 1, + event_type: SESSION_REMOVED_EVENT, + session_id: this.sessionId, + task_id: this.taskId, + sequence: this.acknowledgedSequence, + event_id: `terminal:${this.sessionId}`, + payload: { type: SESSION_REMOVED_EVENT, session_id: this.sessionId, task_id: this.taskId }, + }; + this.listeners.forEach((listener) => listener(removedEvent)); + } + + private async resume() { + const ready = await this.ready(); + const client = getWebSocketClient(); + if (!client || this.closed || this.terminal || !this.sessionId) return; + const ack = await client.request(SESSION_SUBSCRIBE_ACTION, { + session_id: this.sessionId, + consumer_kind: "plugin", + consumer_id: this.consumerId, + plugin_id: this.pluginId, + generation: ready.generation, + binding_token: ready.bindingToken, + last_seen_sequence: this.acknowledgedSequence, + resume_token: this.currentResumeToken, + }); + if (ack.success) { + this.currentResumeToken = ack.resume_token; + const invalidResume = ack.result === "invalid_resume"; + if (invalidResume) { + this.sequenceBlocked = true; + this.acknowledgedSequence = 0; + this.committedSnapshots.clear(); + this.buffered.length = 0; + } + this.nextSequence = + ack.replay_from ?? Math.max(ack.event_watermark + 1, this.acknowledgedSequence + 1); + for (const sequence of this.pendingBySequence.keys()) { + if (sequence <= ack.event_watermark) this.pendingBySequence.delete(sequence); + } + if (!invalidResume) this.sequenceBlocked = false; + if (invalidResume) this.stateGeneration += 1; + this.readyPromise = Promise.resolve({ + ...ready, + snapshotToken: ack.snapshot_token, + snapshotExpiresAt: ack.expires_at || ready.snapshotExpiresAt, + resumeToken: ack.resume_token, + watermark: ack.event_watermark, + }); + if (invalidResume) { + await this.notifyRebindListeners(); + this.sequenceBlocked = false; + this.drainPending(this.sessionId); + } + return; + } + if (ack.error.code === "session_removed") this.projectTerminalRemoval(); + } +} + +export function PluginConversationScopeProvider({ + pluginId, + taskId, + sessionId, + generation = 0, + presentation = "desktop", + children, +}: React.PropsWithChildren<{ + pluginId: string; + taskId: string; + sessionId: string | null; + generation?: number; + presentation?: "desktop" | "mobile"; +}>) { + const controller = React.useMemo( + () => new AbortController(), + [generation, pluginId, presentation, sessionId, taskId], + ); + const scope = React.useMemo( + () => new OrderedConversationScope(pluginId, taskId, sessionId, controller), + [controller, pluginId, sessionId, taskId], + ); + React.useLayoutEffect(() => { + const client = getWebSocketClient(); + let sawDisconnect = false; + const removeListener = client?.onRawSessionEvent((event) => scope.accept(event)); + const removeStatusListener = client?.onConnectionStatus((status) => { + if (status !== "connected") { + sawDisconnect = true; + return; + } + if (sawDisconnect) { + sawDisconnect = false; + scope.reconnect(); + } + }); + return () => { + removeListener?.(); + removeStatusListener?.(); + scope.close(); + controller.abort(); + }; + }, [controller, scope]); + return ( + {children} + ); +} diff --git a/apps/web/lib/plugins/duplicate-render.test.tsx b/apps/web/lib/plugins/duplicate-render.test.tsx index e96c17958e..ca963e7cf4 100644 --- a/apps/web/lib/plugins/duplicate-render.test.tsx +++ b/apps/web/lib/plugins/duplicate-render.test.tsx @@ -30,6 +30,7 @@ function makeHostFactory(pluginId: string): PluginHostApi { pluginId, React, jsx: React.createElement, + conversation: {} as PluginHostApi["conversation"], store: { getState: () => ({}) as never, setState: () => {}, subscribe: () => () => {} }, context: { getActiveWorkspaceId: () => undefined, diff --git a/apps/web/lib/plugins/host-api.ts b/apps/web/lib/plugins/host-api.ts index e231c970bc..1fc8f1c7b4 100644 --- a/apps/web/lib/plugins/host-api.ts +++ b/apps/web/lib/plugins/host-api.ts @@ -112,6 +112,10 @@ import { Combobox } from "@/components/combobox"; import { RichTextEditor, RichTextReadOnly } from "@/components/editors/tiptap/rich-text-editor"; import { PageTopbar } from "@/components/page-topbar"; import { TaskCreateDialog } from "@/components/task-create-dialog"; +import { + PromptMentionText as NativePromptMentionText, + usePromptMentionNames, +} from "@/components/task/chat/messages/prompt-mention-components"; import { ChangeRequestList, ChangeRequestRow } from "@/components/integrations/change-request-list"; import type { ChangeRequestDetailProps } from "@/components/integrations/change-request-detail"; import { IntegrationStartTaskMenu } from "@/components/integrations/integration-start-task-menu"; @@ -148,6 +152,7 @@ import { readResolvedTheme, subscribeToThemeChanges } from "./theme"; import { composeWriterId, subscribeToUserStateChanges } from "./user-state-sync"; import { buildPluginContextApi } from "./plugin-context-api"; import { pluginTranslationNamespace } from "./plugin-translations"; +import { pluginConversationApi } from "./conversation-host"; import type { PluginActionInput, PluginActionOptions, @@ -191,6 +196,21 @@ function PluginChangeRequestDetail(props: ChangeRequestDetailProps) { ); } +function PluginPromptMentionText({ + text, + interactive = false, +}: { + text: string; + interactive?: boolean; +}) { + const promptNames = usePromptMentionNames(); + return React.createElement(NativePromptMentionText, { + text, + promptNames, + focusable: interactive, + }); +} + /** * Curated `@kandev/ui` subset exposed on `host.ui`, plus a handful of * first-party app components (bottom of the map). Plugins must use these @@ -350,6 +370,8 @@ const PLUGIN_UI: PluginUIApi & Record = { // pixel-identical to the Plan panel. See rich-text-editor.tsx. RichTextEditor, RichTextReadOnly, + // - PromptMentionText: native prompt-reference parsing and chip rendering. + PromptMentionText: PluginPromptMentionText, IntegrationAuthStatusBanner, IntegrationEnabledControl: DraftedIntegrationEnabledControl, SettingsSection, @@ -499,6 +521,7 @@ export function buildHostApi(pluginId: string, storeApi: StoreApi): Pl return getBackendConfig().apiBaseUrl; }, }, + conversation: pluginConversationApi, ui: createPluginUIApi(pluginId), useResponsiveBreakpoint, // Getter, not a value captured at boot: a plugin built once at page load diff --git a/apps/web/lib/plugins/host-initialize-timeout.test.ts b/apps/web/lib/plugins/host-initialize-timeout.test.ts index c52a217a49..43bb8bfaf4 100644 --- a/apps/web/lib/plugins/host-initialize-timeout.test.ts +++ b/apps/web/lib/plugins/host-initialize-timeout.test.ts @@ -24,6 +24,7 @@ function makeHostFactory(pluginId: string): PluginHostApi { pluginId, React: {} as PluginHostApi["React"], jsx: {} as PluginHostApi["jsx"], + conversation: {} as PluginHostApi["conversation"], store: { getState: () => ({}) as never, setState: () => {}, diff --git a/apps/web/lib/plugins/host-lifecycle.test.ts b/apps/web/lib/plugins/host-lifecycle.test.ts index 4a06f15a32..11797f53c3 100644 --- a/apps/web/lib/plugins/host-lifecycle.test.ts +++ b/apps/web/lib/plugins/host-lifecycle.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; -import { pluginDestinations } from "@/lib/navigation/plugin-destinations"; -import { loadPlugins } from "./host"; +import { loadPlugins, unloadPlugin } from "./host"; import { pluginRegistry } from "./registry"; import type { ActivePlugin, PluginHostApi, PluginRegistry } from "./types"; @@ -29,6 +28,7 @@ function makeHostFactory(pluginId: string): PluginHostApi { pluginId, React: {} as PluginHostApi["React"], jsx: {} as PluginHostApi["jsx"], + conversation: {} as PluginHostApi["conversation"], store: { getState: () => ({}) as never, setState: () => {}, @@ -89,6 +89,10 @@ function activePlugin(overrides: Partial = {}): ActivePlugin { }; } +function registerFake(id: string, plugin: unknown) { + (window as unknown as FakeWindow).registerKandevPlugin(id, plugin); +} + function fakeImporterFor( bundles: Record void>, ): (url: string) => Promise { @@ -186,8 +190,8 @@ describe("authoritative plugin lifecycle", () => { }); }); -describe("authoritative plugin lifecycle — partial-initialization-failure retention", () => { - it("keeps a sidebar-footer registration made before initialize() throws, and marks the plugin failed", async () => { +describe("authoritative plugin lifecycle staging", () => { + it("discards a sidebar-footer registration when initialize() throws", async () => { const initialize = vi.fn(async (registry: PluginRegistry) => { registry.registerNavItem({ id: "nav-throw-a", @@ -210,29 +214,13 @@ describe("authoritative plugin lifecycle — partial-initialization-failure rete ); expect(pluginRegistry.getPluginLifecycle(PLUGIN_THROW_ID)?.status).toBe("failed"); - expect(pluginRegistry.getNavItems()).toContainEqual({ - id: "nav-throw-a", - label: "Throw", - path: "/throw-a", - section: SIDEBAR_FOOTER_SECTION, - }); - // The footer's manifest is built from getNavRegistrations() through - // pluginDestinations() (see plugin-destinations.ts), which has no concept - // of plugin lifecycle status — a "failed" plugin's already-registered item - // maps to the "insights" section exactly like a "ready" plugin's would, - // which is what makes it occupy a footer budget slot (spec.md:816's AC). - const destinations = pluginDestinations(pluginRegistry.getNavRegistrations()); - expect(destinations).toContainEqual( - expect.objectContaining({ - pluginItemId: "nav-throw-a", - section: "insights", - source: "plugin", - }), + expect(pluginRegistry.getNavItems()).not.toContainEqual( + expect.objectContaining({ id: "nav-throw-a" }), ); errorSpy.mockRestore(); }); - it("keeps a sidebar-footer registration made before initialize() times out, and marks the plugin failed", async () => { + it("discards a sidebar-footer registration when initialize() times out", async () => { const initializeStarted = deferred(); const initializeGate = deferred(); const initialize = vi.fn(async (registry: PluginRegistry) => { @@ -264,25 +252,139 @@ describe("authoritative plugin lifecycle — partial-initialization-failure rete await load; expect(pluginRegistry.getPluginLifecycle(PLUGIN_PARTIAL_TIMEOUT_ID)?.status).toBe("failed"); - expect(pluginRegistry.getNavItems()).toContainEqual({ - id: "nav-partial-timeout", - label: "Partial", - path: "/partial-timeout", - section: SIDEBAR_FOOTER_SECTION, - }); - // See the throw case above: pluginDestinations() has no lifecycle concept, - // so a timed-out plugin's already-registered item still maps to "insights" - // and occupies a footer budget slot exactly like a ready plugin's would. - const destinations = pluginDestinations(pluginRegistry.getNavRegistrations()); - expect(destinations).toContainEqual( - expect.objectContaining({ - pluginItemId: "nav-partial-timeout", - section: "insights", - source: "plugin", - }), + expect(pluginRegistry.getNavItems()).not.toContainEqual( + expect.objectContaining({ id: "nav-partial-timeout" }), ); initializeGate.resolve(); warnSpy.mockRestore(); }); }); +describe("plugin reload staging", () => { + const PLUGIN_RELOAD_ID = "plugin-reload-staging"; + + afterEach(() => { + unloadPlugin(PLUGIN_RELOAD_ID, { evictCache: true }); + }); + + it("keeps the published runtime and contributions when a replacement fails", async () => { + const oldDestroy = vi.fn(); + const importer = async (url: string) => { + if (url === "/old-bundle.js") { + registerFake(PLUGIN_RELOAD_ID, { + initialize: (registry: PluginRegistry) => + registry.registerNavItem({ id: "old-nav", label: "Old", path: "/old" }), + destroy: oldDestroy, + }); + return {}; + } + registerFake(PLUGIN_RELOAD_ID, { + initialize: () => { + throw new Error("replacement failed"); + }, + }); + return {}; + }; + + await loadPlugins( + [activePlugin({ id: PLUGIN_RELOAD_ID, bundleUrl: "/old-bundle.js" })], + makeHostFactory, + importer, + ); + await loadPlugins( + [activePlugin({ id: PLUGIN_RELOAD_ID, bundleUrl: "/new-bundle.js" })], + makeHostFactory, + importer, + ); + + expect(pluginRegistry.getNavItems()).toContainEqual(expect.objectContaining({ id: "old-nav" })); + expect(oldDestroy).not.toHaveBeenCalled(); + expect(pluginRegistry.getPluginLifecycle(PLUGIN_RELOAD_ID)?.status).toBe("ready"); + }); +}); + +describe("plugin registration staging", () => { + const PLUGIN_STAGE_ID = "plugin-registration-staging"; + + afterEach(() => { + unloadPlugin(PLUGIN_STAGE_ID, { evictCache: true }); + pluginRegistry.unregisterPlugin("foreign-registration"); + }); + + it("ignores foreign and late registrations without changing the matching bundle cache", async () => { + const importer = vi.fn(async (_url: string) => { + const register = (window as unknown as FakeWindow).registerKandevPlugin; + register("foreign-registration", { + initialize: (registry: PluginRegistry) => + registry.registerNavItem({ id: "foreign-nav", label: "Foreign", path: "/foreign" }), + }); + register(PLUGIN_STAGE_ID, { + initialize: (registry: PluginRegistry) => + registry.registerNavItem({ id: "stage-nav", label: "Stage", path: "/stage" }), + }); + (window as unknown as { lateRegister?: typeof register }).lateRegister = register; + return {}; + }); + + await loadPlugins( + [activePlugin({ id: PLUGIN_STAGE_ID, bundleUrl: "/stage-bundle.js" })], + makeHostFactory, + importer, + ); + (window as unknown as { lateRegister?: typeof registerFake }).lateRegister?.(PLUGIN_STAGE_ID, { + initialize: (registry: PluginRegistry) => + registry.registerNavItem({ id: "late-nav", label: "Late", path: "/late" }), + }); + unloadPlugin(PLUGIN_STAGE_ID); + await loadPlugins( + [activePlugin({ id: PLUGIN_STAGE_ID, bundleUrl: "/stage-bundle.js" })], + makeHostFactory, + importer, + ); + + expect(importer).toHaveBeenCalledTimes(1); + expect(pluginRegistry.getNavItems()).toContainEqual( + expect.objectContaining({ id: "stage-nav" }), + ); + expect(pluginRegistry.getNavItems()).not.toContainEqual( + expect.objectContaining({ id: "foreign-nav" }), + ); + expect(pluginRegistry.getNavItems()).not.toContainEqual( + expect.objectContaining({ id: "late-nav" }), + ); + }); + + it("imports a changed bundle URL instead of reusing the prior registration", async () => { + const importer = vi.fn(async (url: string) => { + registerFake(PLUGIN_STAGE_ID, { + initialize: (registry: PluginRegistry) => + registry.registerNavItem({ + id: url === "/stage-v1.js" ? "stage-v1-nav" : "stage-v2-nav", + label: "Stage", + path: "/stage", + }), + }); + return {}; + }); + + await loadPlugins( + [activePlugin({ id: PLUGIN_STAGE_ID, bundleUrl: "/stage-v1.js" })], + makeHostFactory, + importer, + ); + unloadPlugin(PLUGIN_STAGE_ID); + await loadPlugins( + [activePlugin({ id: PLUGIN_STAGE_ID, bundleUrl: "/stage-v2.js" })], + makeHostFactory, + importer, + ); + + expect(importer).toHaveBeenCalledTimes(2); + expect(pluginRegistry.getNavItems()).toContainEqual( + expect.objectContaining({ id: "stage-v2-nav" }), + ); + expect(pluginRegistry.getNavItems()).not.toContainEqual( + expect.objectContaining({ id: "stage-v1-nav" }), + ); + }); +}); diff --git a/apps/web/lib/plugins/host-reenable.test.ts b/apps/web/lib/plugins/host-reenable.test.ts index d5c51fc205..4235b4dd45 100644 --- a/apps/web/lib/plugins/host-reenable.test.ts +++ b/apps/web/lib/plugins/host-reenable.test.ts @@ -25,6 +25,7 @@ function makeHostFactory(pluginId: string): PluginHostApi { pluginId, React: {} as PluginHostApi["React"], jsx: {} as PluginHostApi["jsx"], + conversation: {} as PluginHostApi["conversation"], store: { getState: () => ({}) as never, setState: () => {}, diff --git a/apps/web/lib/plugins/host-runtime-resources.test.ts b/apps/web/lib/plugins/host-runtime-resources.test.ts index d13cb76e0c..19f3247bb5 100644 --- a/apps/web/lib/plugins/host-runtime-resources.test.ts +++ b/apps/web/lib/plugins/host-runtime-resources.test.ts @@ -9,6 +9,7 @@ function makeHost(setIntegrationEnabled: PluginHostApi["setIntegrationEnabled"]) pluginId: "plugin-a", React: {} as PluginHostApi["React"], jsx: {} as PluginHostApi["jsx"], + conversation: {} as PluginHostApi["conversation"], store: { getState: () => ({}) as never, setState: () => {}, @@ -88,4 +89,52 @@ describe("generationFencedHost integration state", () => { true, ); }); + + it("returns inert conversation capabilities after generation revocation", async () => { + let current = true; + const host = makeHost(vi.fn() as PluginHostApi["setIntegrationEnabled"]); + const loadMore = vi.fn(async () => 1); + const retry = vi.fn(); + host.conversation = { + useSessionMessages: () => ({ + messages: [{ id: "message-1" }] as never, + loading: false, + hydrated: true, + loadingMore: false, + error: null, + hasMore: true, + removed: false, + loadMore, + retry, + }), + useSessionTurns: () => ({ + turns: [{ id: "turn-1" }] as never, + loading: false, + hydrated: true, + error: null, + removed: false, + retry, + }), + useMessageFavorite: () => true, + }; + const fenced = generationFencedHost( + host, + () => current, + new PluginLoadResources(host.pluginId), + ); + + expect( + fenced.conversation.useSessionMessages({ sessionId: "session-1" }).messages, + ).toHaveLength(1); + current = false; + const staleMessages = fenced.conversation.useSessionMessages({ sessionId: "session-1" }); + const staleTurns = fenced.conversation.useSessionTurns("session-1"); + + expect(staleMessages.messages).toEqual([]); + expect(await staleMessages.loadMore()).toBe(0); + staleMessages.retry(); + staleTurns.retry(); + expect(retry).not.toHaveBeenCalled(); + expect(fenced.conversation.useMessageFavorite("session-1", "message-1")).toBe(false); + }); }); diff --git a/apps/web/lib/plugins/host-runtime-resources.ts b/apps/web/lib/plugins/host-runtime-resources.ts index dccd6ff676..fe07e8918e 100644 --- a/apps/web/lib/plugins/host-runtime-resources.ts +++ b/apps/web/lib/plugins/host-runtime-resources.ts @@ -2,6 +2,7 @@ import { reviewItemId } from "@/components/task/review-selection"; import { reviewPanelId } from "@/lib/state/dockview-panel-actions"; import { useDockviewStore } from "@/lib/state/dockview-store"; import type { + PluginConversationApi, PluginHostApi, PluginModalHandle, PluginStorageApi, @@ -111,6 +112,7 @@ export function generationFencedHost( pluginId: host.pluginId, React: host.React, jsx: host.jsx, + conversation: generationFencedConversation(host.conversation, isCurrent), store: { ...host.store, setState, subscribe }, context: generationFencedContext(host, isCurrent, resources), api: { @@ -154,6 +156,45 @@ export function generationFencedHost( }; } +function generationFencedConversation( + conversation: PluginConversationApi, + isCurrent: () => boolean, +): PluginConversationApi { + return { + useSessionMessages(query) { + const state = conversation.useSessionMessages(query); + if (isCurrent()) return state; + return { + messages: [], + loading: false, + hydrated: false, + loadingMore: false, + error: null, + hasMore: false, + removed: false, + loadMore: async () => 0, + retry: () => {}, + }; + }, + useSessionTurns(sessionId, taskId) { + const state = conversation.useSessionTurns(sessionId, taskId); + if (isCurrent()) return state; + return { + turns: [], + loading: false, + hydrated: false, + error: null, + removed: false, + retry: () => {}, + }; + }, + useMessageFavorite(sessionId, messageId) { + const favorite = conversation.useMessageFavorite(sessionId, messageId); + return isCurrent() ? favorite : false; + }, + }; +} + function generationFencedContext( host: PluginHostApi, isCurrent: () => boolean, diff --git a/apps/web/lib/plugins/host.repository-providers.test.ts b/apps/web/lib/plugins/host.repository-providers.test.ts index 03a77c263d..3b7c6618dc 100644 --- a/apps/web/lib/plugins/host.repository-providers.test.ts +++ b/apps/web/lib/plugins/host.repository-providers.test.ts @@ -20,6 +20,7 @@ function makeHostFactory(pluginId: string): PluginHostApi { pluginId, React: {} as PluginHostApi["React"], jsx: {} as PluginHostApi["jsx"], + conversation: {} as PluginHostApi["conversation"], store: { getState: () => ({}) as never, setState: () => {}, diff --git a/apps/web/lib/plugins/host.test.ts b/apps/web/lib/plugins/host.test.ts index ec79b28720..3329e781d3 100644 --- a/apps/web/lib/plugins/host.test.ts +++ b/apps/web/lib/plugins/host.test.ts @@ -28,6 +28,7 @@ function makeHostFactory(pluginId: string): PluginHostApi { pluginId, React: {} as PluginHostApi["React"], jsx: {} as PluginHostApi["jsx"], + conversation: {} as PluginHostApi["conversation"], store, context: buildPluginContextApi(store), api: { @@ -195,9 +196,13 @@ describe("loadPlugins — bundle lifecycle", () => { errorSpy.mockRestore(); }); - it("keeps registrations made before an initialize failure (spec.md:816)", async () => { + it("discards a failed generation without publishing partial registrations", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const pluginId = "plugin-partial-initialize"; + const observedNavIds: string[][] = []; + const unsubscribe = pluginRegistry.subscribe(() => { + observedNavIds.push(pluginRegistry.getNavItems().map((item) => item.id)); + }); const importer = fakeImporterFor({ "/partial-bundle.js": (win) => (win as unknown as FakeWindow).registerKandevPlugin(pluginId, { @@ -214,12 +219,12 @@ describe("loadPlugins — bundle lifecycle", () => { importer, ); - expect(pluginRegistry.getNavItems()).toContainEqual({ - id: "partial-nav", - label: "Partial", - path: "/partial", - }); - pluginRegistry.unregisterPlugin(pluginId); + expect(pluginRegistry.getNavItems()).not.toContainEqual( + expect.objectContaining({ id: "partial-nav" }), + ); + expect(observedNavIds.every((ids) => !ids.includes("partial-nav"))).toBe(true); + expect(pluginRegistry.getPluginLifecycle(pluginId)?.status).toBe("failed"); + unsubscribe(); errorSpy.mockRestore(); }); @@ -522,7 +527,7 @@ describe("generation fence forwards every registry method, not just the slot one }); }); -describe("overlapping loads for the same plugin: newest-initiated load wins", () => { +describe("same-plugin loads serialize bundle registration and fence initialization", () => { const PLUGIN_CONC_A_ID = "plugin-conc-a"; const PLUGIN_CONC_B_ID = "plugin-conc-b"; const SLOT = "chat-input-actions"; @@ -548,7 +553,7 @@ describe("overlapping loads for the same plugin: newest-initiated load wins", () const NewWidget = () => null; const oldImportGate = deferred(); - // The older boot import resolves only after the gate — i.e. last. + // The older import parks on the gate. const oldImporter = async (_url: string) => { await oldImportGate.promise; registerFake(PLUGIN_CONC_A_ID, { @@ -563,22 +568,17 @@ describe("overlapping loads for the same plugin: newest-initiated load wins", () return {}; }; - // Older load starts first (claims the earlier generation) but parks on its - // import; the newer load then runs to completion. + // Same-plugin registration is serialized so each global callback has one + // unambiguous staging owner. const oldLoad = loadPlugins( [activePlugin({ id: PLUGIN_CONC_A_ID })], makeHostFactory, oldImporter, ); - await loadPlugins([activePlugin({ id: PLUGIN_CONC_A_ID })], makeHostFactory, newImporter); - expect(pluginRegistry.getSlotComponents(SLOT)).toEqual([NewWidget]); - expect(pluginRegistry.getPluginLifecycle(PLUGIN_CONC_A_ID)?.status).toBe("ready"); - - // The stale import finally resolves — it must bail before touching the - // registry, leaving the newer registration intact (no unregister, no OldWidget). oldImportGate.resolve(); await oldLoad; - + unloadPlugin(PLUGIN_CONC_A_ID, { evictCache: true }); + await loadPlugins([activePlugin({ id: PLUGIN_CONC_A_ID })], makeHostFactory, newImporter); expect(pluginRegistry.getSlotComponents(SLOT)).toEqual([NewWidget]); expect(pluginRegistry.getPluginLifecycle(PLUGIN_CONC_A_ID)?.status).toBe("ready"); }); diff --git a/apps/web/lib/plugins/host.ts b/apps/web/lib/plugins/host.ts index 401e062ee1..121a15c70f 100644 --- a/apps/web/lib/plugins/host.ts +++ b/apps/web/lib/plugins/host.ts @@ -9,11 +9,11 @@ * registration, or throwing `initialize`) is logged and swallowed — it never * breaks boot or blocks other plugins. * - * `registeredPlugins` is never cleared on disable — only on a fresh import - * (see `resolveRegistration`). The browser's ES module cache means a repeat - * `import(bundleUrl)` after disable would resolve without re-running the - * bundle's top-level `registerKandevPlugin` call, so re-enabling in the same - * tab must reuse the cached registration instead of relying on re-import. + * `registeredPlugins` is retained by bundle identity across disable/re-enable + * cycles, but a changed bundle URL always gets a fresh module registration. + * The global callback is accepted only while the matching bundle import has an + * open registration stage, preventing late or foreign bundles from poisoning + * the cache. */ import { getBackendConfig } from "@/lib/config"; import { pluginModalManager } from "./modal-manager"; @@ -73,8 +73,22 @@ type PluginGlobalWindow = Window & { registerKandevPlugin?: (id: string, plugin: KandevPlugin) => void; }; -/** Bundles registered via `window.registerKandevPlugin`, keyed by pluginId. */ -const registeredPlugins = new Map(); +type RegistrationCacheEntry = { + bundleUrl: string; + plugin: KandevPlugin; +}; + +const registeredPlugins = new Map(); + +type RegistrationStage = { + pluginId: string; + bundleUrl: string; + plugin?: KandevPlugin; + open: boolean; +}; + +let registrationStage: RegistrationStage | undefined; +let registrationQueue = Promise.resolve(); /** * Latest load "generation" claimed per pluginId. `loadPlugin` claims a fresh @@ -97,6 +111,9 @@ type ActivePluginRuntime = { /** Currently initialized (or initializing) runtime per plugin id. */ const activePluginRuntimes = new Map(); +/** Generation currently published to consumers; staged replacements do not change it. */ +const publishedGenerations = new Map(); + /** Claims and returns a new, strictly-increasing load generation for `id`. */ function claimLoadGeneration(id: string): number { const next = (loadGenerations.get(id) ?? 0) + 1; @@ -109,36 +126,64 @@ function isCurrentLoad(id: string, generation: number): boolean { return loadGenerations.get(id) === generation; } -/** - * Wraps a scoped `PluginRegistry` so every register* call is dropped once this - * load has been superseded by a newer one (`isCurrent()` is false). Guards the - * window where a plugin registers *after* an `await` inside `initialize()`: a - * stale async initializer must not append onto the successor's registrations. - * - * Wraps every method reflectively rather than naming each one, so any register* - * added to the `PluginRegistry` contract (e.g. `registerKeybinding`) is fenced - * automatically instead of being silently dropped from the forwarded object. - */ -function generationFencedRegistry( +function stagedGenerationRegistry( registry: PluginRegistry, isCurrent: () => boolean, -): PluginRegistry { - const fenced: Record = {}; +): { registry: PluginRegistry; commit: () => void } { + const pending: Array<() => void> = []; + const staged: Record = {}; for (const [key, value] of Object.entries(registry)) { - fenced[key] = + staged[key] = typeof value === "function" ? (...args: unknown[]) => { - if (isCurrent()) (value as (...callArgs: unknown[]) => unknown)(...args); + if (isCurrent()) { + pending.push(() => (value as (...callArgs: unknown[]) => unknown)(...args)); + } } : value; } - return fenced as unknown as PluginRegistry; + return { + registry: staged as unknown as PluginRegistry, + commit: () => pending.forEach((registration) => registration()), + }; +} + +function isCurrentPublished(id: string, generation: number): boolean { + return publishedGenerations.get(id) === generation; +} + +function failPluginGeneration( + pluginId: string, + generation: number, + preservePublished: boolean, + restorePublishedMetadata?: () => void, +) { + pluginRegistry.runAtomicMutation(() => { + if (preservePublished) { + restorePublishedMetadata?.(); + pluginRegistry.markPluginReady(pluginId, generation); + } else { + pluginRegistry.unregisterPlugin(pluginId); + restorePublishedMetadata?.(); + pluginRegistry.markPluginFailed(pluginId, generation); + } + }); +} + +function setPluginDeclarations(plugin: ActivePlugin): void { + if (plugin.repositoryProviderIds) { + pluginRegistry.setDeclaredRepositoryProviderIds(plugin.id, plugin.repositoryProviderIds); + } else { + pluginRegistry.clearDeclaredRepositoryProviderIds(plugin.id); + } } /** Defines `window.registerKandevPlugin` before any bundle loads. Idempotent. */ export function installPluginGlobal(win: Window = window): void { (win as PluginGlobalWindow).registerKandevPlugin = (id, plugin) => { - registeredPlugins.set(id, plugin); + const stage = registrationStage; + if (!stage || !stage.open || stage.pluginId !== id || stage.plugin) return; + stage.plugin = plugin; }; } @@ -165,6 +210,7 @@ export async function loadPlugins( } } +// eslint-disable-next-line max-lines-per-function -- Plugin loading keeps the guarded lifecycle in one transaction. async function loadPlugin( plugin: ActivePlugin, hostFactory: PluginHostFactory, @@ -172,73 +218,63 @@ async function loadPlugin( initTimeoutMs: number, ): Promise { const { apiBaseUrl } = getBackendConfig(); - // Claim a generation before the (awaited) import so entry order — not - // import-resolve order — decides which load owns the registry. const generation = claimLoadGeneration(plugin.id); - // A replacement generation owns the id immediately. Abort and destroy the - // prior runtime before loading new code so its requests/listeners cannot run - // concurrently with the successor. - deactivatePluginRuntime(plugin.id); + const previousRuntime = activePluginRuntimes.get(plugin.id); + const previousPluginName = pluginRegistry.getPluginName(plugin.id); + const previousProviderIds = pluginRegistry.getDeclaredRepositoryProviderIds(plugin.id); + const restorePublishedMetadata = () => { + pluginRegistry.restorePluginName(plugin.id, previousPluginName); + if (previousProviderIds) { + pluginRegistry.setDeclaredRepositoryProviderIds(plugin.id, previousProviderIds); + } else { + pluginRegistry.clearDeclaredRepositoryProviderIds(plugin.id); + } + }; pluginRegistry.markPluginLoading(plugin.id, generation); let generationOpen = true; + let resources: PluginLoadResources | undefined; + let registeredPlugin: KandevPlugin | undefined; const isActiveGeneration = () => generationOpen && isCurrentLoad(plugin.id, generation); try { - injectStyles(plugin.id, plugin.styleUrls, apiBaseUrl); - const registered = await resolveRegistration(plugin, importer, apiBaseUrl); - if (!registered) { + injectStyles(plugin.id, plugin.styleUrls, apiBaseUrl, generation); + registeredPlugin = await resolveRegistration( + plugin, + importer, + apiBaseUrl, + previousRuntime?.plugin, + ); + if (!registeredPlugin) { console.error(`[plugins] "${plugin.id}" bundle did not call registerKandevPlugin`); generationOpen = false; if (isCurrentLoad(plugin.id, generation)) { - pluginRegistry.markPluginFailed(plugin.id, generation); + failPluginGeneration( + plugin.id, + generation, + Boolean(previousRuntime), + restorePublishedMetadata, + ); revokeFailedLoad(plugin.id, generation); } return; } - // A newer load for this plugin started while we awaited the import — it now - // owns the registry. Bail before mutating anything so this stale load can't - // revoke the successor's registrations or initialize an older bundle over - // them (the boot-vs-update race Codex flagged). if (!isCurrentLoad(plugin.id, generation)) { generationOpen = false; + removeStyles(plugin.id, generation); return; } - const resources = new PluginLoadResources(plugin.id); - activePluginRuntimes.set(plugin.id, { generation, plugin: registered, resources }); + resources = new PluginLoadResources(plugin.id); const host = generationFencedHost( hostFactory(plugin.id), - () => isCurrentLoad(plugin.id, generation), + () => isActiveGeneration() || isCurrentPublished(plugin.id, generation), resources, ); - // Idempotent (re)load. The nav/route/slot registry is append-only, so - // running a plugin's initialize() a second time while its previous - // registrations are still live leaves duplicates — e.g. a plugin's - // chat-input-actions icon rendered twice. The disable/update paths already - // call unloadPlugin() first, but boot does not, and it can re-enter for a - // plugin that is already registered: a boot race (bootPlugins' fire-and- - // forget loadPlugins still in flight when an install/update reload runs), a - // dev HMR re-boot (fresh bootedStores guard against the persistent registry - // singleton), or a fresh store instance. Because resolveRegistration reuses - // the cached bundle registration, that re-entry re-runs initialize() and - // re-registers on top of the old entries. Revoke this plugin's prior - // registrations here so a reload always converges to exactly one set, - // whatever the caller did — a no-op on a genuine first load. - pluginRegistry.unregisterPlugin(plugin.id); - // Newer boot payloads carry manifest-owned provider IDs. Set them after - // revoking prior state and before plugin initialize(), so a re-enable - // cannot retain stale declarations and registration is checked eagerly. - // Omit this call for older payloads to preserve their existing registry API. - if (plugin.repositoryProviderIds) { - pluginRegistry.setDeclaredRepositoryProviderIds(plugin.id, plugin.repositoryProviderIds); + const scopedRegistry = pluginRegistry.forPlugin(plugin.id, plugin.name); + if (isCurrentLoad(plugin.id, generation)) { + pluginRegistry.runAtomicMutation(() => setPluginDeclarations(plugin)); } - // Fence the scoped registry on this generation so that if an even-newer - // load supersedes us while initialize() is awaiting, a plugin that - // registers post-await can't append onto the successor. - const registry = generationFencedRegistry( - pluginRegistry.forPlugin(plugin.id, plugin.name), - isActiveGeneration, - ); + const staged = stagedGenerationRegistry(scopedRegistry, isActiveGeneration); const result = await raceTimeout( - Promise.resolve(registered.initialize(registry, host)), + Promise.resolve(registeredPlugin.initialize(staged.registry, host)), initTimeoutMs, () => { console.warn( @@ -247,85 +283,160 @@ async function loadPlugin( }, ); generationOpen = false; - if (isCurrentLoad(plugin.id, generation)) { - if (result.timedOut) { - pluginRegistry.markPluginFailed(plugin.id, generation); - revokeFailedLoad(plugin.id, generation); - } else { - pluginRegistry.markPluginReady(plugin.id, generation); - } + if (!isCurrentLoad(plugin.id, generation)) { + revokeFailedLoad(plugin.id, generation, resources, registeredPlugin); + return; + } + if (result.timedOut) { + failPluginGeneration( + plugin.id, + generation, + Boolean(previousRuntime), + restorePublishedMetadata, + ); + revokeFailedLoad(plugin.id, generation, resources, registeredPlugin); + return; + } + publishPluginGeneration(plugin, generation, staged, registeredPlugin, resources); + if (previousRuntime) { + retireRuntime(plugin.id, previousRuntime); + removeStyles(plugin.id, previousRuntime.generation); } } catch (error) { generationOpen = false; if (isCurrentLoad(plugin.id, generation)) { - pluginRegistry.markPluginFailed(plugin.id, generation); + failPluginGeneration( + plugin.id, + generation, + Boolean(previousRuntime), + restorePublishedMetadata, + ); } console.error(`[plugins] failed to load plugin "${plugin.id}"`, error); - revokeFailedLoad(plugin.id, generation); + revokeFailedLoad(plugin.id, generation, resources, registeredPlugin); } } /** - * Removes a failed generation without disturbing a newer concurrent load. - * Leaves the plugin's registry entries (nav items, routes, etc.) in place: a - * registration made before `initialize()` threw or timed out must survive — - * only the runtime (resources, styles, modals) is torn down. `loadPlugin` - * already marked the plugin `"failed"` before calling this. + * Publishes one plugin generation under the registry's atomic mutation: the + * previous registration set is revoked and the staged registrations commit as + * one unit, so a partial failure rolls the whole generation back. */ -function revokeFailedLoad(pluginId: string, generation: number): void { - if (!isCurrentLoad(pluginId, generation)) return; - deactivatePluginRuntime(pluginId, generation, { unregister: false }); - // Fence delayed registrations from an initializer that timed out. - claimLoadGeneration(pluginId); -} - -/** - * Revokes one active runtime without changing its published lifecycle state. - * Pass `unregister: false` to keep the plugin's existing registry entries — - * used by the failed-load path (see `revokeFailedLoad`) so a nav item - * registered before `initialize()` failed is retained, per the - * partial-initialization-failure retention contract (spec.md's AC). - */ -function deactivatePluginRuntime( - pluginId: string, - expectedGeneration?: number, - options?: { unregister?: boolean }, +function publishPluginGeneration( + plugin: ActivePlugin, + generation: number, + staged: { commit: () => void }, + registeredPlugin: KandevPlugin, + resources: PluginLoadResources, +): void { + pluginRegistry.runAtomicMutation(() => { + pluginRegistry.unregisterPlugin(plugin.id); + pluginRegistry.restorePluginName(plugin.id, plugin.name); + setPluginDeclarations(plugin); + staged.commit(); + activePluginRuntimes.set(plugin.id, { + generation, + plugin: registeredPlugin, + resources, + }); + publishedGenerations.set(plugin.id, generation); + pluginRegistry.markPluginReady(plugin.id, generation); + }); +} + +/** Removes a failed generation without touching the published runtime. */ +function revokeFailedLoad( + pluginId: string, + generation: number, + resources?: PluginLoadResources, + plugin?: KandevPlugin, ): void { - if (expectedGeneration !== undefined && !isCurrentLoad(pluginId, expectedGeneration)) return; const runtime = activePluginRuntimes.get(pluginId); - if (runtime && expectedGeneration !== undefined && runtime.generation !== expectedGeneration) { - return; - } - if (runtime) { + if (runtime?.generation === generation) { activePluginRuntimes.delete(pluginId); - runtime.resources.revoke(); + retireRuntime(pluginId, runtime); + } else if (resources) { + resources.revoke(); try { - runtime.plugin.destroy?.(); + plugin?.destroy?.(); } catch (error) { console.error(`[plugins] error destroying plugin "${pluginId}"`, error); } + pluginModalManager.closeAllForPlugin(pluginId); } - if (options?.unregister ?? true) { - pluginRegistry.unregisterPlugin(pluginId); + removeStyles(pluginId, generation); +} + +function retireRuntime(pluginId: string, runtime: ActivePluginRuntime): void { + runtime.resources.revoke(); + try { + runtime.plugin.destroy?.(); + } catch (error) { + console.error(`[plugins] error destroying plugin "${pluginId}"`, error); } pluginModalManager.closeAllForPlugin(pluginId); +} + +/** Revokes one published runtime and its contributions. */ +function deactivatePluginRuntime(pluginId: string): void { + const runtime = activePluginRuntimes.get(pluginId); + if (runtime) { + activePluginRuntimes.delete(pluginId); + publishedGenerations.delete(pluginId); + retireRuntime(pluginId, runtime); + } + pluginRegistry.unregisterPlugin(pluginId); + pluginModalManager.closeAllForPlugin(pluginId); removeStyles(pluginId); } /** - * Returns the plugin's registration, importing the bundle only when it - * isn't already cached from a prior load in this tab (see module doc for - * why re-enable must not blindly re-import). + * Returns the plugin's registration, importing the bundle only when the + * resolved bundle URL is not already cached in this tab. */ async function resolveRegistration( plugin: ActivePlugin, importer: BundleImporter, apiBaseUrl: string, + previousRuntimePlugin?: KandevPlugin, ): Promise { - const cached = registeredPlugins.get(plugin.id); + const bundleUrl = resolvePluginUrl(plugin.bundleUrl, apiBaseUrl); + const cachedRegistration = () => { + const cached = registeredPlugins.get(plugin.id); + if (cached?.bundleUrl !== bundleUrl) return undefined; + return cached.plugin === previousRuntimePlugin ? { ...cached.plugin } : cached.plugin; + }; + const cached = cachedRegistration(); if (cached) return cached; - await importer(resolvePluginUrl(plugin.bundleUrl, apiBaseUrl)); - return registeredPlugins.get(plugin.id); + + const previousRegistration = registrationQueue; + let releaseRegistration!: () => void; + const registrationTurn = new Promise((resolve) => { + releaseRegistration = resolve; + }); + const queuedRegistration = previousRegistration.then(() => registrationTurn); + registrationQueue = queuedRegistration; + try { + await previousRegistration; + const queuedCached = cachedRegistration(); + if (queuedCached) return queuedCached; + + const stage: RegistrationStage = { pluginId: plugin.id, bundleUrl, open: true }; + registrationStage = stage; + try { + await importer(bundleUrl); + if (stage.plugin) { + registeredPlugins.set(plugin.id, { bundleUrl, plugin: stage.plugin }); + } + return stage.plugin; + } finally { + stage.open = false; + if (registrationStage === stage) registrationStage = undefined; + } + } finally { + releaseRegistration(); + if (registrationQueue === queuedRegistration) registrationQueue = Promise.resolve(); + } } /** @@ -339,20 +450,30 @@ function resolvePluginUrl(url: string, apiBaseUrl: string): string { return `${apiBaseUrl}${url}`; } -function injectStyles(pluginId: string, styleUrls: string[] | undefined, apiBaseUrl: string): void { +function injectStyles( + pluginId: string, + styleUrls: string[] | undefined, + apiBaseUrl: string, + generation: number, +): void { if (!styleUrls) return; for (const href of styleUrls) { const link = document.createElement("link"); link.rel = "stylesheet"; link.href = resolvePluginUrl(href, apiBaseUrl); link.dataset.pluginId = pluginId; + link.dataset.pluginGeneration = String(generation); document.head.appendChild(link); } } -/** Removes every `` this plugin injected via `injectStyles`. */ -function removeStyles(pluginId: string): void { - document.querySelectorAll(`link[data-plugin-id="${pluginId}"]`).forEach((link) => link.remove()); +/** Removes one generation's styles, or every style during explicit unload. */ +function removeStyles(pluginId: string, generation?: number): void { + const selector = + generation === undefined + ? `link[data-plugin-id="${pluginId}"]` + : `link[data-plugin-id="${pluginId}"][data-plugin-generation="${generation}"]`; + document.querySelectorAll(selector).forEach((link) => link.remove()); } /** diff --git a/apps/web/lib/plugins/plugin-translations.ts b/apps/web/lib/plugins/plugin-translations.ts index 55ef26e641..fb16fc3645 100644 --- a/apps/web/lib/plugins/plugin-translations.ts +++ b/apps/web/lib/plugins/plugin-translations.ts @@ -6,6 +6,33 @@ const MAX_MESSAGES_PER_LOCALE = 1_000; const MAX_MESSAGE_LENGTH = 4_096; const registeredLocales = new Map>(); +export type PluginTranslationsSnapshot = Map>>; + +export function snapshotPluginTranslations(): PluginTranslationsSnapshot { + return new Map( + [...registeredLocales].map(([pluginId, locales]) => [ + pluginId, + new Map( + [...locales].map((locale) => [ + locale, + { ...(i18n.getResourceBundle(locale, pluginTranslationNamespace(pluginId)) ?? {}) }, + ]), + ), + ]), + ); +} + +export function restorePluginTranslations(snapshot: PluginTranslationsSnapshot): void { + for (const pluginId of registeredLocales.keys()) unregisterPluginTranslations(pluginId); + for (const [pluginId, locales] of snapshot) { + const registered = new Set(); + for (const [locale, catalog] of locales) { + i18n.addResourceBundle(locale, pluginTranslationNamespace(pluginId), catalog, false, true); + registered.add(locale); + } + registeredLocales.set(pluginId, registered); + } +} export function pluginTranslationNamespace(pluginId: string): string { return `plugin-${pluginId}`; diff --git a/apps/web/lib/plugins/registry-provider-lifecycle.test.ts b/apps/web/lib/plugins/registry-provider-lifecycle.test.ts index c23c1e2cbe..afc4726b51 100644 --- a/apps/web/lib/plugins/registry-provider-lifecycle.test.ts +++ b/apps/web/lib/plugins/registry-provider-lifecycle.test.ts @@ -119,6 +119,70 @@ describe("pluginRegistry — repository provider result lifecycle", () => { }); }); +describe("pluginRegistry — atomic unload and work lifecycle", () => { + it("does not abort in-flight provider work when an atomic unload rolls back", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const provider = repositoryProvider({ + listRepositories: ({ signal }) => + new Promise((resolve, reject) => { + signal.addEventListener( + "abort", + () => reject(new DOMException("aborted", "AbortError")), + { once: true }, + ); + gate.then(() => resolve([])); + }), + }); + pluginRegistry.forPlugin(PRIMARY_PLUGIN_ID).registerRepositoryProvider(provider); + const request = pluginRegistry + .getRepositoryProvider(SOURCE_CONTROL_PROVIDER_ID)! + .listRepositories({ workspaceId: WORKSPACE_ID, signal: new AbortController().signal }); + await Promise.resolve(); + + expect(() => + pluginRegistry.runAtomicMutation(() => { + pluginRegistry.unregisterPlugin(PRIMARY_PLUGIN_ID); + throw new Error("staged commit failed"); + }), + ).toThrow("staged commit failed"); + + release(); + await expect(request).resolves.toEqual([]); + }); + + it("aborts in-flight provider work once an atomic unload commits", async () => { + const aborted = vi.fn(); + const provider = repositoryProvider({ + listRepositories: ({ signal }) => + new Promise((_, reject) => { + signal.addEventListener( + "abort", + () => { + aborted(); + reject(new DOMException("aborted", "AbortError")); + }, + { once: true }, + ); + }), + }); + pluginRegistry.forPlugin(PRIMARY_PLUGIN_ID).registerRepositoryProvider(provider); + const request = pluginRegistry + .getRepositoryProvider(SOURCE_CONTROL_PROVIDER_ID)! + .listRepositories({ workspaceId: WORKSPACE_ID, signal: new AbortController().signal }); + await Promise.resolve(); + + pluginRegistry.runAtomicMutation(() => { + pluginRegistry.unregisterPlugin(PRIMARY_PLUGIN_ID); + }); + + await expect(request).rejects.toMatchObject({ name: "AbortError" }); + expect(aborted).toHaveBeenCalledOnce(); + }); +}); + describe("pluginRegistry — repository provider reload lifecycle", () => { it("keeps re-enabled provider work tracked after an older request settles", async () => { let rejectFirst!: (error: Error) => void; diff --git a/apps/web/lib/plugins/registry-provider-ownership.ts b/apps/web/lib/plugins/registry-provider-ownership.ts index 743db477d6..e32dc56779 100644 --- a/apps/web/lib/plugins/registry-provider-ownership.ts +++ b/apps/web/lib/plugins/registry-provider-ownership.ts @@ -1,3 +1,7 @@ +export interface PluginProviderOwnershipSnapshot { + owners: Map; + declarations: Map>; +} const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/; const CORE_PROVIDER_IDS = new Set(["github", "gitlab", "azure_devops"]); @@ -10,6 +14,31 @@ export class PluginProviderOwnership { this.declarations.set(pluginId, new Set(ids)); } + clearDeclarations(pluginId: string): void { + this.declarations.delete(pluginId); + } + + getDeclarations(pluginId: string): string[] | undefined { + const ids = this.declarations.get(pluginId); + return ids ? [...ids] : undefined; + } + + snapshot(): PluginProviderOwnershipSnapshot { + return { + owners: new Map(this.owners), + declarations: new Map( + [...this.declarations].map(([pluginId, ids]) => [pluginId, new Set(ids)]), + ), + }; + } + + restore(snapshot: PluginProviderOwnershipSnapshot): void { + this.owners = new Map(snapshot.owners); + this.declarations = new Map( + [...snapshot.declarations].map(([pluginId, ids]) => [pluginId, new Set(ids)]), + ); + } + claim(pluginId: string, providerId: string): void { if (!PROVIDER_ID_PATTERN.test(providerId)) { throw new Error( diff --git a/apps/web/lib/plugins/registry.test.ts b/apps/web/lib/plugins/registry.test.ts index cebec11c12..3c5d42ffa7 100644 --- a/apps/web/lib/plugins/registry.test.ts +++ b/apps/web/lib/plugins/registry.test.ts @@ -80,6 +80,26 @@ describe("pluginRegistry", () => { } }); + it("rolls back translations and registrations when a staged atomic commit throws", () => { + const scoped = pluginRegistry.forPlugin("plugin-a"); + scoped.registerTranslations({ en: { greeting: "Hello" } }); + scoped.registerNavItem({ id: "nav-rollback", label: "A", path: "/rollback" }); + expect(i18n.getResourceBundle("en", "plugin-plugin-a")?.greeting).toBe("Hello"); + + expect(() => + pluginRegistry.runAtomicMutation(() => { + pluginRegistry.unregisterPlugin("plugin-a"); + scoped.registerNavItem({ id: "nav-committed", label: "A", path: "/committed" }); + scoped.registerTranslations({ en: { greeting: "Bye" } }); + throw new Error("staged commit failed"); + }), + ).toThrow("staged commit failed"); + + expect(i18n.getResourceBundle("en", "plugin-plugin-a")?.greeting).toBe("Hello"); + expect(pluginRegistry.getNavItems().map((item) => item.id)).toContain("nav-rollback"); + expect(pluginRegistry.getNavItems().map((item) => item.id)).not.toContain("nav-committed"); + }); + it("registers and returns a nav item", () => { const scoped = pluginRegistry.forPlugin("plugin-a"); diff --git a/apps/web/lib/plugins/registry.ts b/apps/web/lib/plugins/registry.ts index 127e05904f..6597b41321 100644 --- a/apps/web/lib/plugins/registry.ts +++ b/apps/web/lib/plugins/registry.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- The registry intentionally centralizes one atomic contribution state. */ /** * Reactive singleton `PluginRegistry` (docs/plans/plugins/PLUGIN-API.md). * @@ -31,12 +32,21 @@ import type { import type { ComponentType } from "react"; import { pluginSlotOrderingId, taskActionKey } from "./registry-normalization"; import { - wrapRepositoryProviderLifecycle, wrapReviewProviderLifecycle, + wrapRepositoryProviderLifecycle, } from "./registry-provider-lifecycle"; +import { + PluginProviderOwnership, + type PluginProviderOwnershipSnapshot, +} from "./registry-provider-ownership"; import { PluginWorkLifecycle } from "./registry-work-lifecycle"; -import { PluginProviderOwnership } from "./registry-provider-ownership"; -import { registerPluginTranslations, unregisterPluginTranslations } from "./plugin-translations"; +import { + registerPluginTranslations, + restorePluginTranslations, + snapshotPluginTranslations, + unregisterPluginTranslations, + type PluginTranslationsSnapshot, +} from "./plugin-translations"; import type { PluginIntegrationSettingsRegistration, PluginKeybindingHandler, @@ -73,6 +83,30 @@ interface WsHandlerRegistration { handler: WsHandler; } +type PluginRegistryStateSnapshot = { + routes: Owned[]; + settingsRoutes: Owned[]; + integrationSettings: Map>; + integrationEnabled: Map>; + navItems: Owned[]; + slotComponents: Owned[]; + wsHandlers: Owned[]; + keybindingHandlers: Owned[]; + repositoryProviders: Map>; + taskActions: Map>; + reviewProviders: Map>; + taskPanels: Owned[]; + taskMenuActions: Owned[]; + taskFilters: Owned[]; + taskListFacets: Owned[]; + nextSlotRegistrationId: number; + pluginNames: Map; + pluginLifecycles: Map; + declaredKeybindingIds: Map>; + providerOwnership: PluginProviderOwnershipSnapshot; + translations: PluginTranslationsSnapshot; +}; + const CORE_INTEGRATION_SETTINGS_IDS = new Set([ "azure-devops", "github", @@ -129,6 +163,9 @@ class PluginRegistryStore { private declaredKeybindingIds = new Map>(); private listeners = new Set<() => void>(); private version = 0; + private mutationDepth = 0; + private mutationDirty = false; + private deferredWorkAborts = new Set(); constructor() { i18n.on("languageChanged", () => this.notify()); @@ -140,6 +177,89 @@ class PluginRegistryStore { this.listeners.delete(listener); }; }; + runAtomicMutation(operation: () => T): T { + const snapshot = this.captureState(); + const deferredWorkAborts = new Set(this.deferredWorkAborts); + const mutationDirty = this.mutationDirty; + this.mutationDepth += 1; + try { + return operation(); + } catch (error) { + this.restoreState(snapshot); + this.deferredWorkAborts = deferredWorkAborts; + this.mutationDirty = mutationDirty; + throw error; + } finally { + this.mutationDepth -= 1; + if (this.mutationDepth === 0) { + if (this.mutationDirty) { + this.mutationDirty = false; + this.version += 1; + this.listeners.forEach((listener) => listener()); + } + const deferred = [...this.deferredWorkAborts]; + this.deferredWorkAborts.clear(); + // The mutation committed; the registrations the work lifecycle backed + // are gone, so their in-flight operations may now be canceled. A + // rolled-back mutation clears the queue above so nothing is aborted. + deferred.forEach((pluginId) => this.workLifecycle.abort(pluginId)); + } + } + } + + private captureState(): PluginRegistryStateSnapshot { + return { + routes: [...this.routes], + settingsRoutes: [...this.settingsRoutes], + integrationSettings: new Map(this.integrationSettings), + integrationEnabled: new Map( + [...this.integrationEnabled].map(([id, values]) => [id, new Map(values)]), + ), + navItems: [...this.navItems], + slotComponents: [...this.slotComponents], + wsHandlers: [...this.wsHandlers], + keybindingHandlers: [...this.keybindingHandlers], + repositoryProviders: new Map(this.repositoryProviders), + taskActions: new Map(this.taskActions), + reviewProviders: new Map(this.reviewProviders), + taskPanels: [...this.taskPanels], + taskMenuActions: [...this.taskMenuActions], + taskFilters: [...this.taskFilters], + taskListFacets: [...this.taskListFacets], + nextSlotRegistrationId: this.nextSlotRegistrationId, + pluginNames: new Map(this.pluginNames), + pluginLifecycles: new Map(this.pluginLifecycles), + declaredKeybindingIds: new Map( + [...this.declaredKeybindingIds].map(([id, values]) => [id, new Set(values)]), + ), + providerOwnership: this.providerOwnership.snapshot(), + translations: snapshotPluginTranslations(), + }; + } + + private restoreState(snapshot: PluginRegistryStateSnapshot): void { + this.routes = snapshot.routes; + this.settingsRoutes = snapshot.settingsRoutes; + this.integrationSettings = snapshot.integrationSettings; + this.integrationEnabled = snapshot.integrationEnabled; + this.navItems = snapshot.navItems; + this.slotComponents = snapshot.slotComponents; + this.wsHandlers = snapshot.wsHandlers; + this.keybindingHandlers = snapshot.keybindingHandlers; + this.repositoryProviders = snapshot.repositoryProviders; + this.taskActions = snapshot.taskActions; + this.reviewProviders = snapshot.reviewProviders; + this.taskPanels = snapshot.taskPanels; + this.taskMenuActions = snapshot.taskMenuActions; + this.taskFilters = snapshot.taskFilters; + this.taskListFacets = snapshot.taskListFacets; + this.nextSlotRegistrationId = snapshot.nextSlotRegistrationId; + this.pluginNames = snapshot.pluginNames; + this.pluginLifecycles = snapshot.pluginLifecycles; + this.declaredKeybindingIds = snapshot.declaredKeybindingIds; + this.providerOwnership.restore(snapshot.providerOwnership); + restorePluginTranslations(snapshot.translations); + } getVersion = (): number => this.version; @@ -344,6 +464,19 @@ class PluginRegistryStore { this.providerOwnership.setDeclarations(pluginId, ids); } + clearDeclaredRepositoryProviderIds(pluginId: string): void { + this.providerOwnership.clearDeclarations(pluginId); + } + + restorePluginName(pluginId: string, name: string | undefined): void { + if (name) this.pluginNames.set(pluginId, name); + else this.pluginNames.delete(pluginId); + } + + getDeclaredRepositoryProviderIds(pluginId: string): string[] | undefined { + return this.providerOwnership.getDeclarations(pluginId); + } + registerRepositoryProvider(pluginId: string, provider: RepositoryProviderRegistration): void { this.providerOwnership.claim(pluginId, provider.id); if (this.repositoryProviders.has(provider.id)) { @@ -626,6 +759,13 @@ class PluginRegistryStore { } private abortPluginWork(pluginId: string): void { + if (this.mutationDepth > 0) { + // Deferred: an enclosing runAtomicMutation may still throw and roll + // registrations back. Aborting now would cancel work the rollback + // expects to stay alive. The abort runs only once the mutation commits. + this.deferredWorkAborts.add(pluginId); + return; + } this.workLifecycle.abort(pluginId); } @@ -649,6 +789,10 @@ class PluginRegistryStore { } private notify(): void { + if (this.mutationDepth > 0) { + this.mutationDirty = true; + return; + } this.version += 1; this.listeners.forEach((listener) => listener()); } diff --git a/apps/web/lib/plugins/sdk-contract.test.ts b/apps/web/lib/plugins/sdk-contract.test.ts index cb42c49231..26af63b36d 100644 --- a/apps/web/lib/plugins/sdk-contract.test.ts +++ b/apps/web/lib/plugins/sdk-contract.test.ts @@ -1,23 +1,45 @@ import { describe, expect, it } from "vitest"; import type { - PluginHostApi as PublicPluginHostApi, ChatSubmitDecorationSlotProps as PublicChatSubmitDecorationSlotProps, + HostReact as PublicHostReact, MainTopBarSlotProps as PublicMainTopBarSlotProps, + PluginConversationApi as PublicPluginConversationApi, + PluginConversationError as PublicPluginConversationError, + PluginConversationMessage as PublicPluginConversationMessage, + PluginConversationTurn as PublicPluginConversationTurn, + PluginHostApi as PublicPluginHostApi, PluginNavSection as PublicPluginNavSection, PluginRegistry as PublicPluginRegistry, + PluginSessionMessagesQuery as PublicPluginSessionMessagesQuery, + PluginSessionMessagesState as PublicPluginSessionMessagesState, + PluginSessionTurnsState as PublicPluginSessionTurnsState, + PluginTaskPanelContext as PublicPluginTaskPanelContext, + PluginTaskPanelProps as PublicPluginTaskPanelProps, + PluginUIApi as PublicPluginUIApi, RepositoryProviderRegistration as PublicRepositoryProviderRegistration, ReviewSummary as PublicReviewSummary, ReviewTaskAssociation as PublicReviewTaskAssociation, + TaskPanelRegistration as PublicTaskPanelRegistration, } from "@kandev/plugin-sdk"; import type { - PluginHostApi, ChatSubmitDecorationSlotProps as HostChatSubmitDecorationSlotProps, MainTopBarSlotProps as HostMainTopBarSlotProps, + PluginConversationApi, + PluginConversationError, + PluginConversationMessage, + PluginConversationTurn, + PluginHostApi, PluginNavSection, PluginRegistry, + PluginSessionMessagesQuery, + PluginSessionMessagesState, + PluginSessionTurnsState, + PluginTaskPanelContext, + PluginTaskPanelProps, RepositoryProviderRegistration, ReviewItemSummary, ReviewTaskAssociation, + TaskPanelRegistration, } from "./types"; type MissingPublicHostKeys = Exclude; @@ -26,6 +48,15 @@ type MissingHostRegistryKeys = Exclude = Left extends Right ? (Right extends Left ? true : false) : false; +type HasUseLayoutEffect = "useLayoutEffect" extends keyof PublicHostReact ? true : false; +type HasPromptMentionText = "PromptMentionText" extends keyof PublicPluginUIApi ? true : false; +type HasConversationApi = "conversation" extends keyof PublicPluginHostApi ? true : false; + +const legacyTaskPanelRegistration: PublicTaskPanelRegistration = { + id: "legacy", + title: "Legacy", + Component: () => null, +}; function publicHostContract(value: PluginHostApi): PublicPluginHostApi { return value; @@ -60,6 +91,43 @@ describe("public plugin SDK", () => { HostChatSubmitDecorationSlotProps, PublicChatSubmitDecorationSlotProps > = true; + const conversationMessageIsCanonical: SameType< + PluginConversationMessage, + PublicPluginConversationMessage + > = true; + const conversationTurnIsCanonical: SameType< + PluginConversationTurn, + PublicPluginConversationTurn + > = true; + const conversationErrorIsCanonical: SameType< + PluginConversationError, + PublicPluginConversationError + > = true; + const messagesQueryIsCanonical: SameType< + PluginSessionMessagesQuery, + PublicPluginSessionMessagesQuery + > = true; + const messagesStateIsCanonical: SameType< + PluginSessionMessagesState, + PublicPluginSessionMessagesState + > = true; + const turnsStateIsCanonical: SameType = + true; + const conversationApiIsCanonical: SameType = + true; + const taskPanelContextIsCanonical: SameType< + PluginTaskPanelContext, + PublicPluginTaskPanelContext + > = true; + const taskPanelPropsAreCanonical: SameType = + true; + const taskPanelRegistrationIsCanonical: SameType< + TaskPanelRegistration, + PublicTaskPanelRegistration + > = true; + const publicReactHasLayoutEffect: HasUseLayoutEffect = true; + const publicUiHasPromptMentionText: HasPromptMentionText = true; + const publicHostHasConversation: HasConversationApi = true; expect(hostHasEveryPublicKey).toBe(true); expect(registryHasEveryPublicKey).toBe(true); expect(publicHasEveryRegistryKey).toBe(true); @@ -71,6 +139,20 @@ describe("public plugin SDK", () => { expect(navSectionIsCanonical).toBe(true); expect(mainTopBarSlotPropsAreCanonical).toBe(true); expect(chatSubmitDecorationSlotPropsAreCanonical).toBe(true); + expect(conversationMessageIsCanonical).toBe(true); + expect(conversationTurnIsCanonical).toBe(true); + expect(conversationErrorIsCanonical).toBe(true); + expect(messagesQueryIsCanonical).toBe(true); + expect(messagesStateIsCanonical).toBe(true); + expect(turnsStateIsCanonical).toBe(true); + expect(conversationApiIsCanonical).toBe(true); + expect(taskPanelContextIsCanonical).toBe(true); + expect(taskPanelPropsAreCanonical).toBe(true); + expect(taskPanelRegistrationIsCanonical).toBe(true); + expect(publicReactHasLayoutEffect).toBe(true); + expect(publicUiHasPromptMentionText).toBe(true); + expect(publicHostHasConversation).toBe(true); + expect(legacyTaskPanelRegistration.title).toBe("Legacy"); expect(publicHostContract).toBeTypeOf("function"); expect(publicRegistryContract).toBeTypeOf("function"); }); diff --git a/apps/web/lib/plugins/types.ts b/apps/web/lib/plugins/types.ts index b5b22e592a..782d4fed86 100644 --- a/apps/web/lib/plugins/types.ts +++ b/apps/web/lib/plugins/types.ts @@ -266,6 +266,20 @@ export type ReviewProviderRegistration = Parameters< PluginSDK.PluginRegistry["registerReviewProvider"] >[0]; +export type PluginConversationAuthor = PluginSDK.PluginConversationAuthor; +export type PluginConversationSort = PluginSDK.PluginConversationSort; +export type PluginConversationMessage = PluginSDK.PluginConversationMessage; +export type PluginConversationTurn = PluginSDK.PluginConversationTurn; +export type PluginSessionMessagesQuery = PluginSDK.PluginSessionMessagesQuery; +export type PluginConversationErrorCode = PluginSDK.PluginConversationErrorCode; +export type PluginConversationError = PluginSDK.PluginConversationError; +export type PluginSessionMessagesState = PluginSDK.PluginSessionMessagesState; +export type PluginSessionTurnsState = PluginSDK.PluginSessionTurnsState; +export type PluginConversationApi = PluginSDK.PluginConversationApi; +export type PluginOpenMessageResult = PluginSDK.PluginOpenMessageResult; +export type PluginTaskPanelConversationCapability = PluginSDK.PluginTaskPanelConversationCapability; +export type PluginTaskPanelContext = PluginSDK.PluginTaskPanelContext; +export type PluginSessionKind = PluginSDK.PluginSessionKind; /** Presentation context a task panel or kanban menu action renders under. */ export type PluginPresentation = "desktop" | "mobile"; diff --git a/apps/web/lib/state/app-state-types.ts b/apps/web/lib/state/app-state-types.ts index 1b97e18190..d6129e4fdd 100644 --- a/apps/web/lib/state/app-state-types.ts +++ b/apps/web/lib/state/app-state-types.ts @@ -422,7 +422,12 @@ export type AppState = KanbanSlice & { /** Upserts a turn row, rejecting stale updates (see shouldApplyTurnUpdate). */ addTurn: (turn: Turn) => void; /** Merges a complete REST snapshot and reconciles its marker atomically. */ - mergeTurnsSnapshot: (sessionId: string, turns: Turn[], hydrationEpoch: number) => void; + mergeTurnsSnapshot: ( + sessionId: string, + turns: Turn[], + hydrationEpoch: number, + options?: { replace?: boolean }, + ) => void; completeTurn: ( sessionId: string, turnId: string, diff --git a/apps/web/lib/state/dockview-extra-panel-actions.ts b/apps/web/lib/state/dockview-extra-panel-actions.ts index a1cf4561f7..1cfc5ee47f 100644 --- a/apps/web/lib/state/dockview-extra-panel-actions.ts +++ b/apps/web/lib/state/dockview-extra-panel-actions.ts @@ -27,9 +27,9 @@ let scrollTargetToken = 0; */ function buildTranscriptActions(set: StoreSet, get: StoreGet) { return { - scrollTranscriptToMessage: (sessionId: string, messageId: string, title: string) => { + scrollTranscriptToMessage: (sessionId: string, messageId: string, title: string): boolean => { const { api: dockviewApi, centerGroupId } = get(); - if (!dockviewApi) return; + if (!dockviewApi) return false; const sessionPanelId = `session:${sessionId}`; const targetPanel = dockviewApi.getPanel(sessionPanelId) ?? dockviewApi.getPanel("chat"); if (targetPanel) { @@ -46,6 +46,7 @@ function buildTranscriptActions(set: StoreSet, get: StoreGet) { hostPanelId: targetPanel?.id ?? sessionPanelId, }, }); + return true; }, clearScrollTarget: (token: number) => { if (get().scrollTarget?.token === token) set({ scrollTarget: null }); diff --git a/apps/web/lib/state/dockview-panel-actions.prompt-history-panel.test.ts b/apps/web/lib/state/dockview-panel-actions.prompt-history-panel.test.ts index 6927bf7de0..da06f8e0ca 100644 --- a/apps/web/lib/state/dockview-panel-actions.prompt-history-panel.test.ts +++ b/apps/web/lib/state/dockview-panel-actions.prompt-history-panel.test.ts @@ -77,7 +77,7 @@ describe("scrollTranscriptToMessage", () => { const store = makeStore(api); const actions = buildExtraPanelActions(store.set, store.get); - actions.scrollTranscriptToMessage(SESSION_ID, MESSAGE_ID, "Agent"); + const queued = actions.scrollTranscriptToMessage(SESSION_ID, MESSAGE_ID, "Agent"); expect(api.getPanel(`session:${SESSION_ID}`)).toMatchObject({ api: { component: "chat" } }); expect(store.state.scrollTarget).toMatchObject({ @@ -85,6 +85,18 @@ describe("scrollTranscriptToMessage", () => { messageId: MESSAGE_ID, hostPanelId: `session:${SESSION_ID}`, }); + expect(queued).toBe(true); + }); + + it("reports unavailable when no dockview host can queue navigation", () => { + const store = makeStore(makeApi()); + store.state.api = null; + const actions = buildExtraPanelActions(store.set, store.get); + + const queued = actions.scrollTranscriptToMessage(SESSION_ID, MESSAGE_ID, "Agent"); + + expect(queued).toBe(false); + expect(store.state.scrollTarget).toBeNull(); }); it("focuses an existing session panel instead of adding a second tab", () => { @@ -125,7 +137,9 @@ describe("scrollTranscriptToMessage", () => { expect((api.getPanel("chat") as unknown as { isActive: boolean }).isActive).toBe(true); expect(store.state.scrollTarget?.hostPanelId).toBe("chat"); }); +}); +describe("scrollTranscriptToMessage target lifecycle", () => { it("activates the target panel before recording the target", () => { const api = makeApi(); api.addPanel({ diff --git a/apps/web/lib/state/dockview-store.ts b/apps/web/lib/state/dockview-store.ts index c34b25c720..a5a2216112 100644 --- a/apps/web/lib/state/dockview-store.ts +++ b/apps/web/lib/state/dockview-store.ts @@ -271,7 +271,7 @@ type DockviewStore = { selectedDiff: { path: string; content?: string } | null; setSelectedDiff: (diff: { path: string; content?: string } | null) => void; scrollTarget: TranscriptScrollTarget | null; - scrollTranscriptToMessage: (sessionId: string, messageId: string, title: string) => void; + scrollTranscriptToMessage: (sessionId: string, messageId: string, title: string) => boolean; clearScrollTarget: (token: number) => void; clearScrollTargetForOwner: (sessionId: string, hostPanelId: string) => void; activeGroupId: string | null; diff --git a/apps/web/lib/state/layout-manager/panel-title.ts b/apps/web/lib/state/layout-manager/panel-title.ts index 0e9a3e6f64..b791c4a99d 100644 --- a/apps/web/lib/state/layout-manager/panel-title.ts +++ b/apps/web/lib/state/layout-manager/panel-title.ts @@ -1,6 +1,7 @@ import { t } from "@/lib/i18n"; import { panelRegistryEntry } from "./constants"; +import { resolvePluginPanelDefinition } from "./plugin-panels"; /** * The display title for a registry panel, in the active locale. @@ -24,6 +25,8 @@ import { panelRegistryEntry } from "./constants"; * `panelFromDockviewPanel` canonicalizes on the way back out. */ export function panelTitle(id: string, fallback?: string, component?: string): string { + const pluginPanel = resolvePluginPanelDefinition(id); + if (pluginPanel) return pluginPanel.title; const config = panelRegistryEntry(id, component); if (!config) return fallback ?? id; return config.titleKey ? t(config.titleKey) : config.title; diff --git a/apps/web/lib/state/layout-manager/plugin-panels.test.ts b/apps/web/lib/state/layout-manager/plugin-panels.test.ts index 59a8131221..e3dfa2136e 100644 --- a/apps/web/lib/state/layout-manager/plugin-panels.test.ts +++ b/apps/web/lib/state/layout-manager/plugin-panels.test.ts @@ -1,4 +1,5 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { i18n as I18n } from "i18next"; import { pluginRegistry } from "@/lib/plugins/registry"; import { isKnownPanelId, @@ -6,6 +7,7 @@ import { parsePluginPanelId, pluginPanelId, resolvePluginPanelDefinition, + resolveTaskPanelTitle, } from "./plugin-panels"; afterEach(() => { @@ -91,3 +93,30 @@ describe("resolvePluginPanelDefinition", () => { expect(resolvePluginPanelDefinition("chat")).toBeUndefined(); }); }); + +describe("resolveTaskPanelTitle", () => { + it("uses the plugin namespace and falls back to the canonical English title", () => { + function Notes() { + return null; + } + pluginRegistry.forPlugin("plugin-a").registerTaskPanel({ + id: "notes", + title: "Notes", + titleKey: "panels.notes", + Component: Notes, + }); + const registration = pluginRegistry.getTaskPanel("plugin-a", "notes"); + if (!registration) throw new Error("panel registration missing"); + const translate = vi.fn(() => "Notas"); + + expect( + resolveTaskPanelTitle(registration, { + t: translate as unknown as I18n["t"], + }), + ).toBe("Notas"); + expect(translate).toHaveBeenCalledWith("panels.notes", { + ns: "plugin-plugin-a", + defaultValue: "Notes", + }); + }); +}); diff --git a/apps/web/lib/state/layout-manager/plugin-panels.ts b/apps/web/lib/state/layout-manager/plugin-panels.ts index 3247c54f7d..58d9fb3c1b 100644 --- a/apps/web/lib/state/layout-manager/plugin-panels.ts +++ b/apps/web/lib/state/layout-manager/plugin-panels.ts @@ -7,6 +7,10 @@ * (rather than one per plugin) and lets a saved layout round-trip even when * the plugin that registered the panel is no longer installed. */ +import type { i18n as I18n } from "i18next"; +import { i18n } from "@/lib/i18n"; +import { pluginTranslationNamespace } from "@/lib/plugins/plugin-translations"; +import type { PluginTaskPanelRegistration } from "@/lib/plugins/registry-registration-types"; import { pluginRegistry } from "@/lib/plugins/registry"; import { KNOWN_PANEL_IDS, STRUCTURAL_COMPONENTS } from "./constants"; import type { LayoutPanel } from "./types"; @@ -60,6 +64,23 @@ export function isStructuralComponent(component: string): boolean { return STRUCTURAL_COMPONENTS.has(component) || component === PLUGIN_PANEL_COMPONENT; } +/** Resolves plugin-owned copy in its generation namespace with a saved-layout-safe fallback. */ +export function resolveTaskPanelTitle( + registration: PluginTaskPanelRegistration, + translator: Pick = i18n, +): string { + if (!registration.titleKey) return registration.title; + return translator.t(registration.titleKey, { + ns: pluginTranslationNamespace(registration.pluginId), + defaultValue: registration.title, + }); +} +export function canonicalPluginPanelTitle(id: string): string | undefined { + const parsed = parsePluginPanelId(id); + if (!parsed) return undefined; + return pluginRegistry.getTaskPanel(parsed.pluginId, parsed.panelKey)?.title; +} + /** * Resolves a `plugin::` id to a full `LayoutPanel` * definition using the plugin's current registration (title/icon), or @@ -74,7 +95,7 @@ export function resolvePluginPanelDefinition(id: string): LayoutPanel | undefine return { id, component: PLUGIN_PANEL_COMPONENT, - title: registration.title, + title: resolveTaskPanelTitle(registration), tabComponent: PLUGIN_PANEL_TAB_COMPONENT, params: { pluginId: parsed.pluginId, panelKey: parsed.panelKey }, }; diff --git a/apps/web/lib/state/layout-manager/serializer.ts b/apps/web/lib/state/layout-manager/serializer.ts index 26c7c36cc6..05250181c7 100644 --- a/apps/web/lib/state/layout-manager/serializer.ts +++ b/apps/web/lib/state/layout-manager/serializer.ts @@ -20,6 +20,7 @@ import { RIGHT_BOTTOM_GROUP, } from "./constants"; import { panelTitle } from "./panel-title"; +import { canonicalPluginPanelTitle } from "./plugin-panels"; /** Canonical English, because `normalizePanel` produces a stored LayoutState. */ // i18n-exempt: canonical English persisted in saved layouts, compared when restoring. @@ -227,7 +228,11 @@ function panelFromDockviewPanel(panel: any): LayoutPanel { component: panel.view?.contentComponent ?? panel.id, // CAPTURE direction: dockview holds the localized title, and this result is // what gets persisted, so a registry panel goes back to canonical English. - title: canonicalPanelTitle(panel.id, panel.view?.contentComponent) ?? panel.title ?? panel.id, + title: + canonicalPluginPanelTitle(panel.id) ?? + canonicalPanelTitle(panel.id, panel.view?.contentComponent) ?? + panel.title ?? + panel.id, ...(panel.view?.tabComponent ? { tabComponent: panel.view.tabComponent } : {}), ...(panel.params ? { params: panel.params as Record } : {}), }; diff --git a/apps/web/lib/state/slices/session/turn-actions.test.ts b/apps/web/lib/state/slices/session/turn-actions.test.ts index a45b608ebd..f00a72249b 100644 --- a/apps/web/lib/state/slices/session/turn-actions.test.ts +++ b/apps/web/lib/state/slices/session/turn-actions.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Turn reconciliation fixtures cover the store contract in one file. */ import { describe, expect, it } from "vitest"; import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; @@ -283,6 +284,30 @@ describe("mergeTurnsSnapshot", () => { expect(store.getState().turns.loadedBySession[SESSION_ID]).toBe(true); expect(store.getState().turns.activeBySession[SESSION_ID]).toBe("turn-new"); }); + + it("replaces rows missing from an authoritative recovery snapshot", () => { + const store = makeStore(); + seedSession(store, "RUNNING", LATER_AT); + store.getState().addTurn(turn("turn-stale", { started_at: STARTED_AT })); + store.getState().addTurn(turn("turn-kept", { started_at: LATER_AT })); + store.getState().setActiveTurn(SESSION_ID, "turn-kept"); + + store.getState().mergeTurnsSnapshot( + SESSION_ID, + [ + turn("turn-kept", { + started_at: LATER_AT, + updated_at: LATER_AT, + completed_at: COMPLETED_AT, + }), + ], + 0, + { replace: true }, + ); + + expect(store.getState().turns.bySession[SESSION_ID].map(({ id }) => id)).toEqual(["turn-kept"]); + expect(store.getState().turns.activeBySession[SESSION_ID]).toBeNull(); + }); }); describe("addTurn malformed timestamps", () => { diff --git a/apps/web/lib/state/slices/session/turn-actions.ts b/apps/web/lib/state/slices/session/turn-actions.ts index 83f6cae0ae..5eefdf0072 100644 --- a/apps/web/lib/state/slices/session/turn-actions.ts +++ b/apps/web/lib/state/slices/session/turn-actions.ts @@ -168,6 +168,19 @@ export function mergeTurnRows(target: Draft, incoming: Turn[]): void { } } +/** Replaces a complete turn snapshot while preserving newer rows by id. */ +export function replaceTurnRows(target: Draft, incoming: Turn[]): void { + const existingById = new Map(target.map((turn) => [turn.id, turn])); + const nextRows = incoming.map((turn) => { + const existing = existingById.get(turn.id); + if (!existing) return turn; + if (!shouldApplyTurnUpdate(existing as Turn, turn)) return existing as Turn; + return { ...turn, completed_at: existing.completed_at ?? turn.completed_at }; + }); + target.length = 0; + target.push(...nextRows); +} + /** Applies active-turn reconciliation to a draft owned by a hydration path. */ export function reconcileActiveTurnAfterHydrationDraft( draft: TurnReconciliationDraft, @@ -216,11 +229,17 @@ export function shouldApplyTurnUpdate(existing: Turn, incoming: Turn): boolean { /** Store action: merges a complete REST turn snapshot and reconciles its marker atomically. */ function mergeTurnsSnapshotAction(set: ImmerSet) { - return (sessionId: string, turns: Turn[], hydrationEpoch: number) => + return ( + sessionId: string, + turns: Turn[], + hydrationEpoch: number, + options?: { replace?: boolean }, + ) => set((draft) => { if (!draft.taskSessions.items[sessionId]) return; const target = (draft.turns.bySession[sessionId] ??= []); - mergeTurnRows(target, turns); + if (options?.replace) replaceTurnRows(target, turns); + else mergeTurnRows(target, turns); applyActiveTurnReconciliation(draft, sessionId, hydrationEpoch); draft.turns.loadedBySession[sessionId] = true; }); diff --git a/apps/web/lib/state/slices/session/types.ts b/apps/web/lib/state/slices/session/types.ts index 15ce6be11a..ce8de217fc 100644 --- a/apps/web/lib/state/slices/session/types.ts +++ b/apps/web/lib/state/slices/session/types.ts @@ -342,7 +342,12 @@ export type SessionSliceActions = { /** Upserts a turn row, rejecting stale updates (see shouldApplyTurnUpdate). */ addTurn: (turn: Turn) => void; /** Merges a complete REST snapshot and reconciles its marker atomically. */ - mergeTurnsSnapshot: (sessionId: string, turns: Turn[], hydrationEpoch: number) => void; + mergeTurnsSnapshot: ( + sessionId: string, + turns: Turn[], + hydrationEpoch: number, + options?: { replace?: boolean }, + ) => void; completeTurn: ( sessionId: string, turnId: string, diff --git a/apps/web/lib/types/session-events.ts b/apps/web/lib/types/session-events.ts index ab807ac864..2a4fef902d 100644 --- a/apps/web/lib/types/session-events.ts +++ b/apps/web/lib/types/session-events.ts @@ -265,6 +265,10 @@ export type SessionBackendMessageMap = { >; "session.turn.started": BackendMessage<"session.turn.started", TurnEventPayload>; "session.turn.completed": BackendMessage<"session.turn.completed", TurnEventPayload>; + "session.turn.removed": BackendMessage< + "session.turn.removed", + { id: string; session_id: string; task_id: string } + >; "session.available_commands": BackendMessage< "session.available_commands", AvailableCommandsPayload diff --git a/apps/web/lib/utils.ts b/apps/web/lib/utils.ts index a2498142df..845192d45e 100644 --- a/apps/web/lib/utils.ts +++ b/apps/web/lib/utils.ts @@ -1,6 +1,7 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; import { t } from "@/lib/i18n"; +export { generateUUID } from "./uuid"; import { parseStrictRfc3339Timestamp } from "@/lib/utils/strict-timestamp"; export function cn(...inputs: ClassValue[]) { @@ -22,58 +23,6 @@ export function formatDollars(subcents: number | null | undefined): string { return `$${(subcents / 10000).toFixed(2)}`; } -let uuidFallbackCounter = 0; -let uuidFallbackContextCounter = 0; - -function createUuidFallbackContext(): string { - const entropy = new Uint32Array(2); - if (typeof crypto !== "undefined" && crypto.getRandomValues) { - try { - crypto.getRandomValues(entropy); - return entropy[0].toString(16).padStart(8, "0"); - } catch { - // Fall through to the non-cryptographic legacy-environment fallback. - } - } - - const monotonic = typeof performance !== "undefined" ? Math.floor(performance.now() * 1000) : 0; - const counter = uuidFallbackContextCounter++; - return ((Date.now() ^ monotonic ^ counter) >>> 0).toString(16).padStart(8, "0"); -} - -// Keep a per-module component so two tabs generating IDs in the same -// millisecond do not share the timestamp/counter prefix. Prefer Web Crypto -// entropy when available; old environments get a monotonic best-effort ID. -const uuidFallbackContext = createUuidFallbackContext(); - -/** - * Generate a UUID. Falls back to crypto.getRandomValues when randomUUID is - * unavailable (e.g., HTTP on non-localhost), then to a UUID-shaped fallback - * with a per-context component for legacy environments without any Web Crypto API. - */ -export function generateUUID(): string { - if (typeof crypto !== "undefined" && crypto.randomUUID) { - return crypto.randomUUID(); - } - - if (typeof crypto !== "undefined" && crypto.getRandomValues) { - const bytes = crypto.getRandomValues(new Uint8Array(16)); - bytes[6] = (bytes[6] & 0x0f) | 0x40; - bytes[8] = (bytes[8] & 0x3f) | 0x80; - const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; - } - - const context = uuidFallbackContext; - const timestamp = Date.now().toString(16).padStart(12, "0").slice(-12); - const counter = (uuidFallbackCounter++ >>> 0).toString(16).padStart(8, "0"); - const chars = `${context}${timestamp}${counter}`.padEnd(32, "0").slice(0, 32).split(""); - chars[12] = "4"; - chars[16] = "8"; - const hex = chars.join(""); - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; -} - /** * Extract organization/repository name from a repository URL. * Supports common Git providers and SSH/HTTPS formats. diff --git a/apps/web/lib/utils/pending-clarification-detached.test.ts b/apps/web/lib/utils/pending-clarification-detached.test.ts new file mode 100644 index 0000000000..fa37662a53 --- /dev/null +++ b/apps/web/lib/utils/pending-clarification-detached.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { sessionId, taskId, type Message } from "@/lib/types/http"; +import { findPendingClarification, findPendingClarificationGroup } from "./pending-clarification"; + +const TURN_ID = "turn-detached"; +const PENDING_ID = "pending-detached"; + +function message(overrides: Partial = {}): Message { + return { + id: "message-detached", + session_id: sessionId("session-detached"), + task_id: taskId("task-detached"), + turn_id: TURN_ID, + author_type: "agent", + type: "clarification_request", + content: "Which database should we use?", + created_at: "2026-09-12T00:00:00Z", + updated_at: "2026-09-12T00:00:00Z", + metadata: { + pending_id: PENDING_ID, + status: "pending", + agent_disconnected: true, + }, + ...overrides, + }; +} + +describe("detached clarification ownership", () => { + it("keeps an explicitly detached request visible after pending action clears", () => { + const detached = message(); + const scope = { currentTurnId: TURN_ID, pendingAction: null } as const; + + expect(findPendingClarification([detached], scope)).toBe(detached); + expect(findPendingClarificationGroup([detached], scope)).toEqual([detached]); + }); +}); diff --git a/apps/web/lib/utils/pending-clarification-scope.test.ts b/apps/web/lib/utils/pending-clarification-scope.test.ts new file mode 100644 index 0000000000..53800f2439 --- /dev/null +++ b/apps/web/lib/utils/pending-clarification-scope.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { sessionId as toSessionId, taskId as toTaskId, type Message } from "@/lib/types/http"; +import { findPendingClarification, findPendingClarificationGroup } from "./pending-clarification"; + +const CURRENT_TURN_ID = "turn-new"; + +function message(overrides: Partial): Message { + return { + id: "msg-1", + session_id: toSessionId("session-1"), + task_id: toTaskId("task-1"), + author_type: "agent", + content: "", + type: "message", + created_at: "2026-05-02T00:00:00Z", + ...overrides, + }; +} + +describe("completed-turn clarification ownership", () => { + it("does not use a detached clarification while the current action is scoped", () => { + const detached = message({ + id: "detached", + turn_id: "turn-old", + type: "clarification_request", + metadata: { status: "pending", agent_disconnected: true }, + }); + const newer = message({ id: "newer", turn_id: CURRENT_TURN_ID, type: "message" }); + const scope = { currentTurnId: CURRENT_TURN_ID, pendingAction: "clarification" as const }; + + expect(findPendingClarification([detached, newer], scope)).toBeNull(); + expect(findPendingClarificationGroup([detached, newer], scope)).toEqual([]); + }); + + it("supersedes a detached clarification after the newer turn completes", () => { + const detached = message({ + id: "detached", + turn_id: "turn-old", + type: "clarification_request", + metadata: { status: "pending", agent_disconnected: true }, + }); + const newer = message({ id: "newer", turn_id: CURRENT_TURN_ID, type: "message" }); + const scope = { + currentTurnId: CURRENT_TURN_ID, + currentTurnCompleted: true, + pendingAction: null, + } as const; + + expect(findPendingClarification([detached, newer], scope)).toBeNull(); + expect(findPendingClarificationGroup([detached, newer], scope)).toEqual([]); + }); +}); diff --git a/apps/web/lib/utils/pending-clarification.test.ts b/apps/web/lib/utils/pending-clarification.test.ts index e0d517fd9e..c075f7bdef 100644 --- a/apps/web/lib/utils/pending-clarification.test.ts +++ b/apps/web/lib/utils/pending-clarification.test.ts @@ -231,6 +231,7 @@ describe("findPendingClarificationGroup", () => { }); }); +// eslint-disable-next-line max-lines-per-function describe("current-turn clarification ownership", () => { it("ignores an older pending request when a newer durable turn exists", () => { const messages = [ @@ -245,6 +246,22 @@ describe("current-turn clarification ownership", () => { expect(findPendingClarification(messages, { currentTurnId: CURRENT_TURN_ID })).toBeNull(); expect(findPendingClarificationGroup(messages, { currentTurnId: CURRENT_TURN_ID })).toEqual([]); }); + it("keeps a detached clarification answerable after a newer turn starts", () => { + const detached = message({ + id: "detached", + turn_id: "turn-old", + type: "clarification_request", + metadata: { pending_id: OLD_PENDING_ID, status: "pending", agent_disconnected: true }, + }); + const newer = message({ id: "newer", turn_id: CURRENT_TURN_ID, type: "message" }); + + expect( + findPendingClarificationGroup([detached, newer], { + currentTurnId: CURRENT_TURN_ID, + pendingAction: null, + }), + ).toEqual([detached]); + }); it.each([null, "permission"] as const)( "treats a matching visible turn as inert when explicit authority is %s", @@ -262,18 +279,6 @@ describe("current-turn clarification ownership", () => { }, ); - it("does not reactivate history when every newer-turn message is deleted", () => { - const messages = [ - message({ - id: "old", - turn_id: "turn-old", - type: "clarification_request", - metadata: { pending_id: OLD_PENDING_ID, status: "pending" }, - }), - ]; - expect(findPendingClarification(messages, { currentTurnId: CURRENT_TURN_ID })).toBeNull(); - }); - it("returns only the current turn's exact pending bundle", () => { const messages = [ message({ @@ -636,8 +641,6 @@ describe("hasPendingPermissionRequest — turn scoping", () => { ).toBe(false); }); - // A legacy permission_request with no turn_id, sitting in a session whose - // latest message *does* have a turn_id, must not bypass the boundary. it("ignores a legacy null-turn_id pending permission when a turn is active", () => { expect( hasPendingPermissionRequest([ diff --git a/apps/web/lib/utils/pending-clarification.ts b/apps/web/lib/utils/pending-clarification.ts index 066992c9c2..6d284064c1 100644 --- a/apps/web/lib/utils/pending-clarification.ts +++ b/apps/web/lib/utils/pending-clarification.ts @@ -8,14 +8,31 @@ import { isInputCapableSessionState } from "./task-pending-input"; export type PendingClarificationScope = { /** - * Undefined means turn history is not loaded, so pendingAction gates the fallback. - * Null means history is loaded but has no durable turns, so all messages are hidden. - * A string scopes detection to that exact turn. An empty object disables detection. + * Undefined means turn history is not loaded, so pendingAction gates the + * fallback. Null means history is loaded but has no durable turns, so all + * messages are hidden except a pending request explicitly detached after + * agent disconnection. A string scopes detection to that exact turn. An + * empty object disables detection. */ currentTurnId?: string | null; + /** True when currentTurnId identifies a completed turn. */ + currentTurnCompleted?: boolean; + /** + * Allows detached clarification requests to remain answerable after their + * turn is no longer the newest durable turn. + */ pendingAction?: TaskPendingAction | null; + allowDetached?: boolean; }; +function hasDetachedPendingClarification(messages: readonly Message[]): boolean { + return messages.some((message) => { + if (!isPendingClarificationMessage(message)) return false; + const metadata = message.metadata as ClarificationRequestMetadata | undefined; + return metadata?.agent_disconnected === true; + }); +} + export function isPendingClarificationMessage(message: Message): boolean { if (message.type !== "clarification_request") return false; const metadata = message.metadata as ClarificationRequestMetadata | undefined; @@ -99,8 +116,19 @@ function clarificationMessagesInScope( scope?: PendingClarificationScope, ): readonly Message[] { if (!scope) return messages; - if (scope.pendingAction !== undefined && scope.pendingAction !== "clarification") return []; - if (scope.currentTurnId === null) return []; + const allowDetached = + (scope.allowDetached === true || scope.pendingAction === null) && + scope.currentTurnCompleted !== true && + hasDetachedPendingClarification(messages); + if ( + scope.pendingAction !== undefined && + scope.pendingAction !== "clarification" && + !allowDetached + ) { + return []; + } + if (scope.currentTurnId === null && !allowDetached) return []; + if (allowDetached) return messages; if (scope.currentTurnId === undefined) { return scope.pendingAction === "clarification" ? messages : []; } @@ -131,11 +159,13 @@ export function findPendingClarification( if (!messages?.length) return null; const scoped = clarificationMessagesInScope(messages, scope); // Sidebar callers do not have durable turn history. Use persisted message - // order instead of WebSocket arrival order so a delayed predecessor event - // cannot hide the current request or re-arm an older one. const latestTurnId = newestMessageTurnId(scoped); - for (let i = scoped.length - 1; i >= 0; i--) { - if (latestTurnId && scoped[i].turn_id !== latestTurnId) continue; + for (let i = scoped.length - 1; i >= 0; i -= 1) { + const metadata = scoped[i].metadata as ClarificationRequestMetadata | undefined; + const isDetached = metadata?.agent_disconnected === true; + const detachedAllowed = + (scope?.allowDetached === true || scope?.pendingAction === null) && isDetached; + if (latestTurnId && scoped[i].turn_id !== latestTurnId && !detachedAllowed) continue; if (scoped[i].type !== "clarification_request") continue; if (isPendingClarificationMessage(scoped[i])) return scoped[i]; } @@ -158,7 +188,7 @@ export function findPendingClarificationGroup( ): Message[] { if (!messages) return []; const scoped = clarificationMessagesInScope(messages, scope); - const last = findPendingClarification(scoped); + const last = findPendingClarification(scoped, scope); if (!last) return []; const meta = last.metadata as ClarificationRequestMetadata | undefined; const pendingID = meta?.pending_id; diff --git a/apps/web/lib/uuid.ts b/apps/web/lib/uuid.ts new file mode 100644 index 0000000000..ba82d2d1f7 --- /dev/null +++ b/apps/web/lib/uuid.ts @@ -0,0 +1,51 @@ +let uuidFallbackCounter = 0; +let uuidFallbackContextCounter = 0; + +function createUuidFallbackContext(): string { + const entropy = new Uint32Array(2); + if (typeof crypto !== "undefined" && crypto.getRandomValues) { + try { + crypto.getRandomValues(entropy); + return entropy[0].toString(16).padStart(8, "0"); + } catch { + // Fall through to the non-cryptographic legacy-environment fallback. + } + } + + const monotonic = typeof performance !== "undefined" ? Math.floor(performance.now() * 1000) : 0; + const counter = uuidFallbackContextCounter++; + return ((Date.now() ^ monotonic ^ counter) >>> 0).toString(16).padStart(8, "0"); +} + +// Keep a per-module component so two tabs generating IDs in the same +// millisecond do not share the timestamp/counter prefix. Prefer Web Crypto +// entropy when available; old environments get a monotonic best-effort ID. +const uuidFallbackContext = createUuidFallbackContext(); + +/** + * Generate a UUID. Falls back to crypto.getRandomValues when randomUUID is + * unavailable (e.g., HTTP on non-localhost), then to a UUID-shaped fallback + * with a per-context component for legacy environments without any Web Crypto API. + */ +export function generateUUID(): string { + if (typeof crypto !== "undefined" && crypto.randomUUID) { + return crypto.randomUUID(); + } + + if (typeof crypto !== "undefined" && crypto.getRandomValues) { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; + } + + const context = uuidFallbackContext; + const timestamp = Date.now().toString(16).padStart(12, "0").slice(-12); + const counter = (uuidFallbackCounter++ >>> 0).toString(16).padStart(8, "0"); + const chars = `${context}${timestamp}${counter}`.padEnd(32, "0").slice(0, 32).split(""); + chars[12] = "4"; + chars[16] = "8"; + const hex = chars.join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} diff --git a/apps/web/lib/ws/client.test.ts b/apps/web/lib/ws/client.test.ts index f70b8ab708..d37be6f3b6 100644 --- a/apps/web/lib/ws/client.test.ts +++ b/apps/web/lib/ws/client.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines, max-lines-per-function, sonarjs/no-duplicate-string -- Ordered transport fixtures keep the full recovery handshake together. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { WebSocketClient, WebSocketRequestError, WebSocketRequestTimeoutError } from "./client"; @@ -9,9 +10,6 @@ type SentRequest = { payload: unknown; }; -const SESSION_ID = "sess-1"; -const SESSION_SUBSCRIBE_ACTION = "session.subscribe"; - class FakeWebSocket { static readonly OPEN = 1; static readonly CLOSED = 3; @@ -69,19 +67,59 @@ function connectClient(options?: ConstructorParameters[2 } function sessionSubscribeRequest(socket: FakeWebSocket, index = 0) { - const request = socket.sent.filter((message) => message.action === SESSION_SUBSCRIBE_ACTION)[ - index - ]; + const request = socket.sent.filter((message) => message.action === "session.subscribe")[index]; if (!request) throw new Error("No session.subscribe request was sent"); return request; } +function coreRequestPayload(request: SentRequest): Record | null { + if (typeof request.payload !== "object" || request.payload === null) return null; + const payload = request.payload as Record; + return payload.consumer_kind === "core" ? payload : null; +} + function acknowledge(socket: FakeWebSocket, request: SentRequest) { - socket.receive({ - id: request.id, - type: "response", - payload: { success: true }, - }); + const payload: Record = { success: true }; + const requestPayload = coreRequestPayload(request); + if (requestPayload) { + payload.session_id = requestPayload.session_id; + payload.wire_id = requestPayload.wire_id; + if (request.action === "session.ack") { + payload.acknowledged_sequence = requestPayload.sequence; + payload.resume_token = "resume-core"; + } else { + payload.result = "fresh"; + payload.event_watermark = 0; + payload.snapshot_cutoff = 0; + payload.snapshot_token = "snapshot-core"; + payload.resume_token = "resume-core"; + payload.expires_at = "2026-09-07T12:00:00Z"; + } + } + socket.receive({ id: request.id, type: "response", payload }); +} +function acknowledgeSessionRegistration(socket: FakeWebSocket, startIndex = 0) { + acknowledge(socket, sessionSubscribeRequest(socket, startIndex)); + acknowledge(socket, sessionSubscribeRequest(socket, startIndex + 1)); +} + +function acknowledgeWithResumeToken(socket: FakeWebSocket, request: SentRequest, token: string) { + const payload: Record = { success: true, resume_token: token }; + const requestPayload = coreRequestPayload(request); + if (requestPayload) { + payload.session_id = requestPayload.session_id; + payload.wire_id = requestPayload.wire_id; + if (request.action === "session.ack") { + payload.acknowledged_sequence = requestPayload.sequence; + } else { + payload.result = "fresh"; + payload.event_watermark = 0; + payload.snapshot_cutoff = 0; + payload.snapshot_token = "snapshot-core"; + payload.expires_at = "2026-09-07T12:00:00Z"; + } + } + socket.receive({ id: request.id, type: "response", payload }); } beforeEach(() => { @@ -94,15 +132,15 @@ afterEach(() => { vi.useRealTimers(); }); -// eslint-disable-next-line max-lines-per-function -- the readiness suite covers the shared lifecycle contract. +// eslint-disable-next-line max-lines-per-function -- readiness tests cover registration and retry lifecycle. describe("session subscription readiness", () => { it("accepts a registration acknowledgement that arrives after seven seconds", async () => { vi.useFakeTimers(); const { client, socket } = connectClient(); - const subscription = client.subscribeSessionWithReady(SESSION_ID); + const subscription = client.subscribeSessionWithReady("sess-1"); await vi.advanceTimersByTimeAsync(7000); - acknowledge(socket, sessionSubscribeRequest(socket)); + acknowledgeSessionRegistration(socket); await expect(subscription.ready).resolves.toBeUndefined(); subscription.unsubscribe(); @@ -111,16 +149,10 @@ describe("session subscription readiness", () => { it("recovers timed out registration without a visibility change", async () => { vi.useFakeTimers(); const { client, socket } = connectClient(); - const subscription = client.subscribeSessionWithReady(SESSION_ID); + const subscription = client.subscribeSessionWithReady("sess-1"); - await vi.advanceTimersByTimeAsync(10000); - expect( - socket.sent.filter((message) => message.action === SESSION_SUBSCRIBE_ACTION), - ).toHaveLength(1); - - await vi.advanceTimersByTimeAsync(1000); - const retryRequest = sessionSubscribeRequest(socket, 1); - acknowledge(socket, retryRequest); + await vi.advanceTimersByTimeAsync(11000); + acknowledgeSessionRegistration(socket, 2); await expect(subscription.ready).resolves.toBeUndefined(); subscription.unsubscribe(); @@ -129,35 +161,30 @@ describe("session subscription readiness", () => { it("stops after the bounded registration retry budget", async () => { vi.useFakeTimers(); const { client, socket } = connectClient(); - const subscription = client.subscribeSessionWithReady(SESSION_ID); + const subscription = client.subscribeSessionWithReady("sess-1"); await vi.advanceTimersByTimeAsync(21000); await expect(subscription.ready).rejects.toBeInstanceOf(WebSocketRequestTimeoutError); - expect( - socket.sent.filter((message) => message.action === SESSION_SUBSCRIBE_ACTION), - ).toHaveLength(2); + expect(socket.sent.filter((message) => message.action === "session.subscribe")).toHaveLength(4); subscription.unsubscribe(); }); it("cancels a scheduled registration retry when the last consumer unsubscribes", async () => { vi.useFakeTimers(); const { client, socket } = connectClient(); - const subscription = client.subscribeSessionWithReady(SESSION_ID); + const subscription = client.subscribeSessionWithReady("sess-1"); await vi.advanceTimersByTimeAsync(10000); subscription.unsubscribe(); await expect(subscription.ready).rejects.toThrow("Session subscription released"); await vi.advanceTimersByTimeAsync(1000); - expect( - socket.sent.filter((message) => message.action === SESSION_SUBSCRIBE_ACTION), - ).toHaveLength(1); + expect(socket.sent.filter((message) => message.action === "session.subscribe")).toHaveLength(2); }); - it("resolves only after the server acknowledges the registration", async () => { const { client, socket } = connectClient(); - const subscription = client.subscribeSessionWithReady(SESSION_ID); + const subscription = client.subscribeSessionWithReady("sess-1"); const request = sessionSubscribeRequest(socket); let ready = false; @@ -168,7 +195,9 @@ describe("session subscription readiness", () => { expect(ready).toBe(false); acknowledge(socket, request); - + await Promise.resolve(); + expect(ready).toBe(false); + acknowledge(socket, sessionSubscribeRequest(socket, 1)); await expect(subscription.ready).resolves.toBeUndefined(); expect(ready).toBe(true); subscription.unsubscribe(); @@ -176,15 +205,15 @@ describe("session subscription readiness", () => { it("shares one in-flight acknowledgement between ref-counted consumers", async () => { const { client, socket } = connectClient(); - const first = client.subscribeSessionWithReady(SESSION_ID); - const second = client.subscribeSessionWithReady(SESSION_ID); + const first = client.subscribeSessionWithReady("sess-1"); + const second = client.subscribeSessionWithReady("sess-1"); expect(second.ready).toBe(first.ready); - expect( - socket.sent.filter((message) => message.action === SESSION_SUBSCRIBE_ACTION), - ).toHaveLength(1); + expect(socket.sent.filter((message) => message.action === "session.subscribe")).toHaveLength(2); acknowledge(socket, sessionSubscribeRequest(socket)); + await Promise.resolve(); + acknowledge(socket, sessionSubscribeRequest(socket, 1)); await expect(second.ready).resolves.toBeUndefined(); first.unsubscribe(); @@ -193,21 +222,26 @@ describe("session subscription readiness", () => { it("allows a failed registration to be retried with fresh readiness", async () => { const { client, socket } = connectClient(); - const subscription = client.subscribeSessionWithReady(SESSION_ID); + const subscription = client.subscribeSessionWithReady("sess-1"); + const firstRequest = sessionSubscribeRequest(socket); + const orderedFirstRequest = sessionSubscribeRequest(socket, 1); socket.receive({ id: firstRequest.id, type: "error", payload: { message: "session is not ready" }, }); + acknowledge(socket, orderedFirstRequest); await expect(subscription.ready).rejects.toThrow("session is not ready"); - const retry = client.resubscribeSession(SESSION_ID); - const retryRequest = sessionSubscribeRequest(socket, 1); + const retry = client.resubscribeSession("sess-1"); + const retryRequest = sessionSubscribeRequest(socket, 2); + const orderedRetryRequest = sessionSubscribeRequest(socket, 3); expect(retry).not.toBe(subscription.ready); acknowledge(socket, retryRequest); + acknowledge(socket, orderedRetryRequest); await expect(retry).resolves.toBeUndefined(); subscription.unsubscribe(); }); @@ -215,8 +249,10 @@ describe("session subscription readiness", () => { it("tracks the re-registration after reconnect", async () => { vi.useFakeTimers(); const { client, socket } = connectClient({ enabled: true, initialDelay: 0, maxAttempts: 1 }); - const initial = client.subscribeSessionWithReady(SESSION_ID); + const initial = client.subscribeSessionWithReady("sess-1"); acknowledge(socket, sessionSubscribeRequest(socket)); + await Promise.resolve(); + acknowledge(socket, sessionSubscribeRequest(socket, 1)); await expect(initial.ready).resolves.toBeUndefined(); socket.close(); @@ -224,18 +260,231 @@ describe("session subscription readiness", () => { const reconnectedSocket = FakeWebSocket.latest(); reconnectedSocket.open(); - const reconnected = client.subscribeSessionWithReady(SESSION_ID); + const reconnected = client.subscribeSessionWithReady("sess-1"); const reconnectRequest = sessionSubscribeRequest(reconnectedSocket, 0); - expect(reconnectRequest.payload).toEqual({ session_id: SESSION_ID }); + expect(reconnectRequest.payload).toEqual({ session_id: "sess-1" }); expect(reconnected.ready).not.toBe(initial.ready); acknowledge(reconnectedSocket, reconnectRequest); + await Promise.resolve(); + acknowledge(reconnectedSocket, sessionSubscribeRequest(reconnectedSocket, 1)); await expect(reconnected.ready).resolves.toBeUndefined(); initial.unsubscribe(); reconnected.unsubscribe(); }); }); +describe("ordered core session compatibility", () => { + it("registers a distinct core wire and dispatches each sequence before acknowledging it", async () => { + const { client, socket } = connectClient(); + const handler = vi.fn(); + client.on("session.message.added", handler); + const subscription = client.subscribeSessionWithReady("sess-1"); + acknowledge(socket, sessionSubscribeRequest(socket)); + await Promise.resolve(); + const ordered = sessionSubscribeRequest(socket, 1); + expect(ordered.payload).toMatchObject({ + session_id: "sess-1", + consumer_kind: "core", + }); + expect(ordered.payload).not.toHaveProperty("last_seen_sequence"); + expect((ordered.payload as { wire_id: string }).wire_id).toMatch(/^core:web:/); + acknowledgeWithResumeToken(socket, ordered, "resume-core"); + await subscription.ready; + + socket.receive({ + type: "session.event", + protocol_version: 1, + event_type: "message.added", + session_id: "sess-1", + task_id: "task-1", + sequence: 1, + event_id: "event-1", + payload: { + type: "message.added", + session_id: "sess-1", + message_id: "message-1", + task_id: "task-1", + author_type: "user", + content: "hello", + created_at: "2026-09-07T12:00:00Z", + }, + }); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0]?.[0].payload).toMatchObject({ + message_id: "message-1", + type: "message", + }); + const ack = socket.sent.at(-1); + expect(ack).toMatchObject({ + action: "session.ack", + payload: { + session_id: "sess-1", + consumer_kind: "core", + sequence: 1, + resume_token: "resume-core", + }, + }); + if (!ack) throw new Error("No session.ack request was sent"); + acknowledge(socket, ack); + subscription.unsubscribe(); + }); +}); +it("buffers ordered events until the core subscription watermark is known", async () => { + const { client, socket } = connectClient(); + const handler = vi.fn(); + client.on("session.message.added", handler); + const subscription = client.subscribeSessionWithReady("sess-1"); + const legacy = sessionSubscribeRequest(socket); + const ordered = sessionSubscribeRequest(socket, 1); + acknowledge(socket, legacy); + + socket.receive({ + type: "session.event", + protocol_version: 1, + event_type: "message.added", + session_id: "sess-1", + task_id: "task-1", + sequence: 4, + event_id: "event-4", + payload: { + type: "message.added", + session_id: "sess-1", + message_id: "message-4", + task_id: "task-1", + author_type: "user", + content: "hello", + created_at: "2026-09-07T12:00:00Z", + }, + }); + + expect(handler).not.toHaveBeenCalled(); + expect(socket.sent.some((message) => message.action === "session.ack")).toBe(false); + + const requestPayload = coreRequestPayload(ordered); + socket.receive({ + id: ordered.id, + type: "response", + payload: { + success: true, + session_id: "sess-1", + wire_id: requestPayload?.wire_id, + result: "fresh", + event_watermark: 3, + snapshot_cutoff: 3, + snapshot_token: "snapshot-core", + resume_token: "resume-core", + expires_at: "2026-09-07T12:00:00Z", + }, + }); + + await subscription.ready; + expect(handler).toHaveBeenCalledTimes(1); + expect(socket.sent.at(-1)).toMatchObject({ + action: "session.ack", + payload: { session_id: "sess-1", consumer_kind: "core", sequence: 4 }, + }); + subscription.unsubscribe(); +}); + +describe("ordered core session validation", () => { + it("does not project or acknowledge a mismatched payload type", async () => { + const { client, socket } = connectClient(); + const handler = vi.fn(); + client.on("session.message.added", handler); + const subscription = client.subscribeSessionWithReady("sess-1"); + acknowledge(socket, sessionSubscribeRequest(socket)); + await Promise.resolve(); + acknowledge(socket, sessionSubscribeRequest(socket, 1)); + await subscription.ready; + const sentBefore = socket.sent.length; + + socket.receive({ + type: "session.event", + protocol_version: 1, + event_type: "message.added", + session_id: "sess-1", + task_id: "task-1", + sequence: 1, + event_id: "event-poison", + payload: { type: "message.updated", message_id: "message-1" }, + }); + + expect(handler).not.toHaveBeenCalled(); + expect(socket.sent).toHaveLength(sentBefore + 1); + subscription.unsubscribe(); + }); + + it("delivers valid poison and turn removal envelopes to raw session consumers", async () => { + const { client, socket } = connectClient(); + const rawHandler = vi.fn(); + client.onRawSessionEvent(rawHandler); + const subscription = client.subscribeSessionWithReady("sess-1"); + acknowledge(socket, sessionSubscribeRequest(socket)); + await Promise.resolve(); + acknowledge(socket, sessionSubscribeRequest(socket, 1)); + await subscription.ready; + + const poison = { + type: "session.event", + protocol_version: 1, + event_type: "message.added", + session_id: "sess-1", + task_id: "task-1", + sequence: 1, + event_id: "event-poison", + payload: { type: "message.updated", message_id: "message-1" }, + }; + const turnRemoval = { + type: "session.event", + protocol_version: 1, + event_type: "session.turn.removed", + session_id: "sess-1", + task_id: "task-1", + sequence: 2, + event_id: "event-turn-removed", + payload: { type: "session.turn.removed", id: "turn-1" }, + }; + + socket.receive(poison); + socket.receive(turnRemoval); + + expect(rawHandler.mock.calls.map(([event]) => event)).toEqual([poison, turnRemoval]); + subscription.unsubscribe(); + }); + + it("acknowledges a registry-approved ignorable event without projection", async () => { + const { client, socket } = connectClient(); + const subscription = client.subscribeSessionWithReady("sess-1"); + acknowledge(socket, sessionSubscribeRequest(socket)); + await Promise.resolve(); + acknowledge(socket, sessionSubscribeRequest(socket, 1)); + await subscription.ready; + + socket.receive({ + type: "session.event", + protocol_version: 1, + event_type: "session.workspace_sources.updated", + session_id: "sess-1", + task_id: "task-1", + sequence: 1, + event_id: "event-ignored", + payload: { + type: "session.workspace_sources.updated", + session_id: "sess-1", + task_id: "task-1", + }, + }); + + expect(socket.sent.at(-1)).toMatchObject({ + action: "session.ack", + payload: { session_id: "sess-1", consumer_kind: "core", sequence: 1 }, + }); + subscription.unsubscribe(); + }); +}); + describe("connection generations", () => { it("ignores notifications delivered by a replaced socket", () => { vi.useFakeTimers(); @@ -322,12 +571,14 @@ describe("session subscription reconnect recovery", () => { it("keeps queued hydration behind the reconnect subscription acknowledgement", async () => { vi.useFakeTimers(); const { client, socket } = connectClient({ enabled: true, initialDelay: 0, maxAttempts: 1 }); - const initial = client.subscribeSessionWithReady(SESSION_ID); + const initial = client.subscribeSessionWithReady("sess-1"); acknowledge(socket, sessionSubscribeRequest(socket)); + await Promise.resolve(); + acknowledge(socket, sessionSubscribeRequest(socket, 1)); await expect(initial.ready).resolves.toBeUndefined(); socket.close(); - const reconnectReadiness = client.getSessionSubscriptionReadiness(SESSION_ID); + const reconnectReadiness = client.getSessionSubscriptionReadiness("sess-1"); let readinessResolved = false; void reconnectReadiness.then(() => { readinessResolved = true; @@ -336,7 +587,7 @@ describe("session subscription reconnect recovery", () => { id: "hydration-1", type: "request", action: "message.list", - payload: { session_id: SESSION_ID }, + payload: { session_id: "sess-1" }, }); await Promise.resolve(); expect(readinessResolved).toBe(false); @@ -346,7 +597,8 @@ describe("session subscription reconnect recovery", () => { reconnectedSocket.open(); expect(reconnectedSocket.sent.map((message) => message.action)).toEqual([ - SESSION_SUBSCRIBE_ACTION, + "session.subscribe", + "session.subscribe", "message.list", ]); const reconnectRequest = sessionSubscribeRequest(reconnectedSocket); @@ -356,6 +608,8 @@ describe("session subscription reconnect recovery", () => { if (!hydrationRequest) throw new Error("No queued message.list request was sent"); acknowledge(reconnectedSocket, reconnectRequest); + await Promise.resolve(); + acknowledge(reconnectedSocket, sessionSubscribeRequest(reconnectedSocket, 1)); await expect(reconnectReadiness).resolves.toBeUndefined(); expect(hydrationRequest.id).toBe("hydration-1"); initial.unsubscribe(); @@ -363,10 +617,287 @@ describe("session subscription reconnect recovery", () => { it("rejects an active readiness when reconnect recovery is disabled", async () => { const { client, socket } = connectClient({ enabled: false }); - const subscription = client.subscribeSessionWithReady(SESSION_ID); + const subscription = client.subscribeSessionWithReady("sess-1"); socket.close(); await expect(subscription.ready).rejects.toThrow("WebSocket connection closed"); subscription.unsubscribe(); }); }); + +describe("ordered core session validation", () => { + it("runs poison recovery for a session.event-shaped frame that fails the strict envelope check", async () => { + const { client, socket } = connectClient(); + const handler = vi.fn(); + const subscription = client.subscribeSessionWithReady("sess-1"); + acknowledge(socket, sessionSubscribeRequest(socket)); + await Promise.resolve(); + const ordered = sessionSubscribeRequest(socket, 1); + acknowledgeWithResumeToken(socket, ordered, "resume-core"); + await subscription.ready; + const sentBefore = socket.sent.length; + + // protocol_version 2 (unsupported) with an otherwise contiguous shape. + socket.receive({ + type: "session.event", + protocol_version: 2, + event_type: "message.added", + session_id: "sess-1", + task_id: "task-1", + sequence: 1, + event_id: "event-1", + payload: { type: "message.added", message_id: "message-2" }, + }); + + expect(handler).not.toHaveBeenCalled(); + // One recovery re-subscribe with replace_cursor, no ACK of the malformed + // frame. + const recovery = socket.sent + .slice(sentBefore) + .filter((frame) => frame.action === "session.subscribe"); + expect(recovery).toHaveLength(1); + expect(recovery[0]?.payload).toMatchObject({ replace_cursor: true, session_id: "sess-1" }); + expect(socket.sent.slice(sentBefore).some((frame) => frame.action === "session.ack")).toBe( + false, + ); + subscription.unsubscribe(); + }); + + it("keeps core projection paused until recovery hydration completes", async () => { + const { client, socket } = connectClient(); + const handler = vi.fn(); + client.on("session.message.added", handler); + const subscription = client.subscribeSessionWithReady("sess-1"); + acknowledge(socket, sessionSubscribeRequest(socket)); + await Promise.resolve(); + acknowledgeWithResumeToken(socket, sessionSubscribeRequest(socket, 1), "resume-core"); + await subscription.ready; + + let resolveRecovery!: (repaired: boolean) => void; + let recoveryCompleted = false; + const recovery = new Promise((resolve) => { + resolveRecovery = resolve; + }); + const registerRecovery = ( + client as unknown as { + registerCoreSessionRecovery: ( + sessionId: string, + handler: () => Promise, + ) => () => void; + } + ).registerCoreSessionRecovery; + const unregisterRecovery = registerRecovery.call(client, "sess-1", () => + recovery.then((repaired) => { + recoveryCompleted = true; + return repaired; + }), + ); + + socket.receive({ + type: "session.event", + protocol_version: 2, + event_type: "message.added", + session_id: "sess-1", + task_id: "task-1", + sequence: 1, + event_id: "poison-1", + payload: { type: "message.added", session_id: "sess-1", message_id: "poison" }, + }); + const recoveryRequest = sessionSubscribeRequest(socket, 2); + socket.receive({ + id: recoveryRequest.id, + type: "response", + payload: { + success: true, + session_id: "sess-1", + wire_id: coreRequestPayload(recoveryRequest)?.wire_id, + result: "invalid_resume", + event_watermark: 3, + snapshot_cutoff: 3, + snapshot_token: "snapshot-recovery", + resume_token: "resume-recovery", + expires_at: "2099-01-01T00:00:00Z", + }, + }); + + socket.receive({ + type: "session.event", + protocol_version: 1, + event_type: "message.added", + session_id: "sess-1", + task_id: "task-1", + sequence: 3, + event_id: "event-3", + payload: { + type: "message.added", + session_id: "sess-1", + task_id: "task-1", + message_id: "message-3", + author_type: "user", + content: "after snapshot cutoff", + created_at: "2026-09-07T12:00:00Z", + }, + }); + expect(handler).not.toHaveBeenCalled(); + + resolveRecovery(true); + await vi.waitFor(() => expect(recoveryCompleted).toBe(true)); + + socket.receive({ + type: "session.event", + protocol_version: 1, + event_type: "message.added", + session_id: "sess-1", + task_id: "task-1", + sequence: 4, + event_id: "event-4", + payload: { + type: "message.added", + session_id: "sess-1", + task_id: "task-1", + message_id: "message-4", + author_type: "user", + content: "after recovery", + created_at: "2026-09-07T12:01:00Z", + }, + }); + expect(handler).toHaveBeenCalledTimes(1); + unregisterRecovery(); + subscription.unsubscribe(); + }); + + it("keeps failed core recovery paused until an explicit retry succeeds", async () => { + const { client, socket } = connectClient(); + const projected = vi.fn(); + client.on("session.message.added", projected); + const recovery = vi + .fn<() => Promise>() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + const unregister = client.registerCoreSessionRecovery("sess-1", recovery); + const subscription = client.subscribeSessionWithReady("sess-1"); + acknowledge(socket, sessionSubscribeRequest(socket)); + await Promise.resolve(); + acknowledgeWithResumeToken(socket, sessionSubscribeRequest(socket, 1), "resume-core"); + await subscription.ready; + + socket.receive({ + type: "session.event", + protocol_version: 2, + event_type: "message.added", + session_id: "sess-1", + task_id: "task-1", + sequence: 1, + event_id: "poison-1", + payload: { type: "message.added", session_id: "sess-1", message_id: "poison" }, + }); + const recoveryRequest = sessionSubscribeRequest(socket, 2); + socket.receive({ + id: recoveryRequest.id, + type: "response", + payload: { + success: true, + session_id: "sess-1", + wire_id: coreRequestPayload(recoveryRequest)?.wire_id, + result: "invalid_resume", + event_watermark: 1, + snapshot_cutoff: 1, + snapshot_token: "snapshot-recovery", + resume_token: "resume-recovery", + expires_at: "2099-01-01T00:00:00Z", + }, + }); + await vi.waitFor(() => expect(recovery).toHaveBeenCalledTimes(1)); + + socket.receive({ + type: "session.event", + protocol_version: 1, + event_type: "message.added", + session_id: "sess-1", + task_id: "task-1", + sequence: 2, + event_id: "event-2", + payload: { + type: "message.added", + session_id: "sess-1", + task_id: "task-1", + message_id: "message-2", + author_type: "user", + content: "held until repair", + created_at: "2026-09-07T12:00:00Z", + }, + }); + expect(projected).not.toHaveBeenCalled(); + + const retry = client.retryCoreSessionRecovery("sess-1"); + expect(retry).toBeDefined(); + await expect(retry).resolves.toBe(true); + expect(projected).toHaveBeenCalledTimes(1); + unregister(); + subscription.unsubscribe(); + }); + + it("keeps a recovery generation dirty until a later core owner hydrates it", async () => { + const { client, socket } = connectClient(); + const projected = vi.fn(); + client.on("session.message.added", projected); + const subscription = client.subscribeSessionWithReady("sess-1"); + acknowledge(socket, sessionSubscribeRequest(socket)); + await Promise.resolve(); + acknowledgeWithResumeToken(socket, sessionSubscribeRequest(socket, 1), "resume-core"); + await subscription.ready; + + socket.receive({ + type: "session.event", + protocol_version: 2, + event_type: "message.added", + session_id: "sess-1", + task_id: "task-1", + sequence: 1, + event_id: "poison-1", + payload: { type: "message.added", session_id: "sess-1", message_id: "poison" }, + }); + const recoveryRequest = sessionSubscribeRequest(socket, 2); + socket.receive({ + id: recoveryRequest.id, + type: "response", + payload: { + success: true, + session_id: "sess-1", + wire_id: coreRequestPayload(recoveryRequest)?.wire_id, + result: "invalid_resume", + event_watermark: 1, + snapshot_cutoff: 1, + snapshot_token: "snapshot-recovery", + resume_token: "resume-recovery", + expires_at: "2099-01-01T00:00:00Z", + }, + }); + await Promise.resolve(); + + socket.receive({ + type: "session.event", + protocol_version: 1, + event_type: "message.added", + session_id: "sess-1", + task_id: "task-1", + sequence: 2, + event_id: "event-2", + payload: { + type: "message.added", + session_id: "sess-1", + task_id: "task-1", + message_id: "message-2", + author_type: "user", + content: "wait for owner", + created_at: "2026-09-07T12:00:00Z", + }, + }); + expect(projected).not.toHaveBeenCalled(); + + const unregister = client.registerCoreSessionRecovery("sess-1", async () => true); + await vi.waitFor(() => expect(projected).toHaveBeenCalledTimes(1)); + unregister(); + subscription.unsubscribe(); + }); +}); diff --git a/apps/web/lib/ws/client.ts b/apps/web/lib/ws/client.ts index edac279cb6..312365c5ca 100644 --- a/apps/web/lib/ws/client.ts +++ b/apps/web/lib/ws/client.ts @@ -1,4 +1,4 @@ -/* eslint-disable max-lines -- the shared WebSocket client owns all transport lifecycle paths. */ +/* eslint-disable max-lines -- WebSocketClient intentionally owns one connection's complete request, subscription, and reconnect lifecycle. */ import type { BackendMessageMap, BackendMessageType } from "@/lib/types/backend"; import type { ConnectionStatus } from "@/lib/types/connection"; @@ -10,6 +10,15 @@ import { toWebSocketRequestError, WebSocketRequestTimeoutError, } from "./request-error"; +import { + isRawSessionEvent, + orderedCoreDisposition, + orderedCoreAction, + projectCoreSessionPayload, + type CoreSessionStream, + type RawSessionEvent, +} from "./ordered-session-events"; +export type { RawSessionEvent } from "./ordered-session-events"; export { isWebSocketRequestTimeoutError, WebSocketRequestError, @@ -48,6 +57,8 @@ export interface SessionSubscriptionHandle { unsubscribe: () => void; } +export type CoreSessionRecoveryHandler = () => Promise; + type SessionSubscriptionReadiness = { promise: Promise; resolve: () => void; @@ -57,10 +68,98 @@ type SessionSubscriptionReadiness = { attempt: number; retryTimer: ReturnType | null; }; - export const SESSION_ENTRY_REQUEST_TIMEOUT_MS = 10000; export const SESSION_ENTRY_RETRY_DELAY_MS = 1000; const MAX_SESSION_SUBSCRIPTION_ATTEMPTS = 2; +// i18n-exempt: transport/API diagnostic, never rendered as user-facing copy. +const SESSION_SUBSCRIPTION_RELEASED_ERROR = "Session subscription released"; + +type OrderedSessionResponse = Record; + +// i18n-exempt: transport protocol validation diagnostics are never rendered as user-facing copy. +function invalidOrderedSessionResponse(): Error { + return new Error("Invalid ordered session response"); +} + +function isNonNegativeSafeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +function requiredOrderedString(response: OrderedSessionResponse, key: string): string { + const value = response[key]; + if (typeof value !== "string" || value === "") throw invalidOrderedSessionResponse(); + return value; +} + +// eslint-disable-next-line complexity -- The wire contract is validated field-by-field before state mutation. +function validateOrderedSubscribeResponse( + response: unknown, + sessionId: string, + wireId: string, +): { eventWatermark: number; resumeToken: string; result: "fresh" | "replay" | "invalid_resume" } { + if (!response || typeof response !== "object" || Array.isArray(response)) { + throw invalidOrderedSessionResponse(); + } + const payload = response as OrderedSessionResponse; + if (payload.success !== true || payload.session_id !== sessionId || payload.wire_id !== wireId) { + throw invalidOrderedSessionResponse(); + } + const eventWatermark = payload.event_watermark; + if ( + !isNonNegativeSafeInteger(eventWatermark) || + payload.snapshot_cutoff !== eventWatermark || + !["fresh", "replay", "invalid_resume"].includes(payload.result as string) + ) { + throw invalidOrderedSessionResponse(); + } + requiredOrderedString(payload, "snapshot_token"); + const resumeToken = requiredOrderedString(payload, "resume_token"); + requiredOrderedString(payload, "expires_at"); + if (payload.result === "replay") { + const replayFrom = payload.replay_from; + const replayTo = payload.replay_to; + if ( + !isNonNegativeSafeInteger(replayFrom) || + replayFrom === 0 || + !isNonNegativeSafeInteger(replayTo) || + replayTo < replayFrom || + replayTo > eventWatermark + ) { + throw invalidOrderedSessionResponse(); + } + } + return { + eventWatermark, + resumeToken, + result: payload.result as "fresh" | "replay" | "invalid_resume", + }; +} + +function validateOrderedAckResponse( + response: unknown, + sessionId: string, + wireId: string, + sequence: number, +): string { + if (!response || typeof response !== "object" || Array.isArray(response)) { + throw invalidOrderedSessionResponse(); + } + const payload = response as OrderedSessionResponse; + if ( + payload.success !== true || + payload.session_id !== sessionId || + payload.wire_id !== wireId || + payload.acknowledged_sequence !== sequence + ) { + throw invalidOrderedSessionResponse(); + } + return requiredOrderedString(payload, "resume_token"); +} +type RawWebSocketMessage = { + type?: unknown; + id?: unknown; + payload?: unknown; +}; const DEFAULT_RECONNECT_OPTIONS: Required = { enabled: true, @@ -86,6 +185,8 @@ export class WebSocketClient { timeout: ReturnType; } >(); + private rawSessionEventHandlers = new Set<(event: RawSessionEvent) => void>(); + private statusHandlers = new Set<(status: WebSocketStatus) => void>(); private pendingQueue: string[] = []; private reconnectOptions: Required; private reconnectAttempts = 0; @@ -94,6 +195,8 @@ export class WebSocketClient { private subscriptions = new Map(); private sessionSubscriptions = new Map(); private sessionSubscriptionReadiness = new Map(); + private coreSessionStreams = new Map(); + private coreSessionRecoveryHandlers = new Map>(); // Ref-counted focus signals: a session can be focused by both the task panel // and the task details page if both are mounted. Backend wakes its workspace // tracker into fast-poll mode while any client has focus, falling back to @@ -355,6 +458,21 @@ export class WebSocketClient { if (nextCount <= 0) { this.sessionSubscriptions.delete(sessionId); this.cancelSessionSubscriptionReadiness(sessionId); + const stream = this.coreSessionStreams.get(sessionId); + if (this.status === "connected" && stream) { + this.send({ + id: generateUUID(), + type: "request", + action: "session.unsubscribe", + payload: { + session_id: sessionId, + consumer_kind: "core", + wire_id: stream.wireId, + }, + }); + } + this.coreSessionStreams.delete(sessionId); + this.coreSessionRecoveryHandlers.delete(sessionId); if (this.status === "connected") { this.send({ id: generateUUID(), @@ -462,6 +580,40 @@ export class WebSocketClient { } } + onConnectionStatus(handler: (status: WebSocketStatus) => void) { + this.statusHandlers.add(handler); + return () => this.statusHandlers.delete(handler); + } + + onRawSessionEvent(handler: (event: RawSessionEvent) => void) { + this.rawSessionEventHandlers.add(handler); + return () => { + this.rawSessionEventHandlers.delete(handler); + }; + } + + registerCoreSessionRecovery(sessionId: string, handler: CoreSessionRecoveryHandler) { + const handlers = this.coreSessionRecoveryHandlers.get(sessionId) ?? new Set(); + handlers.add(handler); + this.coreSessionRecoveryHandlers.set(sessionId, handlers); + const stream = this.coreSessionStreams.get(sessionId); + if (stream?.needsHydration) { + void this.continueCoreSessionRecovery(sessionId, stream); + } + return () => { + const current = this.coreSessionRecoveryHandlers.get(sessionId); + if (!current) return; + current.delete(handler); + if (current.size === 0) this.coreSessionRecoveryHandlers.delete(sessionId); + }; + } + + retryCoreSessionRecovery(sessionId: string): Promise | undefined { + const stream = this.coreSessionStreams.get(sessionId); + if (!stream?.needsHydration) return undefined; + return this.continueCoreSessionRecovery(sessionId, stream); + } + private debugNotification(action: BackendMessageType, payload: unknown, handlerCount: number) { if (!isDebug() || DISPATCH_LOG_DENYLIST.has(action)) return; const payloadSessionId = (payload as { session_id?: string } | undefined)?.session_id; @@ -472,32 +624,104 @@ export class WebSocketClient { }); } - private handleParsedMessage(message: BackendMessageMap[BackendMessageType]) { - const msgWithId = message as { id?: string; type: string }; - - if (msgWithId.type === "response" && msgWithId.id) { - if (isDebug()) debugDispatch("response", { id: msgWithId.id }); - this.resolvePendingRequest(msgWithId.id, message.payload); - return; - } - if (msgWithId.type === "error" && msgWithId.id) { - if (isDebug()) debugDispatch("error-response", { id: msgWithId.id }); - this.rejectPendingRequest(msgWithId.id, message.payload); + private handleParsedMessage(value: unknown) { + if (isRawSessionEvent(value)) { + const disposition = orderedCoreDisposition(value); + this.handleCoreSessionEvent(value, disposition); + this.rawSessionEventHandlers.forEach((handler) => handler(value)); return; } + if (this.handleMalformedSessionEnvelope(value)) return; + if (!value || typeof value !== "object" || !("type" in value)) return; + const rawMessage = value as RawWebSocketMessage; + if (this.handleRequestResult(rawMessage)) return; + const message = value as BackendMessageMap[BackendMessageType]; if (message.type !== "notification") return; - - const action = (message as { action?: string })?.action as BackendMessageType | undefined; + const action = message.action; if (!action) return; const handlers = this.handlers.get(action); this.debugNotification(action, message.payload, handlers?.size ?? 0); if (handlers) { handlers.forEach((handler) => handler(message)); } - // Plugin bridge: forward the same notification to any handlers a loaded - // plugin registered for this action (registry.registerWsHandler). dispatchToPluginWsHandlers(action, message.payload); } + /** + * A frame that is session.event-shaped but fails the strict envelope check + * (bad protocol version, missing identity fields, malformed payload) can + * never be a valid ordered row. If it targets a live core stream at the + * expected next sequence, run the same durable poison recovery as a poison + * disposition so the stream rebinds to the authoritative watermark instead + * of stalling silently on a hole; the malformed frame itself is never + * dispatched to consumers. + */ + private handleMalformedSessionEnvelope(value: unknown): boolean { + if (!value || typeof value !== "object") return false; + const candidate = value as { + type?: unknown; + session_id?: unknown; + task_id?: unknown; + sequence?: unknown; + }; + if (candidate.type !== "session.event") return false; + const sessionId = candidate.session_id; + const sequence = candidate.sequence; + const taskId = candidate.task_id; + const stream = + typeof sessionId === "string" && typeof sequence === "number" + ? this.coreSessionStreams.get(sessionId) + : undefined; + if (stream && sequence === stream.lastSeenSequence + 1) { + if (isDebug()) { + console.warn( + `[ws] malformed ordered session.event frame for "${sessionId}" at ${sequence}`, + ); + } + this.recoverCoreSessionPoison(sessionId as string, stream); + } + if ( + typeof sessionId === "string" && + Number.isSafeInteger(sequence) && + (sequence as number) > 0 + ) { + this.notifyMalformedSessionEvent( + sessionId, + sequence as number, + typeof taskId === "string" ? taskId : null, + ); + } + return true; + } + private notifyMalformedSessionEvent(sessionId: string, sequence: number, taskId: string | null) { + const poison: RawSessionEvent = { + type: "session.event", + protocol_version: 1, + event_type: "session.event.poison", + session_id: sessionId, + task_id: taskId, + sequence, + event_id: `poison:${sessionId}:${sequence}`, + payload: { + type: "session.event.poison", + session_id: sessionId, + task_id: taskId, + }, + }; + this.rawSessionEventHandlers.forEach((handler) => handler(poison)); + } + + private handleRequestResult(message: RawWebSocketMessage): boolean { + if (message.type !== "response" && message.type !== "error") return false; + if (typeof message.id !== "string") return false; + if (message.type === "response") { + if (isDebug()) debugDispatch("response", { id: message.id }); + this.resolvePendingRequest(message.id, message.payload); + } else { + if (isDebug()) debugDispatch("error-response", { id: message.id }); + this.rejectPendingRequest(message.id, message.payload); + } + return true; + } private resolvePendingRequest(msgId: string, payload: unknown) { const pending = this.pendingRequests.get(msgId); @@ -619,18 +843,55 @@ export class WebSocketClient { if (readiness.requestStarted) return; readiness.requestStarted = true; readiness.attempt += 1; - void this.request( + const stream = this.getOrCreateCoreSessionStream(sessionId); + stream.ready = false; + stream.pendingEvents.length = 0; + const legacySubscription = this.request( "session.subscribe", { session_id: sessionId }, SESSION_ENTRY_REQUEST_TIMEOUT_MS, - ).then( - () => { + ); + const orderedSubscription = this.request( + "session.subscribe", + { + session_id: sessionId, + consumer_kind: "core", + wire_id: stream.wireId, + ...(stream.resumeToken || stream.lastSeenSequence > 0 + ? { last_seen_sequence: stream.lastSeenSequence } + : {}), + ...(stream.resumeToken ? { resume_token: stream.resumeToken } : {}), + }, + SESSION_ENTRY_REQUEST_TIMEOUT_MS, + ).then((response) => { + const validated = validateOrderedSubscribeResponse(response, sessionId, stream.wireId); + const recoveryRequired = validated.result === "invalid_resume" || stream.needsHydration; + if (recoveryRequired) { + stream.projectionPaused = true; + stream.needsHydration = true; + stream.recoveryGeneration += 1; + stream.recoveryWatermark = validated.eventWatermark; + } else if (validated.result !== "replay") { + stream.lastSeenSequence = Math.max(stream.lastSeenSequence, validated.eventWatermark); + } + stream.resumeToken = validated.resumeToken; + stream.ready = true; + this.drainCoreSessionEvents(sessionId, stream); + }); + void Promise.all([legacySubscription, orderedSubscription]) + .then(() => { if (this.sessionSubscriptionReadiness.get(sessionId) !== readiness) return; readiness.retryTimer = null; readiness.settled = true; readiness.resolve(); - }, - (error: unknown) => { + const stream = this.coreSessionStreams.get(sessionId); + if (stream?.needsHydration) { + void this.continueCoreSessionRecovery(sessionId, stream); + } + }) + .catch((error: unknown) => { + stream.ready = false; + stream.pendingEvents.length = 0; if (this.sessionSubscriptionReadiness.get(sessionId) !== readiness) return; readiness.requestStarted = false; if ( @@ -648,10 +909,197 @@ export class WebSocketClient { this.sessionSubscriptionReadiness.delete(sessionId); readiness.settled = true; readiness.reject(error); - }, + }); + } + + private getOrCreateCoreSessionStream(sessionId: string): CoreSessionStream { + const current = this.coreSessionStreams.get(sessionId); + if (current) return current; + const stream = { + wireId: `core:web:${generateUUID()}`, + lastSeenSequence: 0, + ready: false, + pendingEvents: [], + projectionPaused: false, + needsHydration: false, + recoveryGeneration: 0, + }; + this.coreSessionStreams.set(sessionId, stream); + return stream; + } + + private handleCoreSessionEvent( + event: RawSessionEvent, + disposition = orderedCoreDisposition(event), + ) { + const stream = this.coreSessionStreams.get(event.session_id); + if (!stream) return; + if (stream.projectionPaused || stream.needsHydration) { + stream.pendingEvents.push(event); + return; + } + if (!stream.ready) { + stream.pendingEvents.push(event); + return; + } + if (stream.pendingEvents.length > 0) { + stream.pendingEvents.push(event); + this.drainCoreSessionEvents(event.session_id, stream); + return; + } + this.processCoreSessionEvent(event, stream, disposition); + this.drainCoreSessionEvents(event.session_id, stream); + } + + private drainCoreSessionEvents(sessionId: string, stream: CoreSessionStream) { + if ( + !stream.ready || + stream.projectionPaused || + stream.needsHydration || + stream.pendingEvents.length === 0 + ) + return; + stream.pendingEvents.sort((left, right) => left.sequence - right.sequence); + while (stream.pendingEvents.length > 0) { + const event = stream.pendingEvents[0]; + if (!event) return; + if (event.sequence > stream.lastSeenSequence + 1) { + stream.pendingEvents.length = 0; + this.recoverCoreSessionPoison(sessionId, stream); + return; + } + stream.pendingEvents.shift(); + this.processCoreSessionEvent(event, stream); + } + } + + private processCoreSessionEvent( + event: RawSessionEvent, + stream: CoreSessionStream, + disposition = orderedCoreDisposition(event), + ) { + if (event.sequence <= stream.lastSeenSequence) { + void this.acknowledgeCoreSessionEvent( + event.session_id, + stream, + stream.lastSeenSequence, + ).catch(() => this.recoverCoreSessionPoison(event.session_id, stream)); + return; + } + if (event.sequence !== stream.lastSeenSequence + 1) { + this.recoverCoreSessionPoison(event.session_id, stream); + return; + } + if (disposition === "poison") { + this.recoverCoreSessionPoison(event.session_id, stream); + return; + } + if (disposition === "project") { + const action = orderedCoreAction(event.event_type); + if (!action) return; + const payload = projectCoreSessionPayload(event); + const message = { + type: "notification", + action, + payload, + } as BackendMessageMap[BackendMessageType]; + const handlers = this.handlers.get(action); + this.debugNotification(action, payload, handlers?.size ?? 0); + handlers?.forEach((handler) => handler(message)); + dispatchToPluginWsHandlers(action, payload); + } + stream.lastSeenSequence = event.sequence; + void this.acknowledgeCoreSessionEvent(event.session_id, stream, event.sequence).catch(() => + this.recoverCoreSessionPoison(event.session_id, stream), ); } + private recoverCoreSessionPoison(sessionId: string, stream: CoreSessionStream) { + if (stream.poisonRecovery) return; + stream.projectionPaused = true; + stream.recoveryGeneration += 1; + stream.recoveryWatermark = undefined; + stream.poisonRecovery = this.request("session.subscribe", { + session_id: sessionId, + consumer_kind: "core", + wire_id: stream.wireId, + last_seen_sequence: stream.lastSeenSequence, + ...(stream.resumeToken ? { resume_token: stream.resumeToken } : {}), + replace_cursor: true, + }) + .then((response) => { + const validated = validateOrderedSubscribeResponse(response, sessionId, stream.wireId); + if (this.coreSessionStreams.get(sessionId) !== stream) return; + stream.resumeToken = validated.resumeToken; + stream.needsHydration = true; + stream.recoveryWatermark = validated.eventWatermark; + return this.continueCoreSessionRecovery(sessionId, stream); + }) + .catch(() => undefined) + .finally(() => { + stream.poisonRecovery = undefined; + }); + } + + private continueCoreSessionRecovery( + sessionId: string, + stream: CoreSessionStream, + ): Promise { + if (!stream.needsHydration || stream.recoveryWatermark === undefined) { + return Promise.resolve(false); + } + const existing = stream.recoveryHydration; + if (existing) return existing; + const handlers = [...(this.coreSessionRecoveryHandlers.get(sessionId) ?? [])]; + if (handlers.length === 0) return Promise.resolve(false); + const generation = stream.recoveryGeneration; + const pending = Promise.all(handlers.map((handler) => Promise.resolve().then(() => handler()))) + .then((results) => { + if ( + this.coreSessionStreams.get(sessionId) !== stream || + stream.recoveryGeneration !== generation || + !results.every(Boolean) + ) { + return false; + } + const watermark = stream.recoveryWatermark; + if (watermark === undefined) return false; + stream.lastSeenSequence = Math.max(stream.lastSeenSequence, watermark); + stream.pendingEvents = stream.pendingEvents.filter( + (event) => event.sequence > stream.lastSeenSequence, + ); + stream.needsHydration = false; + stream.projectionPaused = false; + stream.recoveryWatermark = undefined; + this.drainCoreSessionEvents(sessionId, stream); + return true; + }) + .catch(() => false) + .finally(() => { + if (stream.recoveryHydration !== pending) return; + stream.recoveryHydration = undefined; + if (stream.needsHydration && stream.recoveryGeneration !== generation) { + void this.continueCoreSessionRecovery(sessionId, stream); + } + }); + stream.recoveryHydration = pending; + return pending; + } + + private async acknowledgeCoreSessionEvent( + sessionId: string, + stream: CoreSessionStream, + sequence: number, + ) { + const response = await this.request("session.ack", { + session_id: sessionId, + consumer_kind: "core", + wire_id: stream.wireId, + sequence, + resume_token: stream.resumeToken, + }); + stream.resumeToken = validateOrderedAckResponse(response, sessionId, stream.wireId, sequence); + } private cancelSessionSubscriptionReadiness(sessionId: string) { const readiness = this.sessionSubscriptionReadiness.get(sessionId); if (!readiness) return; @@ -662,7 +1110,7 @@ export class WebSocketClient { } if (!readiness.settled) { readiness.settled = true; - readiness.reject(new Error("Session subscription released")); + readiness.reject(new Error(SESSION_SUBSCRIPTION_RELEASED_ERROR)); } } @@ -742,5 +1190,6 @@ export class WebSocketClient { private setStatus(status: WebSocketStatus) { this.status = status; this.onStatusChange?.(status); + this.statusHandlers.forEach((handler) => handler(status)); } } diff --git a/apps/web/lib/ws/ordered-session-events.test.ts b/apps/web/lib/ws/ordered-session-events.test.ts new file mode 100644 index 0000000000..aeda55d063 --- /dev/null +++ b/apps/web/lib/ws/ordered-session-events.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { isRawSessionEvent, orderedCoreDisposition } from "./ordered-session-events"; +import type { RawSessionEvent } from "./ordered-session-events"; + +const validEvent: RawSessionEvent = { + type: "session.event", + protocol_version: 1, + event_type: "message.added", + session_id: "session-1", + task_id: "task-1", + sequence: 1, + event_id: "event-1", + payload: { type: "message.added" }, +}; + +describe("ordered session event envelopes", () => { + it.each([ + ["zero sequence", { sequence: 0 }], + ["negative sequence", { sequence: -1 }], + ["fractional sequence", { sequence: 1.5 }], + ["empty event id", { event_id: "" }], + ["empty event type", { event_type: "" }], + ["empty session id", { session_id: "" }], + ])("rejects %s", (_name, override) => { + const event = { ...validEvent, ...override }; + expect(isRawSessionEvent(event)).toBe(false); + }); + + it("classifies a structurally valid but incompatible payload as poison", () => { + expect( + orderedCoreDisposition({ + ...validEvent, + payload: { type: "message.updated" }, + }), + ).toBe("poison"); + }); + it.each([ + ["syntactically malformed", "not-a-timestamp"], + ["normalized-invalid", "2026-02-30T10:00:00Z"], + ])("classifies %s timestamps as poison", (_name, createdAt) => { + expect( + orderedCoreDisposition({ + ...validEvent, + payload: { + type: "message.added", + session_id: "session-1", + task_id: "task-1", + message_id: "message-1", + author_type: "agent", + content: "content", + created_at: createdAt, + }, + }), + ).toBe("poison"); + }); + + it("classifies turn removal as a projectable ordered event", () => { + expect( + orderedCoreDisposition({ + ...validEvent, + event_type: "session.turn.removed", + payload: { + type: "session.turn.removed", + session_id: "session-1", + task_id: "task-1", + id: "turn-1", + }, + }), + ).toBe("project"); + }); +}); diff --git a/apps/web/lib/ws/ordered-session-events.ts b/apps/web/lib/ws/ordered-session-events.ts new file mode 100644 index 0000000000..0d70492fcd --- /dev/null +++ b/apps/web/lib/ws/ordered-session-events.ts @@ -0,0 +1,156 @@ +import type { BackendMessageType } from "@/lib/types/backend"; +import { parseTurnTimestamp } from "@/lib/state/slices/session/turn-actions"; + +export interface RawSessionEvent { + type: "session.event"; + protocol_version: number; + event_type: string; + session_id: string; + task_id: string | null; + sequence: number; + event_id: string; + payload: unknown; +} + +export type CoreSessionStream = { + wireId: string; + lastSeenSequence: number; + ready: boolean; + pendingEvents: RawSessionEvent[]; + resumeToken?: string; + poisonRecovery?: Promise; + projectionPaused: boolean; + needsHydration: boolean; + recoveryGeneration: number; + recoveryWatermark?: number; + recoveryHydration?: Promise; +}; + +const ORDERED_EVENT_ACTIONS: Readonly> = { + "message.added": "session.message.added", + "message.updated": "session.message.updated", + "message.deleted": "session.message.deleted", + "session.turn.started": "session.turn.started", + "session.turn.completed": "session.turn.completed", + "session.turn.removed": "session.turn.removed", +}; +const IGNORABLE_ORDERED_EVENT_TYPES: Readonly> = { + "session.workspace_sources.updated": true, +}; + +export type OrderedCoreDisposition = "project" | "ignore" | "terminal" | "poison"; + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value !== ""; +} + +function isTimestamp(value: unknown): value is string { + return isNonEmptyString(value) && parseTurnTimestamp(value) !== null; +} + +// eslint-disable-next-line complexity -- Each core event shape is checked against the ordered wire contract. +function isProjectableCorePayload( + event: RawSessionEvent, + payload: Record, +): boolean { + if (payload.session_id !== event.session_id) return false; + if (event.task_id !== null && payload.task_id !== event.task_id) return false; + if (event.task_id === null && payload.task_id !== undefined && payload.task_id !== null) + return false; + switch (event.event_type) { + case "message.added": + case "message.updated": + if ( + !isNonEmptyString(payload.message_id) || + (payload.author_type !== "user" && payload.author_type !== "agent") || + typeof payload.content !== "string" || + !isTimestamp(payload.created_at) + ) { + return false; + } + return event.event_type !== "message.updated" || isTimestamp(payload.updated_at); + case "message.deleted": + return isNonEmptyString(payload.message_id); + case "session.turn.removed": + return isNonEmptyString(payload.id); + case "session.turn.started": + return isNonEmptyString(payload.id) && isTimestamp(payload.started_at); + case "session.turn.completed": + return ( + isNonEmptyString(payload.id) && + isTimestamp(payload.started_at) && + isTimestamp(payload.completed_at) && + isTimestamp(payload.updated_at) + ); + case "session.removed": + return true; + default: + return Boolean(IGNORABLE_ORDERED_EVENT_TYPES[event.event_type]); + } +} + +function isCoreEventPayload(event: RawSessionEvent): boolean { + if ( + !event.payload || + typeof event.payload !== "object" || + Array.isArray(event.payload) || + Reflect.get(event.payload, "type") !== event.event_type + ) { + return false; + } + return isProjectableCorePayload(event, event.payload as Record); +} + +export function orderedCoreDisposition(event: RawSessionEvent): OrderedCoreDisposition { + if (event.protocol_version !== 1 || !isCoreEventPayload(event)) { + return "poison"; + } + if (orderedCoreAction(event.event_type)) return "project"; + if (event.event_type === "session.removed") return "terminal"; + if (IGNORABLE_ORDERED_EVENT_TYPES[event.event_type]) return "ignore"; + return "poison"; +} + +function isPositiveSequence(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0; +} + +export function isRawSessionEvent(value: unknown): value is RawSessionEvent { + if (!value || typeof value !== "object") return false; + const event = value as Partial; + const hasNullableTaskId = event.task_id === null || typeof event.task_id === "string"; + return ( + event.type === "session.event" && + event.protocol_version === 1 && + typeof event.event_type === "string" && + event.event_type.length > 0 && + typeof event.session_id === "string" && + event.session_id.length > 0 && + hasNullableTaskId && + isPositiveSequence(event.sequence) && + typeof event.event_id === "string" && + event.event_id.length > 0 && + "payload" in event + ); +} + +export function orderedCoreAction(eventType: string): BackendMessageType | undefined { + return ORDERED_EVENT_ACTIONS[eventType]; +} + +export function projectCoreSessionPayload(event: RawSessionEvent): Record { + const payload: Record = + event.payload !== null && typeof event.payload === "object" && !Array.isArray(event.payload) + ? { ...event.payload } + : {}; + if (event.event_type === "message.added" || event.event_type === "message.updated") { + if (typeof payload.message_type === "string") { + payload.type = payload.message_type; + } else if (payload.type === event.event_type) { + // Legacy test-harness publishers use the lifecycle event type in the + // generic `type` field and omit the message subtype. + payload.type = "message"; + } + } + return payload; +} diff --git a/apps/web/package.json b/apps/web/package.json index 4968c532f8..148c99f33d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,6 +9,7 @@ "build": "vite build", "build:vite": "vite build", "build:e2e": "cross-env KANDEV_I18N_BUNDLE_PSEUDO=1 pnpm run build", + "build:e2e-plugin": "node scripts/build-e2e-plugin.mjs", "start": "vite preview --host 0.0.0.0", "lint": "eslint --max-warnings 0", "lint:i18n": "eslint --config eslint.i18n.config.mjs", diff --git a/apps/web/scripts/build-e2e-plugin.mjs b/apps/web/scripts/build-e2e-plugin.mjs new file mode 100644 index 0000000000..4732d081fa --- /dev/null +++ b/apps/web/scripts/build-e2e-plugin.mjs @@ -0,0 +1,26 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; + +const argumentIndex = process.argv.indexOf("--outDir"); +if (argumentIndex === -1 || !process.argv[argumentIndex + 1]) { + throw new Error("build:e2e-plugin requires --outDir"); +} + +const source = path.resolve("e2e/fixtures/plugins/prompt-history-plugin/bundle.js"); +const outputDirectory = path.resolve(process.argv[argumentIndex + 1]); +const output = path.join(outputDirectory, "bundle.js"); +const content = await readFile(source); +const sourceHash = createHash("sha256").update(content).digest("hex"); + +await rm(outputDirectory, { recursive: true, force: true }); +await mkdir(outputDirectory, { recursive: true }); +await writeFile(output, content); + +const generated = await readFile(output); +const generatedHash = createHash("sha256").update(generated).digest("hex"); +if (generatedHash !== sourceHash) { + throw new Error("build:e2e-plugin generated stale output"); +} +console.log(`[build:e2e-plugin] ${sourceHash} -> ${output}`); diff --git a/apps/web/scripts/write-e2e-plugin-identity.mjs b/apps/web/scripts/write-e2e-plugin-identity.mjs new file mode 100644 index 0000000000..c51707c346 --- /dev/null +++ b/apps/web/scripts/write-e2e-plugin-identity.mjs @@ -0,0 +1,46 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; + +const [sourcePath, generatedPath, manifestPath, archivePath, identityPath] = process.argv.slice(2); +if (!identityPath) + throw new Error( + "write-e2e-plugin-identity requires source, output, manifest, archive, and identity paths", + ); + +async function sha256(file) { + return createHash("sha256") + .update(await readFile(file)) + .digest("hex"); +} + +const manifest = await readFile(manifestPath, "utf8"); +for (const required of [ + 'id: "kandev-plugin-e2e"', + 'version: "1.0.0"', + 'min_kandev_version: "0.91.1"', + 'api_read: ["messages"]', +]) { + if (!manifest.includes(required)) throw new Error(`fixture manifest missing ${required}`); +} +const output = await readFile(generatedPath, "utf8"); +if (!output.includes('id: "prompt-history-plugin"')) { + throw new Error("fixture output missing prompt-history-plugin panel"); +} + +const identity = { + schemaVersion: 2, + pluginId: "kandev-plugin-e2e", + pluginVersion: "1.0.0", + bundlePath: "ui/bundle.js", + sourceHash: await sha256(sourcePath), + generatedOutputHash: await sha256(generatedPath), + manifestHash: await sha256(manifestPath), + archiveSha256: await sha256(archivePath), + manifestCapability: "messages", + minKandevVersion: "0.91.1", + panelKey: "prompt-history-plugin", +}; +await writeFile(identityPath, `${JSON.stringify(identity, null, 2)}\n`); +console.log(`[e2e-plugin-identity] ${path.resolve(identityPath)}`); diff --git a/docs/decisions/2026-09-06-browser-plugin-conversation-facade.md b/docs/decisions/2026-09-06-browser-plugin-conversation-facade.md new file mode 100644 index 0000000000..9944979cfa --- /dev/null +++ b/docs/decisions/2026-09-06-browser-plugin-conversation-facade.md @@ -0,0 +1,38 @@ +# Browser plugins read session conversations through a typed Host facade + +**Status:** proposed +**Date:** 2026-09-06 +**Area:** frontend, backend, protocol, plugins, security + +## Context + +ADR 0047 gives a plugin backend sanitized conversation reads through the capability-gated Host gRPC channel. Native browser plugins have a different gap: prompt history currently reads private Zustand projections, first-party REST helpers, raw session WebSocket events, local mobile navigation state, custom-prompt settings, and a browser-only favorites store. + +An external prompt-history plugin could technically reach some of these because native plugin JavaScript runs in Kandev's origin and the runtime still exposes a compatibility `host.store`. That would make the plugin depend on unversioned internal shapes, duplicate subscription race handling, and break mobile navigation. Calling a plugin backend for every page would avoid private frontend state but would require a Go backend for an otherwise browser-only feature and add an unnecessary browser-to-plugin-to-host round trip. + +## Decision + +Add a typed browser `host.conversation` facade and scoped task-panel navigation capability. + +- Browser reads use authenticated, plugin-scoped HTTP routes under `/api/plugins/{pluginId}/conversation/...`. The routes require an active plugin with `api_read:messages`, enforce normal user/session authorization, and call existing task services. +- The facade exposes React hooks for paginated session messages, session turns, and read-only favorite state. It owns WebSocket readiness, live reconciliation, reconnect, request cancellation, and plugin-generation fencing. +- Public DTOs are narrower than first-party message and turn DTOs. Content is sanitized; raw system content and arbitrary metadata never leave the host. The only metadata-derived field is a validated sender task ID. +- Task-panel props gain a generation-bound `conversation.openMessage(messageId)` capability. It is scoped to the panel's current task/session/presentation and delegates to the native desktop or mobile navigation owner. +- Host-owned prompt-alias rendering is exposed as a curated UI component so custom-prompt contents and responsive preview mechanics stay private. +- The existing Go Host `Messages().List` RPC remains the plugin-backend contract. It is not stretched to serve browser state, turns, favorite state, or navigation. + +## Consequences + +- A UI-only plugin can reproduce prompt history without importing Kandev web modules, reading `AppState`, using raw WebSocket handlers, or shipping a backend binary. +- Kandev remains responsible for authorization, content sanitization, subscription gaps, native navigation, and private user-state semantics. +- The supported browser contract is capability-gated, but native same-origin plugins are still not a hard security sandbox. This decision does not claim otherwise. +- Additive SDK and task-panel fields remain compatible with existing plugins. Consumers require a `min_kandev_version` that includes the facade. +- Core prompt history remains until a separate plugin and extraction package prove parity and migrate saved built-in panel identities. + +## Alternatives considered + +- **Expose the current prompt-history Zustand slice.** Rejected: it freezes a private cache, actions, and generation model into the public SDK and gives plugins unrelated mutable application state. +- **Tell plugins to call `/api/v1/task-sessions/...` and register raw WS handlers.** Rejected: it makes internal REST/event payloads public by accident and leaves every plugin to reproduce snapshot/subscription race handling. +- **Require a plugin backend to proxy Host gRPC reads.** Rejected: it adds a process and network hop to a browser-only panel, still does not solve native navigation or browser-local favorites, and prevents a UI-only package. +- **Add prompt-history-specific REST and React components as one monolithic Host widget.** Rejected: it would move the feature into a differently named core component rather than establish reusable conversation and panel boundaries. +- **Move mobile scroll-target state into global Zustand.** Rejected: the current mobile layout owns its active full-height surface and exact scroll target locally. A scoped injected adapter preserves that ownership and prevents stale global navigation. diff --git a/docs/plans/plugins/PLUGIN-API.md b/docs/plans/plugins/PLUGIN-API.md index 1bf63856bc..eded4b15e5 100644 --- a/docs/plans/plugins/PLUGIN-API.md +++ b/docs/plans/plugins/PLUGIN-API.md @@ -16,6 +16,17 @@ repositoryProviderIds?: string[] }`. `repositoryProviderIds` is JSON (`~/.kandev/plugins///ui/...`, per manifest `ui.bundle`). There is no reverse proxy and no live upstream request: the plugin subprocess does not need to be running to serve the UI bundle, since installation already extracted the file. + Before importing a bundle, the Host calls authenticated + `GET /api/plugins/{id}/conversation/binding`. Success returns + `{bindingToken,generation,expiresAt}` with `expiresAt` RFC3339 UTC; every + response, including errors, sends `Cache-Control: no-store`. Errors use + `{ "error": { "code": "...", "message": "...", "retryable": false|true } }`: + `401/unauthenticated/false`, `404/not_found/false`, and + `409/generation_superseded/true`. On `401` or `404`, the current load is + skipped without disabling persisted state; on `409`, binding is retried with + bounded backoff and import waits for success. Expiry rebinds or aborts. The + grant remains in a Host-only closure and is never passed to bundle code/public + API values. 2. On SPA boot, the **plugin host** (`apps/web/lib/plugins/host.ts`) iterates `bootPayload.plugins`, injects any `styleUrls` as ``, and dynamically `import(/* @vite-ignore */ bundleUrl)` each bundle as a native ES module. Before a @@ -33,21 +44,19 @@ repositoryProviderIds?: string[] }`. `repositoryProviderIds` is JSON destroy?(): void, }) ``` + The loader serializes stages per plugin and creates a private Host-only `(pluginId,generation,stageId,stageToken)` registration context before import. The public `window.registerKandevPlugin(id,plugin)` signature is unchanged; the Host routes each call to the currently open stage context for that plugin ID through an internal import-context handshake (closure-held stage token, never a public argument). Only the open stage's first registration is accepted. Registration after context closure (timeout, failure, or settled stage), a duplicate registration within the same stage, and a call presenting a foreign or retired stage token are rejected without mutating active state; the next stage does not import until the prior import settles. + Generation lifecycle is `pending -> active -> retired` with one commit linearization point. `pending` owns staged style links keyed by `(pluginId,generation)`, staged registry contributions, staged runtime handles/subscriptions, and the staged binding grant. `active` is the single committed generation serving panels. `retired` is revoked and reclaimed. Failed or timed-out staging removes only pending style links, contributions, handles, and grants. Commit atomically swaps active registry contributions and styles, then retires the old generation: revoke its grant and binding token, call its `destroy?.()`, remove its `(pluginId,generation)` styles and registrations, and close its panels. Prior contributions stay visible until replacement succeeds. 4. After the module resolves, the host calls `initialize(registry, host)`. A - reload/update may unregister the previous generation before starting the next - one; the host keeps that transition unresolved until the current generation's - initialization finishes. Slow or failed reloads do not by themselves revoke - open or saved task panels. On explicit plugin disable/uninstall the host calls - `destroy?.()`, removes the plugin's registrations, and closes its panels. - Each initialization attempt is transactional for plugin-owned runtime state: - failure or timeout aborts plugin-owned work and fences callbacks from the - expired generation. The same generation owns host-created subscriptions, - modal and task-link handles, toasts, and review surfaces; the loader closes or - unsubscribes them before calling `destroy` exactly once. Requests and callbacks - from an expired generation cannot mutate the replacement generation. Failure or - timeout does **not** unregister `registry` contributions (nav items, routes, - etc.) already made before the failure — those persist, and only the plugin's - lifecycle status becomes failed, until the plugin's _next_ load revokes them. + reload/update stages binding, import, and initialization for the successor; + the previous active generation remains usable until the successor commits. + Failed or timed-out staging fences only the pending generation and leaves + open/saved panels on the previous generation. On explicit disable/uninstall + the host calls `destroy?.()`, revokes the active grant, removes registrations, + and closes panels. Each initialization attempt is transactional for + plugin-owned runtime state; its callbacks, subscriptions, and handles are + fenced on failure, while successful replacement revokes the old generation. + Failure does not unregister prior registry contributions until replacement + succeeds. ## Global entry point @@ -60,6 +69,11 @@ The independently consumable frontend type contract is the runtime-free these types instead of re-declaring this document or importing `apps/web` internals. The host has a compile-time assignability test, and the real Bitbucket package is a required exact-head compatibility consumer. +`HostReact` includes `Fragment`, `createElement`, `useState`, `useEffect`, +`useMemo`, `useCallback`, and `useLayoutEffect` with structural React-compatible +signatures. `host.ui.PromptMentionText` is a curated host component with +`{ text: string; interactive?: boolean }` props and owns alias loading plus +fine/coarse-pointer disclosure. ## `host: PluginHostApi` @@ -353,7 +367,7 @@ provider-neutral code-host dashboard set: `ChangeRequestList`, `ChangeRequestRow`, `ChangeRequestDetail`, `IntegrationListToolbar`, `IntegrationScopeBar`, `IntegrationSaveQueryDialog`, `IntegrationRepositoryFilter`, `IntegrationCursorPagination`, `IntegrationStartTaskMenu`, `IntegrationIcon`, `IntegrationChangeRequestStatus`, and -`TaskRowIndicator`, plus native integration settings surfaces: +`TaskRowIndicator`, `PromptMentionText`, plus native integration settings `IntegrationAuthStatusBanner`, `IntegrationEnabledControl`, `SettingsSection`, `SettingsCard`, and `WorkspaceScopedSection`. The authoritative list is `apps/web/lib/plugins/host-api.ts` (`PLUGIN_UI`). @@ -681,6 +695,386 @@ that already filters to this plugin's own events, applies your `scope`/ tab's own writes (so an editor never clobbers its own caret/selection from its own write). +### `host.conversation` — live paginated session history + +This browser-only facade requires the manifest capability +`capabilities.api_read: ["messages"]` and `min_kandev_version: "0.91.1"` or +higher. The same floor applies to the Go `Messages().List` reader. It is the +only supported browser way to read prompt history and does not expose Zustand +state, first-party `/api/v1` URLs, raw content, arbitrary metadata, or raw +WebSocket frames. The Host binds every request and event to the current +plugin generation and task-panel session context. + +```ts +type PluginConversationErrorCode = + | "unauthenticated" + | "not_found" + | "invalid_query" + | "upstream_failure"; + +interface PluginConversationError { + code: PluginConversationErrorCode; + message: string; + retryable: boolean; +} +Browser conversation routes map `401` to `unauthenticated` with +`retryable:false`, `404` to `not_found` with `retryable:false`, `400` to +`invalid_query` with `retryable:false`, and every authorized upstream `5xx` to +`upstream_failure` with `retryable:true`. Binding-only +`409/generation_superseded` is not plugin-visible; continuation-renewal +`409` is plugin-visible as retryable `upstream_failure`. + +type PluginSessionKind = "managed" | "passthrough" | null; +type PluginConversationAuthor = "user" | "agent"; +type PluginConversationSort = "asc" | "desc"; + +interface PluginConversationMessage { + id: string; + taskId: string | null; + sessionId: string; + turnId?: string; + authorType: PluginConversationAuthor; + type: string; + content: string; + createdAt: string; + updatedAt: string; + promptIndex?: number; + senderTaskId?: string; +} + +interface PluginConversationTurn { + id: string; + taskId: string | null; + sessionId: string; + startedAt: string; + completedAt?: string; + updatedAt: string; +} +interface PluginSessionMessagesQuery { + sessionId: string | null; + taskId?: string | null; + authorTypes?: readonly PluginConversationAuthor[]; + sort?: PluginConversationSort; + pageSize?: number; +} + +interface PluginSessionMessagesState { + messages: readonly PluginConversationMessage[]; + loading: boolean; + hydrated: boolean; + loadingMore: boolean; + error: PluginConversationError | null; + hasMore: boolean; + removed: boolean; + loadMore(): Promise; + retry(): void; +} + +interface PluginSessionTurnsState { + turns: readonly PluginConversationTurn[]; + loading: boolean; + hydrated: boolean; + error: PluginConversationError | null; + removed: boolean; + retry(): void; +} + +interface PluginConversationApi { + useSessionMessages( + query: PluginSessionMessagesQuery, + ): PluginSessionMessagesState; + useSessionTurns( + sessionId: string | null, + taskId?: string | null, + ): PluginSessionTurnsState; + useMessageFavorite(sessionId: string | null, messageId: string): boolean; +} +``` + +Within a task-panel render, `host.conversation` resolves the Host-injected +panel scope; `props.conversation.history` is the equivalent explicit handle. +Outside a panel scope, nullable session reads return empty state. `taskId` is tri-state: `undefined` (field omitted) inherits the active panel context task via Host-side resolution before sending, so the server never observes `undefined`; explicit `null` means session-scoped with no task filter and encodes as absent `task_id`; an explicit string must equal the panel context task or the query fails `400/invalid_query` without network activity. An explicit string sends as its value; empty `task_id=` is `400`. Event `task_id` and public DTO `taskId` are nullable (`string | null`), with `null` for session-scoped rows. Cache identity, cursor fingerprint, and live-event filtering all key on the tri-state tag, so `undefined`, `null`, and `"t1"` never collapse; wrong-session or wrong-task events are dropped before merge. +Live event matching is explicit: inherited `undefined` and an explicit task +string accept only events whose `session_id` and `task_id` equal the resolved +query context. Explicit `null` accepts every task ID in the selected session, +including event `task_id: null`. A `session.removed` barrier is accepted for +the selected session regardless of its event task ID and closes both hooks. + +`loadMore()` resolves to the number of newly projected messages. Joined calls +resolve to the same committed count. Exhausted, closed, and terminal handles +resolve `0` without network activity; retryable transport or renewal failures +reject with `PluginConversationError` while leaving committed state unchanged. +Non-retryable query errors also reject and remain in `error`; `retry()` clears +the error and starts a fresh snapshot only for a live, retryable handle. +Turn `retry()` follows the same live-handle rule: it clears a retryable error +and starts a fresh ready snapshot, while closed, removed, exhausted, and +non-retryable handles do not issue network requests. +Each mounted `PluginTaskPanel` is wrapped in a Host-owned scope provider with its own cache, consumer identity, and abort controller. `history` is the authoritative panel handle; global `host.conversation` resolves only the nearest scope. Unmount or any plugin/panel/task/session/generation change aborts in-flight work, releases that consumer, and closes the handle. Closed handles and calls outside a scope return the stable empty state without network activity; two panels never share scope state. Message pages are bounded, newest-first by default, deterministic keyset pages with opaque cursors. `loadMore` is idempotent and preserves cursor, sort, filters, and generation. The Host owns cutoff, sidecars, replay, gaps, deduplication, tombstones, and lifecycle abort; none is public DTO state. +Lifecycle invalidation also includes a presentation change. `loadMore` is +idempotent operationally: concurrent calls for the same committed continuation +join one in-flight request, and calls after `hasMore` is `false` resolve without +network activity. +On `session.removed`, the facade retains already projected rows, sets +`removed` to `true`, sets `hasMore` to `false` for messages, and makes +subsequent `loadMore` and retry operations no-ops without network activity. +The `removed` terminal state is observable and stable; subsequent retries and +pagination calls resolve without network activity. +Each panel scope owns a monotonic `continuation_revision` over `{cursor,snapshot_token,cutoff,fingerprint,generation}` starting at sentinel `0` on scope creation. Query identity change, explicit `retry`, and close/invalidate increment the revision (close moves to an invalid tombstone that rejects all in-flight completions). Renewal and `loadMore` serialize dispatch per scope and CAS-install only when the current revision still equals the captured one; every response is checked against both the revision and the full tuple. Renewal may change only expiry, token rotation, and cursor re-encoding while preserving cutoff and fingerprint. Failed renewal or failed page leaves the committed page state unchanged; close or retry invalidates pending completions. +The HTTP facade returns the public page shapes above. The Host transport carries the subscribe ACK's `snapshot_token` in a Host-only request header; it remains valid through the signed cursor/query expiry (maximum 10 minutes) and rotates only on rebind. The server validates its plugin/session/generation binding and committed cutoff, then returns internal row-sequence sidecars. The Host strips those sidecars before this API resolves. Cursors reject tampering or a different session, task, filter, sort, generation, or expiry; reads are not caller-selectable as-of queries. +The Host renews a continuation before 80% of expiry through its private `POST /api/plugins/{id}/conversation/continuation/renew` operation. It accepts the current cursor and snapshot token, returns replacements with the same cutoff/fingerprint and a new expiry, and never selects a new cutoff. If retention or generation cannot preserve the cutoff, `loadMore` leaves existing state unchanged and reports retryable `upstream_failure`; explicit `retry` starts a fresh snapshot. + +Endpoint error matrix: `GET /conversation/binding` `409/generation_superseded/true` is Host-loader-only (bounded retry, never import until success, `Cache-Control: no-store` on success and every error). `POST /conversation/continuation/renew` `409` maps to public retryable `upstream_failure` without changing the loaded cutoff, also `Cache-Control: no-store`. Plugin-visible errors never carry `generation_superseded`. +Binding tokens expire no later than 10 minutes after issuance; the loader +rebinds before expiry and never sends an expired token to plugin code. +The Host establishes ordered readiness before the initial snapshot. The Host-only +request uses the discriminated `SubscribeRequest` branches below: core requires +`wire_id`; plugin requires `plugin_id`, `generation`, `binding_token`, and +`consumer_id`; identities are mutually exclusive. Success returns the matching +identity, while failure has no success-only fields. Legacy payloads are +accepted only with all ordered fields absent. + +#### Host-only conversation wire contract + +The Host-only snapshot header is `X-Kandev-Snapshot-Token`. HTTP page and +renewal shapes, plus ordered event payloads, are: + +```ts +type CoreSubscribeRequest = { + session_id: string; + consumer_kind: "core"; + last_seen_sequence?: number; + resume_token?: string; + wire_id: string; + plugin_id?: never; + generation?: never; + binding_token?: never; + consumer_id?: never; +}; +type PluginSubscribeRequest = { + session_id: string; + consumer_kind: "plugin"; + plugin_id: string; + generation: number; + binding_token: string; + last_seen_sequence?: number; + resume_token?: string; + consumer_id: string; + wire_id?: never; +}; +type SubscribeRequest = CoreSubscribeRequest | PluginSubscribeRequest; +type SessionWireFailureCode = + | "unauthorized" + | "invalid_binding" + | "generation_superseded" + | "invalid_request" + | "session_not_found" + | "cursor_expired" + | "forward_gap" + | "session_removed"; +type SessionWireError = { + code: SessionWireFailureCode; + message: string; + retryable: boolean; +}; +type SubscribeSuccessPayload = { + success: true; + session_id: string; + result: "fresh" | "replay" | "gap" | "invalid_resume"; + event_watermark: number; + snapshot_cutoff: number; + snapshot_token: string; + replay_from?: number; + replay_to?: number; + oldest_sequence?: number; + resume_token: string; +} & ( + | { wire_id: string; consumer_id?: never } + | { consumer_id: string; wire_id?: never } +); +type SubscribeFailurePayload = { + success: false; + session_id?: string; + error: SessionWireError; +}; +type SubscribeAck = { + id: string; + type: "response"; + action: "session.subscribe"; + payload: SubscribeSuccessPayload | SubscribeFailurePayload; +}; +type CoreSessionAckRequest = { + session_id: string; + consumer_kind: "core"; + sequence: number; + resume_token?: string; + wire_id: string; + plugin_id?: never; + generation?: never; + consumer_id?: never; +}; +type PluginSessionAckRequest = { + session_id: string; + consumer_kind: "plugin"; + plugin_id: string; + generation: number; + sequence: number; + resume_token?: string; + consumer_id: string; + wire_id?: never; +}; +type SessionAckRequest = CoreSessionAckRequest | PluginSessionAckRequest; +type SessionAckSuccessPayload = { + success: true; + session_id: string; + acknowledged_sequence: number; + resume_token: string; +} & ( + | { wire_id: string; consumer_id?: never } + | { consumer_id: string; wire_id?: never } +); +type SessionAckFailurePayload = { + success: false; + session_id?: string; + error: SessionWireError; +}; +type SessionAckResponse = { + id: string; + type: "response"; + action: "session.ack"; + payload: SessionAckSuccessPayload | SessionAckFailurePayload; +}; +type PluginConversationPage = { + messages: readonly PluginConversationMessage[]; + hasMore: boolean; + cursor: string | null; +}; +type PluginConversationTurnsPage = { + turns: readonly PluginConversationTurn[]; +}; +type ContinuationRenewRequest = { + cursor: string; + snapshot_token: string; +}; +type ContinuationRenewResponse = { + cursor: string; + snapshot_token: string; + cutoff: number; + fingerprint: string; + expires_at: string; +}; +type RawSessionEvent = { + type: "session.event"; + protocol_version: number; + event_type: string; + session_id: string; + task_id: string | null; + sequence: number; + event_id: string; + payload: unknown; +}; + +// Validation converts RawSessionEvent to this projected union only after +// protocol, identity, event-type, and payload checks succeed. +type SessionEventPayload = + | { event_type: "message.added"; message: PluginConversationMessage } + | { event_type: "message.updated"; message: PluginConversationMessage } + | { event_type: "message.deleted"; message_id: string } + | { event_type: "session.turn.started"; turn: PluginConversationTurn } + | { event_type: "session.turn.completed"; turn: PluginConversationTurn } + | { event_type: "session.removed" }; +type SessionEventBase = { + type: "session.event"; + protocol_version: 1; + session_id: string; + task_id: string | null; + sequence: number; + event_id: string; +}; +type SessionEvent = { + [T in SessionEventPayload["event_type"]]: SessionEventBase & { + event_type: T; + payload: Extract; + }; +}[SessionEventPayload["event_type"]]; +``` + +The Host sends `X-Kandev-Snapshot-Token` on snapshot reads and validates the +renewal request before exposing only the public page state. `cursor` is +nullable only on a page with no next page; renewal receives and returns the +current non-null cursor. The Host maps event payloads to public DTOs and +strips protocol sidecars before plugin code sees them. Removal is terminal +whether it precedes or follows poison; the replacement cursor is closed. +Only `message.added`, `message.updated`, `message.deleted`, +`session.turn.started`, `session.turn.completed`, and `session.removed` use +this migrated contract. +The raw envelope is decoded with `protocol_version: number` and arbitrary +`event_type`/`payload`; `SessionEvent` is the validated projected union. +Validation requires `payload.type === event_type`; a mismatch is durable +poison, is not projected, and cannot advance ACK. Strict subscribe ACKs require +`session_id`, `result`, `event_watermark`, `snapshot_cutoff`, `snapshot_token`, +and `resume_token`. `fresh` forbids replay and oldest-sequence fields; +`replay` requires `replay_from` and `replay_to` with +`replay_from <= replay_to <= snapshot_cutoff`. +For `replay`, `replay_from` is the first delivered sequence (inclusive) and +equals `last_seen_sequence + 1`; `replay_to` is the final delivered sequence +(inclusive) and equals the committed `snapshot_cutoff`. +`replay` is returned only when `last_seen_sequence` was supplied; otherwise +the initial result is `fresh`. +`gap` requires `oldest_sequence` and forbids replay fields; `invalid_resume` +forbids replay fields and reports the replacement cursor state. The +`resume_token` is always the current token for the returned consumer state. + +Subscribe and ACK failures use the same outer response envelope with +`success:false`, the requested `session_id` when known, and +`error:{code,message,retryable}`; no success-only ordered fields are present. +Codes distinguish `unauthorized`, `invalid_binding`, `generation_superseded`, +`invalid_request`, `session_not_found`, `cursor_expired`, `forward_gap`, and +`session_removed`. Binding/generation failures are non-projection failures; +`generation_superseded` is retryable only for the Host loader, while expired +or retained-cursor failures produce `gap` reconciliation rather than a +plugin-visible subscribe exception. ACK rejects forward gaps and malformed +identity/range/token input without advancing the durable cursor. +Core wire identity: the Host mints one opaque `wireId` per core consumer mount and carries it in subscribe/ACK state as `wire_id`. Plugin panels receive a separate Host-minted opaque `consumer_id`, carried in subscribe/ACK state and never caller-supplied. Broker state is keyed by `(session,consumer_kind,plugin,generation,wire_id|consumer_id)`; each applicable identity survives reconnect through its persisted entry, while a new mount mints a new identity at its registration watermark. Identities transition `registered -> ready -> streaming -> suspended (reconnect window) -> released` and release on unsubscribe/unmount or after the reconnect plus token-retention window expires. Plugin panels from one plugin/generation never share a cursor. +Reconnect grace is 10 minutes after disconnect. Resume and snapshot tokens +expire no later than 10 minutes after issuance; delivery cursors and immutable +event rows remain for the maximum token expiry plus the reconnect grace. +Garbage collection runs only after those bounds and preserves terminal +tombstones until all resumable consumer identities are released. +If `session.removed` precedes poison, the rebind still produces terminal +removal and a closed replacement cursor; no later event is admitted. This is +the same terminal outcome as poison followed by removal. +If a previously authorized consumer reconnects to `session_removed` or a +terminal replacement cursor, the facade preserves committed rows, sets +`removed:true`, sets message `hasMore:false`, and makes `loadMore` and `retry` +no-ops. A fresh unauthorized or nonexistent lookup remains ordinary +`404/not_found` and never reveals deletion state. +Ordered event envelopes carry `protocol_version`; canonical policy registry is `{1:{ignorable:["session.workspace_sources.updated"]}}`. Only a type listed by its matching version may advance ACK without projection. Missing, malformed, or unsupported versions and other unknown types remain durable poison: the cursor stays before the event, diagnostics retain its ID/version, and advancement stops. Rebind atomically snapshots past poison at a new cutoff, commits that cutoff, returns a replacement cursor/token, and does not project the poison. When `session.removed` arrives after a poison cursor, rebind snapshots past both to a terminal cutoff: the outcome is terminal removal, the replacement cursor is terminally closed and admits no post-terminal events, and poison diagnostics remain durable. +Poison operations are owned by `SessionDeliveryDispatcher`: each record moves +`pending -> leased -> exhausted -> requeued`, with a 30-second lease, five +attempts, and exponential backoff capped at 30 seconds. Exhaustion records +event ID, protocol version, and last error, and emits structured +`session_event_poison_total` diagnostics. The Host-only +`session.event.poison.requeue` command requires an authenticated operator with +`session_events:requeue`; it accepts session, event ID, and expected owner epoch, +audits actor, prior state, attempt count, and timestamp, increments owner epoch, +resets attempts, and returns `pending` without changing sequence or payload. +Repeated requests after requeue return current state without mutation; stale +expected epochs are rejected without mutation. Startup reclaims expired leases +but never clears or skips poison. +Legacy projection coalescing: pending contiguous sequence ranges project at most once per `event_id`/sequence per wire, then ACK the highest contiguous sequence. Example: `seq 10 = message.updated id A` followed by `seq 11 = message.updated id A` coalesces to one projection of the latest image `A@11`, then a single `session.ack` for `11`; a `session.removed` barrier after `11` is a separate terminal projection. Same-`event_id` replays and same-sequence redeliveries are dropped; any event with `sequence <= acknowledged_sequence` is suppressed without a second projection. +After contiguous delivery the Host-only `session.ack` uses the discriminated +`SessionAckRequest` branches below: core requires `wire_id`; plugin requires +`plugin_id`, `generation`, and `consumer_id`; identities are mutually exclusive. +Success returns the matching identity and failure has no success-only fields. +The shared client retains the full response envelope, validates outer +IDs/types/actions before payload parsing, and retries by consumer sequence/token. +The Host validates binding/generation, strict integer/range rules, applicable +identity, current or prior token on retry, and contiguous advancement. Snapshot +tokens and sidecars never reach plugin code. Reload, disable, uninstall, panel +switch, or unmount revokes generation. + +Registry ownership matrix: every `register*` call and Host-owned resource is keyed by `(pluginId,generation,stageId,kind,localId)` and staged invisibly until commit. `kind` is the registration method (`route`, `navItem`, `settingsRoute`, `component`, `wsHandler`, `keybinding`, `integrationSettings`, `repositoryProvider`, `reviewProvider`, `taskAction`, `taskPanel`, `taskMenuAction`, `taskFilter`, `taskListFacet`, `translations`) or Host-owned resource (`styleLink`, `modal`, `conversationScope`, `bindingGrant`). A duplicate `(kind,localId)` within the same stage rejects the second call; a call from a foreign or retired `(pluginId,generation,stageId)` is rejected without side effects; stage abort discards all its staged entries without notifications. Commit atomically publishes the stage's entries and revokes the retired generation's entries in one linearization step, emitting notifications only for the committed delta. + ## `registry: PluginRegistry` ```ts @@ -1155,20 +1549,33 @@ interface PluginComposerSlotProps { disabledReason?: string; composer: PluginComposerCapability; } +type PluginOpenMessageResult = { status: "accepted" | "unavailable" }; +interface PluginTaskPanelConversationCapability { + openMessage(messageId: string): PluginOpenMessageResult; + /** Host-created API bound to this panel's context and generation. */ + history: PluginConversationApi; +} -interface PluginTaskPanelProps { - panelId: string; // this registration's panel id, so one Component can back multiple panels +interface PluginTaskPanelContext { taskId: string; sessionId: string | null; + sessionKind: PluginSessionKind; presentation: PluginPresentation; } +interface PluginTaskPanelProps extends PluginTaskPanelContext { + panelId: string; // this registration's panel id, so one Component can back multiple panels + conversation: PluginTaskPanelConversationCapability; +} + interface TaskPanelRegistration { id: string; // plugin-local panel id (unique within the plugin, not globally) title: string; // add-panel-menu row label and dockview tab title + titleKey?: string; icon?: PluginIcon; Component: React.ComponentType; // wrapped in a PluginErrorBoundary mobileEnabled?: boolean; // include in the phone's grouped Panels picker. Default: false. + visible?(context: PluginTaskPanelContext): boolean; } interface PluginTaskMenuContext { diff --git a/docs/plans/pr-3588-conversation-recovery/plan.md b/docs/plans/pr-3588-conversation-recovery/plan.md new file mode 100644 index 0000000000..7b229b1113 --- /dev/null +++ b/docs/plans/pr-3588-conversation-recovery/plan.md @@ -0,0 +1,181 @@ +--- +created: 2026-09-14 +status: complete +requirements: + - REQ-PLUGINS-PROMPT-HISTORY-HOST-002 + - REQ-PLUGINS-PROMPT-HISTORY-HOST-005 +system_design: + - "../../specs/plugins/system-design/conversation-recovery.md" + - "../../specs/plugins/system-design/prompt-history-extraction-host.md" +legacy_specs: [] +--- + +# Implementation Plan: PR 3588 Conversation Recovery + +## Overview + +Repair the three recovery defects found in PR #3588 at commit +`792264a0736563d9597bda15cdda5ad97bd60f84`. +First correct server grants. Then repair core snapshot recovery. Finally repair +expired plugin continuations using the corrected grant behavior. + +The user requested this package on 2026-09-14 and has now authorized its +implementation, commit, push, and PR fixup. The implementation keeps the +package's bounded scope and records validation below. + +## Inputs and settled assumptions + +- [Existing requirements](../../specs/plugins/requirements/prompt-history-extraction-host.md) + remain authoritative. These are implementation defects, not new product requirements. +- [Recovery design](../../specs/plugins/system-design/conversation-recovery.md) supplements the + [existing Host design](../../specs/plugins/system-design/prompt-history-extraction-host.md). +- [Browser API ADR](../../decisions/2026-09-06-browser-plugin-conversation-facade.md) + remains proposed. This package does not change its status. +- [Original package](../prompt-history-plugin-host/plan.md) records the contributor's + implementation. Its earlier results do not prove these new regression cases. +- Static source traces establish the failure paths. No reproduction test ran + during review or planning. Each implementer must obtain RED evidence first. +- The plugin system owns the existing conversation contract. Its design includes + the core compatibility adapter; this repair does not transfer ownership. + +The separate request to justify durable replay remains unresolved: +[maintainer direction](https://github.com/kdlbs/kandev/issues/3567#issuecomment-5619730030). +It does not block planning bounded conformance fixes within the proposed transport. +It remains a merge-scope decision, not implicit approval of that transport. + +## Scope + +### In scope + +- Resume grants that accept each contiguous replay ACK. +- Core message and turn repair after cursor replacement while connected. +- Plugin continuation recovery after expiry, including suspended tabs. +- Focused TDD and visible regression evidence for these outcomes. +- Lifecycle cancellation, terminal removal, and strict authorization during recovery. + +### Out of scope + +- Replacing the journal, changing schemas or retention, or redesigning transport ownership. +- Public SDK changes, new error codes, token lifetime increases, or weaker token validation. +- CI remediation unrelated to these findings, other existing review comments, and the unfinished full-PR review. +- Plugin extraction, publication, core panel removal, and layout migration. +- New controls, copy, navigation, or layout. Existing loading and retry surfaces remain. + +## Technical approach + +### B1: grants + +`acceptOrderedSessionSubscription` currently passes `replay.watermark` to +`MintSessionStreamGrant`. That method uses it for both tokens. +`ValidateSessionResume` rejects a smaller ACK sequence. +A reconnect from 10 with replay 11 and 12 therefore rejects ACK 11. + +Separate snapshot cutoff from the registered cursor sequence. Determine the +replay disposition and effective cursor before minting grants. +Keep token identity and contiguous ACK validation strict. + +### B2: core snapshot recovery + +`recoverCoreSessionPoison` currently updates only stream sequence and token. +It does not trigger or await the core message and turn hydration owners. +An idle mounted Chat can retain stale rows after `invalid_resume`. + +Add an internal, session-scoped recovery completion path between the client and +the existing hydration owners. Pause projection for that session, refresh both +snapshots, commit the matching recovery generation, then resume buffered events. +Transport readiness and hydration completion are separate barriers. +Do not solve this with a synthetic connection-status change or a page reload. + +### B3: expired continuation + +`renewContinuation` refreshes the binding but preserves expired cursor and +snapshot tokens. The renewal route rejects them with `invalid_query`. +The public retry method correctly refuses non-retryable errors. + +Detect known expiry before renewal and start one fresh scoped subscription and +snapshot cycle. Reuse the existing rebind notification and page revision +mechanisms. Do not repeatedly retry the same expired request. +Keep valid pre-expiry renewal at its original cutoff. + +## Tests + +Names below are proposed new regression names, not claims of existing coverage. + +| Finding | Criteria | RED test and file | +| --- | --- | --- | +| B1 | AC-PLUGINS-PROMPT-HISTORY-HOST-002.5, AC-PLUGINS-PROMPT-HISTORY-HOST-002.13, AC-PLUGINS-PROMPT-HISTORY-HOST-002.15 | `TestOrderedReplayGrantAcceptsEveryContiguousAck` in `apps/backend/internal/gateway/websocket/client_session_ack_test.go` | +| B1 | AC-PLUGINS-PROMPT-HISTORY-HOST-002.7, AC-PLUGINS-PROMPT-HISTORY-HOST-002.15 | `TestOrderedReplayGrantRejectsInvalidAck` in the same file | +| B1 | AC-PLUGINS-PROMPT-HISTORY-HOST-002.5, AC-PLUGINS-PROMPT-HISTORY-HOST-002.15 | `keeps projecting after a multi-event reconnect replay` in `apps/web/lib/plugins/conversation-host.test.tsx` | +| B2 | AC-PLUGINS-PROMPT-HISTORY-HOST-002.5, AC-PLUGINS-PROMPT-HISTORY-HOST-002.6, AC-PLUGINS-PROMPT-HISTORY-HOST-002.9, AC-PLUGINS-PROMPT-HISTORY-HOST-002.15 | `rehydrates both core snapshots before resuming after invalid resume` in new `apps/web/hooks/domains/session/use-session-recovery.test.tsx` | +| B2 | AC-PLUGINS-PROMPT-HISTORY-HOST-002.6, AC-PLUGINS-PROMPT-HISTORY-HOST-002.15 | `buffers events and fences stale recovery completion` in `apps/web/lib/ws/client.test.ts` | +| B3 | AC-PLUGINS-PROMPT-HISTORY-HOST-002.2, AC-PLUGINS-PROMPT-HISTORY-HOST-002.6, AC-PLUGINS-PROMPT-HISTORY-HOST-002.10, AC-PLUGINS-PROMPT-HISTORY-HOST-002.13 | `recovers an expired continuation without remount` in `apps/web/lib/plugins/conversation-host.test.tsx` | +| B3 | AC-PLUGINS-PROMPT-HISTORY-HOST-002.2, AC-PLUGINS-PROMPT-HISTORY-HOST-002.6 | `joins expiry recovery and isolates panel queries` in `apps/web/lib/plugins/conversation-host-isolation.test.tsx` | +| B3 | AC-PLUGINS-PROMPT-HISTORY-HOST-002.7, AC-PLUGINS-PROMPT-HISTORY-HOST-002.10 | `TestConversationContinuationRenewRejectsExpiredTokens` in `apps/backend/internal/plugins/conversation_handlers_test.go` | + +## E2E tests + +B1 and B2 share a real transport scenario owned by Task 02. +Add `conversation-recovery.spec.ts` under `apps/web/e2e/tests/plugins/`. +Use the existing test-base backend, plugin fixture, and causal WS controls. +Reconnect after multiple durable mutations, then assert ACK success and a later +visible update. In a second case, force cursor replacement while the socket +stays connected and assert repaired core message and turn state. +Map these cases to AC-PLUGINS-PROMPT-HISTORY-HOST-002.5, AC-PLUGINS-PROMPT-HISTORY-HOST-002.9, AC-PLUGINS-PROMPT-HISTORY-HOST-002.15, and +AC-PLUGINS-PROMPT-HISTORY-HOST-005.3. + +Task 03 adds an expired-continuation case to that file. +Use an isolated test token manager clock if real expiry is necessary. +Any new clock control belongs only to the existing test harness. +A browser clock cannot expire server tokens by itself. +Do not shorten production TTL or wait ten wall-clock minutes. + +Mobile uses the same repaired hooks. This package changes state recovery only: +no layout, touch, scrolling, or navigation change is planned. +Existing `mobile-prompt-history-plugin.spec.ts` remains the mobile parity check. +No new mobile composition or ASCII UI preview is needed. + +## Work orders + +- [x] [Task 01: Correct replay grants](task-01-correct-replay-grants.md) +- [x] [Task 02: Restore core snapshots during recovery](task-02-restore-core-snapshots.md) +- [x] [Task 03: Recover expired plugin continuations](task-03-recover-expired-continuations.md) + +Execution order is 01 -> 02 -> 03. Shared transport, tests, and documentation +make sequential execution appropriate. This order does not authorize delegation. + +## Verification results + +RED evidence was obtained for all three defects. The original replay grant +rejected an intermediate ACK, connected core recovery advanced without +snapshot repair, and expired continuation state reached the renewal endpoint +and became non-retryable. + +GREEN evidence: + +- Backend package tests and race tests passed. +- The focused core recovery command passed 5 files and 130 tests. +- The expanded frontend recovery and Host command passed 8 files and 171 + tests. +- TypeScript typecheck and targeted ESLint passed. +- Backend build, E2E frontend build, and fixture packaging passed. The backend + build reported only existing unsigned Darwin artifact warnings. +- The combined Chromium recovery, packaged-plugin, and core prompt-history + run passed all 5 tests. +- The mobile prompt-history run passed its 1 test. +- Specification validation and specification lint passed. +- git diff --check passed. + +The checks are scoped to this repair and its affected integration paths. They +do not constitute a full backend audit, a complete security audit, or the full +PR review. The broader durable transport scope decision remains separate. + +## Risks + +- Grant signatures must cover every consumer branch and every internal call site. +- A hydration callback that awaits its own completion barrier will deadlock. +- Existing hydration helpers can resolve after failure or merge without removing + stale rows. Neither is sufficient evidence that recovery committed. +- Snapshot responses and buffered events can overlap. Older events must not + overwrite newer snapshot state, and deleted rows must not reappear. +- Expiry, removal, access revocation, and panel replacement can race. +- Real transport tests must use fresh binaries and bundles. diff --git a/docs/plans/pr-3588-conversation-recovery/task-01-correct-replay-grants.md b/docs/plans/pr-3588-conversation-recovery/task-01-correct-replay-grants.md new file mode 100644 index 0000000000..1ad73a5a02 --- /dev/null +++ b/docs/plans/pr-3588-conversation-recovery/task-01-correct-replay-grants.md @@ -0,0 +1,119 @@ +--- +id: "01-correct-replay-grants" +title: "Correct replay grants" +status: complete +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-PLUGINS-PROMPT-HISTORY-HOST-002 + - REQ-PLUGINS-PROMPT-HISTORY-HOST-005 +acceptance_criteria: + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.5 + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.7 + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.13 + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.15 + - AC-PLUGINS-PROMPT-HISTORY-HOST-005.3 +system_design: + - "../../specs/plugins/system-design/conversation-recovery.md" +--- + +# Task 01: Correct replay grants + +## Summary + +Make every valid contiguous replay acknowledgment succeed. +Preserve the distinct snapshot cutoff and all token authorization checks. + +## In scope + +- Separate cutoff and cursor inputs in the internal grant helper. +- Resolve replay claims and effective cursor before minting grants. +- Update all helper callers, including ACK renewal and tests. +- Add backend protocol tests and the plugin replay regression. + +## Out of scope + +Core hydration, continuation expiry, schema changes, and public SDK changes. + +## Acceptance + +- Resume from 10 with events 11 and 12. ACK 11, ACK 12, and live ACK 13 all succeed. +- Snapshot cutoff remains 12. Foreign, expired, tampered, and forward-gap ACKs fail without cursor movement. +- Fresh and replacement subscriptions grant their actual registered position and release claims on failure. + +## TDD sequence + +1. Add `TestOrderedReplayGrantAcceptsEveryContiguousAck` using the real signer, + accepted subscribe response, and `handleSessionAck`. Cover core and plugin consumers. +2. Obtain RED for the rejected intermediate ACK. Add invalid-ACK and fresh/replacement controls. +3. Correct the grant inputs and call sites. Preserve one-event and duplicate ACK behavior. +4. Add the plugin reconnect case in `conversation-host.test.tsx`; assert later live projection. + +## Files likely touched + +- `apps/backend/internal/gateway/websocket/client.go` +- `apps/backend/internal/gateway/websocket/client_session_ack_test.go` +- `apps/backend/internal/plugins/conversation_stream_service.go` +- `apps/backend/internal/plugins/conversation_handlers_test.go` +- `apps/backend/internal/plugins/conversation_tokens.go` only if helper plumbing requires it +- `apps/web/lib/plugins/conversation-host.test.tsx` +- Other internal grant callers found by `rg -n MintSessionStreamGrant apps/backend` +- `docs/plans/plugins/PLUGIN-API.md` for internal token position clarification + +## Verification + +From the repository root, after implementation/validation authorization: + +```bash +(cd apps/backend && go test -tags fts5 ./internal/gateway/websocket ./internal/plugins) +(cd apps/web && pnpm exec vitest run lib/plugins/conversation-host.test.tsx) +git diff --check +``` + +These package-scoped Go commands avoid an unrelated full backend audit. +Task 02 owns the shared real-transport browser proof. + +## Dependencies + +None. Read the whole recovery design before editing the shared grant API. + +## Risks + +Minting before poison disposition or using W for C can preserve the defect. +A mock that accepts any token cannot prove this repair. + + +## Inputs + +- [Plan and evidence](plan.md). +- [Recovery design](../../specs/plugins/system-design/conversation-recovery.md). +- [Existing requirement](../../specs/plugins/requirements/prompt-history-extraction-host.md). +- Source baseline: PR #3588, commit `792264a0736563d9597bda15cdda5ad97bd60f84`. +- Read the scoped AGENTS.md and TDD skill before implementation. + +## Parallelism + +`sequential`. Another agent can execute this work order after the user dispatches it. +Do not spawn agents from this work order. + +## Results + +RED evidence reproduced the defect: with a client acknowledged through +sequence 10 and replay events at 11 and 12, the original grant rejected the +first contiguous ACK at sequence 11 for both core and plugin consumers. + +The gateway now uses the snapshot watermark for the snapshot grant and the +actual replay cursor for the resume grant. Claim cleanup remains attached to +failed mint and registration paths. Invalid forward-gap, malformed, and +tampered ACKs still fail without moving the cursor. + +GREEN evidence: + +- go test -tags fts5 ./internal/gateway/websocket ./internal/plugins: passed. +- go test -race -tags fts5 ./internal/gateway/websocket ./internal/plugins: passed. +- pnpm exec vitest run lib/plugins/conversation-host.test.tsx lib/plugins/conversation-host-isolation.test.tsx: 2 files, 34 tests passed. +- git diff --check: passed. + +The checks are package-scoped. They do not constitute a full backend audit or +the full PR review. diff --git a/docs/plans/pr-3588-conversation-recovery/task-02-restore-core-snapshots.md b/docs/plans/pr-3588-conversation-recovery/task-02-restore-core-snapshots.md new file mode 100644 index 0000000000..97972ae1e7 --- /dev/null +++ b/docs/plans/pr-3588-conversation-recovery/task-02-restore-core-snapshots.md @@ -0,0 +1,140 @@ +--- +id: "02-restore-core-snapshots" +title: "Restore core snapshots during recovery" +status: complete +wave: 2 +depends_on: + - "01-correct-replay-grants" +plan: "plan.md" +requirements: + - REQ-PLUGINS-PROMPT-HISTORY-HOST-002 + - REQ-PLUGINS-PROMPT-HISTORY-HOST-005 +acceptance_criteria: + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.5 + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.6 + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.9 + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.15 + - AC-PLUGINS-PROMPT-HISTORY-HOST-005.3 +system_design: + - "../../specs/plugins/system-design/conversation-recovery.md" +--- + +# Task 02: Restore core snapshots during recovery + +## Summary + +Repair core Chat after cursor replacement without a socket reconnect. +A successful recovery includes authoritative message and turn reconciliation. + +## In scope + +- Internal session recovery generation, completion notification, and projection barrier. +- Integration with existing core message and turn hydration. +- Skipped deletion repair, rich fields, active-turn state, retry, and lifecycle fencing. +- Shared B1/B2 real-transport browser regression. + +## Out of scope + +Public plugin API, plugin page cache ownership, new persistence, and page reload workarounds. + +## Acceptance + +- An idle mounted Chat receives a changed message and completed turn after invalid resume while the socket remains connected. +- Delayed or failed snapshots cannot release projection or restore removed rows. Newer live changes survive the snapshot handoff. +- Session removal, owner disposal, or a newer recovery generation prevents stale writes and releases abandoned work. + +## TDD sequence + +1. Add the store-and-hook regression in new `use-session-recovery.test.tsx`. + Seed an idle session, stale message and turn, and a skipped valid change. + Keep the socket connected and assert both stores repair. +2. Add tests for skipped deletion, buffered update, failed snapshot, repeated recovery, + old-generation completion, and a subscribed session without a mounted owner. +3. Connect the transport to existing hydration owners with awaitable completion. + Use explicit read success. Do not infer success from a resolved best-effort helper. +4. Add the real-transport cases in new `conversation-recovery.spec.ts`. + Reuse the isolated backend and causal controls from the existing plugin specs. + Any poison injection belongs to test helpers or the guarded test harness only. + +## Files likely touched + +- `apps/web/lib/ws/client.ts` and `client.test.ts` +- `apps/web/lib/ws/ordered-session-events.ts` for internal recovery state +- New focused internal recovery module under `apps/web/lib/ws/`, if needed +- `apps/web/hooks/domains/session/use-session-messages.ts` and its tests +- `apps/web/hooks/domains/session/use-session-turns-hydration.ts` and its tests +- New `apps/web/hooks/domains/session/use-session-recovery.test.tsx` +- `apps/web/lib/state/slices/session/turn-actions.ts` only for authoritative recovery semantics +- New `apps/web/e2e/tests/plugins/conversation-recovery.spec.ts` +- `apps/web/e2e/helpers/ws-drop.ts`, `ws-traffic.ts`, or `causal-waits.ts` only for causal fixtures +- `apps/backend/internal/office/testharness/routes.go` only if a guarded poison fixture is required + +## Verification + +From the repository root, after implementation/validation authorization: + +```bash +(cd apps/web && pnpm exec vitest run lib/ws/client.test.ts hooks/domains/session/use-session-recovery.test.tsx hooks/domains/session/use-session-messages.test.ts hooks/domains/session/use-session-turns-hydration.test.ts lib/state/slices/session/turn-actions.test.ts) +(cd apps/web && pnpm run typecheck) +make -C apps/backend build +(cd apps/web && pnpm run build:e2e) +make -C apps/backend e2e-plugin-package +(cd apps/web && pnpm e2e:raw --project=chromium e2e/tests/plugins/conversation-recovery.spec.ts) +git diff --check +``` + +Packaging rebuilds the fixture UI. Do not bypass artifact freshness or overlap E2E suites. +Add the exact focused test command for any additional helper suite changed. + +## Dependencies + +Task 01 provides correct grants. Later tests must not work around its defect. + +## Risks + +Do not deadlock a loader on the barrier it must complete. +Existing latest-window merging and best-effort turn hydration need explicit recovery handling. +The current rich core API must remain distinct from sanitized plugin reads. + + +## Inputs + +- [Plan and evidence](plan.md). +- [Recovery design](../../specs/plugins/system-design/conversation-recovery.md). +- [Existing requirement](../../specs/plugins/requirements/prompt-history-extraction-host.md). +- Source baseline: PR #3588, commit `792264a0736563d9597bda15cdda5ad97bd60f84`. +- Read the scoped AGENTS.md and TDD skill before implementation. + +## Parallelism + +`sequential`. Another agent can execute this work order after the user dispatches it. +Do not spawn agents from this work order. + +## Results + +RED evidence reproduced the connected recovery gap: replacement advanced the +core session cursor without notifying the message and turn owners, so a valid +change skipped by an invalid resume remained stale while the socket stayed +connected. + +The client now pauses projection by recovery generation, keeps buffered events +behind the hydration barrier, and resumes only after authoritative messages +and turns succeed. Core message windows retain pending local rows while +authoritative snapshots remove stale rows. Turn replacement preserves newer +live rows and reconciles the active-turn marker. Failed, stale, disposed, and +terminal generations remain paused or are discarded. + +GREEN evidence: + +- The focused recovery command passed: 5 files, 130 tests. +- The expanded recovery and Host command passed: 8 files, 171 tests. +- pnpm run typecheck: passed. +- make -C apps/backend build: passed. The build reported only existing + unsigned Darwin artifact warnings. +- pnpm run build:e2e: passed. +- make -C apps/backend e2e-plugin-package: passed. +- The recovery Chromium E2E passed both recovery cases. +- git diff --check: passed. + +The package does not change the public SDK or transport ownership decision. +The checks do not constitute a full backend audit or the full PR review. diff --git a/docs/plans/pr-3588-conversation-recovery/task-03-recover-expired-continuations.md b/docs/plans/pr-3588-conversation-recovery/task-03-recover-expired-continuations.md new file mode 100644 index 0000000000..e7a1f3bea3 --- /dev/null +++ b/docs/plans/pr-3588-conversation-recovery/task-03-recover-expired-continuations.md @@ -0,0 +1,154 @@ +--- +id: "03-recover-expired-continuations" +title: "Recover expired plugin continuations" +status: complete +wave: 3 +depends_on: + - "02-restore-core-snapshots" +plan: "plan.md" +requirements: + - REQ-PLUGINS-PROMPT-HISTORY-HOST-002 + - REQ-PLUGINS-PROMPT-HISTORY-HOST-005 +acceptance_criteria: + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.2 + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.5 + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.6 + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.7 + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.10 + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.13 + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.15 + - AC-PLUGINS-PROMPT-HISTORY-HOST-005.3 +system_design: + - "../../specs/plugins/system-design/conversation-recovery.md" +--- + +# Task 03: Recover expired plugin continuations + +## Summary + +Let an idle plugin panel resume paging and live updates without remount. +Keep expired grants invalid and preserve the existing public error contract. + +## In scope + +- On-demand expiry detection and one shared rebind cycle per scope. +- Atomic cursor/snapshot replacement and stale continuation fencing. +- A bounded continuation retry after fresh hydration. +- Idle resume-token rejection through the same recovery mechanism. +- Expiry, removal, access, and concurrent-query regression cases. + +## Out of scope + +Token lifetime changes, broad HTTP-400 retry, new SDK methods, automatic polling, +new UI controls, and core transport redesign. + +## Acceptance + +- After more than ten idle minutes, loadMore recovers and returns newly projected IDs without remount. A later live event still applies. +- Concurrent callers share recovery. Distinct panels and queries retain isolation. + Old responses cannot overwrite new state. Exhausted and terminal handles do no further reads. +- Invalid queries and revoked access remain non-retryable. Transient recovery errors preserve rows and permit a fresh explicit retry. + +## TDD sequence + +1. Add a scope/hook regression that advances beyond the issued token expiry. + The mock must reject expired tuples rather than returning success for every request. +2. Add real token-manager rejection in `TestConversationContinuationRenewRejectsExpiredTokens`. + Use the existing injectable clock. Keep pre-expiry renewal coverage. +3. Test concurrent callers, stale in-flight page/renewal, two queries, removal, + disable/unmount, access revocation, and one live event after idle expiry. +4. Implement one generation-bound recovery through existing rebind listeners. + Preserve the original load-more intent and count semantics from the design. +5. Add the browser expiry case to `conversation-recovery.spec.ts`. + Use causal evidence and a guarded test clock if server expiry is required. + Run the existing desktop/mobile plugin and core panel parity checks. + +## Files likely touched + +- `apps/web/lib/plugins/conversation-scope.tsx` +- `apps/web/lib/plugins/conversation-host.tsx` +- `apps/web/lib/plugins/conversation-host.test.tsx` +- `apps/web/lib/plugins/conversation-host-isolation.test.tsx` +- `apps/backend/internal/plugins/conversation_handlers_test.go` +- `apps/backend/internal/plugins/conversation_tokens.go` only for a test clock seam if necessary +- `apps/web/e2e/tests/plugins/conversation-recovery.spec.ts` from Task 02 +- Existing guarded E2E helpers only if needed for token timing +- `docs/plans/plugins/PLUGIN-API.md` for Host-only expiry recovery clarification + +## Verification + +From the repository root, after implementation/validation authorization: + +```bash +(cd apps/backend && go test -tags fts5 ./internal/plugins ./internal/gateway/websocket) +(cd apps/web && pnpm exec vitest run lib/plugins/conversation-host.test.tsx lib/plugins/conversation-host-isolation.test.tsx) +(cd apps/web && pnpm run typecheck) +make -C apps/backend build +(cd apps/web && pnpm run build:e2e) +make -C apps/backend e2e-plugin-package +(cd apps/web && pnpm e2e:raw --project=chromium e2e/tests/plugins/conversation-recovery.spec.ts e2e/tests/plugins/prompt-history-plugin.spec.ts e2e/tests/task/prompt-history-panel.spec.ts) +(cd apps/web && pnpm e2e:raw --project=mobile-chrome e2e/tests/plugins/mobile-prompt-history-plugin.spec.ts) +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +Record existing unrelated CI or specification failures separately. +Do not remediate them under this work order without authorization. + +## Dependencies + +Task 02 creates the shared browser regression file and completes earlier shared +transport work. Task 01 supplies correct rebind grants. + +## Risks + +A resolved hydration promise is not necessarily a committed snapshot. +Browser and server clocks differ. Never broaden authorization to accommodate that difference. +A failed recovery must not repeatedly resubmit an expired cursor. + + +## Inputs + +- [Plan and evidence](plan.md). +- [Recovery design](../../specs/plugins/system-design/conversation-recovery.md). +- [Existing requirement](../../specs/plugins/requirements/prompt-history-extraction-host.md). +- Source baseline: PR #3588, commit `792264a0736563d9597bda15cdda5ad97bd60f84`. +- Read the scoped AGENTS.md and TDD skill before implementation. + +## Parallelism + +`sequential`. Another agent can execute this work order after the user dispatches it. +Do not spawn agents from this work order. + +## Results + +RED evidence reproduced the expiry defect: an expired cursor and snapshot +token reached the continuation renewal endpoint, which correctly returned +non-retryable invalid_query and left loadMore unrecoverable. + +The scope now detects expiry before renewal and performs one shared fresh +binding and subscription cycle. It fences old page and renewal responses, +notifies both message and turn readers, and continues the original paging +intent with the new cursor. Invalid queries and access failures remain +non-retryable. Terminal session removal retains committed rows and stops +later reads. + +GREEN evidence: + +- go test -tags fts5 ./internal/plugins ./internal/gateway/websocket: passed. +- pnpm exec vitest run lib/plugins/conversation-host.test.tsx lib/plugins/conversation-host-isolation.test.tsx: 2 files, 34 tests passed. +- pnpm run typecheck: passed. +- make -C apps/backend build: passed. The build reported only existing + unsigned Darwin artifact warnings. +- pnpm run build:e2e: passed. +- make -C apps/backend e2e-plugin-package: passed. +- The combined Chromium E2E command passed all 5 tests: recovery, packaged + plugin parity, and core prompt-history panel flows. +- The mobile-chrome prompt-history E2E passed its 1 test. +- python3 scripts/list-docs.py validate: passed. +- python3 scripts/lint-spec-files.py --all: passed. +- git diff --check: passed. + +The checks do not constitute a full backend audit or the full PR review. The +broader durable transport scope decision remains separate. diff --git a/docs/plans/prompt-history-plugin-host/plan.md b/docs/plans/prompt-history-plugin-host/plan.md new file mode 100644 index 0000000000..7e311c02f7 --- /dev/null +++ b/docs/plans/prompt-history-plugin-host/plan.md @@ -0,0 +1,135 @@ +--- +created: 2026-09-06 +status: done +requirements: + - REQ-PLUGINS-PROMPT-HISTORY-HOST-001 + - REQ-PLUGINS-PROMPT-HISTORY-HOST-002 + - REQ-PLUGINS-PROMPT-HISTORY-HOST-003 + - REQ-PLUGINS-PROMPT-HISTORY-HOST-004 + - REQ-PLUGINS-PROMPT-HISTORY-HOST-005 +system_design: + - "../../specs/plugins/system-design/prompt-history-extraction-host.md" +legacy_specs: [] +--- + +# Implementation Plan: Prompt History Plugin Host Prerequisites + +## Objective + +Land the complete public browser Host boundary required by a future external prompt-history plugin while keeping the shipped core panel unchanged. The package adds capability-gated session reads, typed live reconciliation, scoped native navigation, host-owned prompt display dependencies, and the missing generic task-panel context. + +This plan does not implement or publish the external plugin and does not remove or migrate the core panel. + +## Contract decisions + +- Browser plugins consume `host.conversation` from `@kandev/plugin-sdk`; they do not import `apps/web`, inspect `host.store`, call `/api/v1` directly, or register raw session message handlers. +- Authenticated `/api/plugins/{id}/conversation/...` routes reuse task services, enforce `api_read:messages`, and return narrow sanitized DTOs. Existing first-party REST and Go Host RPCs stay compatible. +- Conversation hooks are resolved through a Host-created panel scope carrying panel/task/session/generation; IDs that differ from the active scope are rejected. They own snapshot/subscription ordering, pagination, reconnect reconciliation, and plugin-generation cancellation. The future plugin owns prompt filtering/derivation presentation only through returned DTOs. +- Task-panel registration grows optional localized-title and visibility contracts. Task-panel props grow session kind and a generation-bound message-navigation capability. +- Custom-prompt alias rendering and favorite state remain host-owned because they depend on private settings and browser stores. The API is narrow: one curated renderer and one read-only hook. +- Core prompt history remains present until a later package proves the external plugin, migrates saved panel IDs, and removes core ownership. + +## Work packages + +### Wave 1: Contract + +- [Task 01: Publish browser conversation contract](task-01-publish-browser-conversation-contract.md) + +### Wave 2: Independent host foundations + +- [Task 02: Add plugin conversation read routes](task-02-add-plugin-conversation-reads.md) (depends on Task 01) +- [Task 03: Extend generic task panel capabilities](task-03-extend-task-panel-capabilities.md) (depends on Task 01) + +### Wave 3: Frontend facade + +- [Task 04: Build browser conversation Host facade](task-04-build-conversation-host-facade.md) (depends on Tasks 01-03) + +### Wave 4: Parity proof + +- [Task 05: Prove prompt-history plugin parity](task-05-prove-plugin-parity.md) (depends on Task 04) + +### Wave 5: Documentation and package verification + +- [Task 06: Document and verify Host prerequisites](task-06-document-and-verify.md) (depends on Tasks 02-05) + +### Acceptance ownership + +Each acceptance criterion has one primary work-order owner. Where a criterion +contains separate transport and facade sub-criteria, the plan records explicit +sub-criterion ownership; work orders may verify a behavior owned elsewhere but +must not silently assume it. + +| Criteria | Primary owner | +| --- | --- | +| AC-002.1 public shapes and error mapping | Task 01 | +| AC-002.1 scope revision, CAS, and atomic page lifecycle | Task 04 | +| AC-002.2-3 | Task 01 | +| AC-001.1-7, AC-003.1-4 | Task 03 | +| AC-004.1-3 | Task 04 | +| AC-002.4, AC-002.7-8, AC-002.10, AC-002.13 | Task 02 | +| AC-002.15-16 transport, persistence, and compatibility adapter | Task 02 | +| AC-002.11, AC-005.2-3 | Task 05 | +| AC-005.1 | Task 01 | +| AC-005.4 | Task 06 | + +Tasks 02 and 03 own disjoint backend and frontend trees and are parallel-safe. Execution remains sequential unless the user explicitly authorizes subagents. + +## Mobile design contract + +- Desktop entry remains the task workbench's add-panel menu and dockview panel. +- Mobile entry remains the grouped Panels bottom-navigation action and existing inset picker; the panel renders as the existing full-height plugin task panel. +- The prompt list owns one vertical scroll area. Row navigation is the primary tap; expansion is a distinct 44 px touch action. Alias preview uses the existing coarse-pointer drawer. +- Shared hooks, pagination, derived prompt entries, and navigation outcomes are identical across presentations. Mobile owns only the picker, full-height composition, touch geometry, and local chat scroll target. +- Pixel 5 Playwright must open the fixture plugin panel, traverse older pages, open an alias preview, navigate to a prompt in Chat, and assert no document horizontal overflow. + +## TDD and verification strategy + +Every implementation work order follows RED-GREEN-REFACTOR: add the focused behavioral failure first, implement the smallest contract, then refactor without broadening scope. + +Verification is staged rather than deferred to a single repository-wide run: + +1. contract assignability and SDK typecheck; +2. focused backend route/service tests; +3. focused frontend Host, hook, task-panel, registry, and mobile component tests; +4. desktop and mobile fixture-plugin Playwright after fresh web/backend builds; +5. i18n, public-doc, frontend typecheck/lint, backend lint/test, and affected build commands. + +The core prompt-history unit and E2E suites remain part of the final regression proof because this package must not change shipped ownership or behavior. + +## Risks and controls + +- **Snapshot/event gap:** subscribe and await readiness before the first snapshot; buffer and merge by stable ID and `updatedAt`. +- **Stale session or plugin generation:** generation-key every request, subscription, and navigation capability; abort and revoke on identity/lifecycle change. +- **System prompt leakage:** map a dedicated DTO and test stripped content plus omitted `raw_content`/metadata. +- **Capability bypass inside supported APIs:** gate plugin-scoped routes server-side. Document that same-origin native plugins are not a hard sandbox. +- **Mobile navigation ownership:** inject the existing local mobile adapter; do not globalize `mobileScrollTarget`. +- **Public/internal type drift:** update `@kandev/plugin-sdk`, `PLUGIN-API.md`, internal aliases, host implementation, and assignability tests in the same contract package. +- **Accidental extraction:** keep core registrations, layout ID, stores, locale keys, and tests untouched; fixture contributions use a distinct plugin ID. + +## Completion boundary + +This package is complete when all six work orders are done and a fixture plugin proves the public boundary on desktop and mobile. External plugin creation, publication, core removal, and saved-layout migration remain blocked on a later explicitly approved design and implementation package. + +## Current result + +### Recovery follow-up, 2026-09-14 + +The [PR #3588 recovery package](../pr-3588-conversation-recovery/plan.md) +tracks three newly identified defects: replay grants, core snapshot repair, +and expired continuations. Its work orders remain pending. +The completion statements and results below describe the original delivery; +they do not prove these recovery cases. Tasks 02, 04, and 05 link to the +new repair work and its exact verification matrix. The full PR review and +durable transport scope decision remain separate. + +### Original delivery record + +Delivered the additive SDK, authenticated browser reads, task-panel context and +navigation, browser facade, host-owned display adapters, ordered transport, +durable primary-database journal/versioning, atomic poison rebind, lifecycle +fencing, and desktop/mobile fixture proof while preserving core Prompt History +ownership. Retention now runs from the live service maintenance lifecycle. + +All six work orders are complete. External plugin creation, publication, core +removal, and saved-layout migration remain blocked on a later explicitly +approved design and implementation package. diff --git a/docs/plans/prompt-history-plugin-host/task-01-publish-browser-conversation-contract.md b/docs/plans/prompt-history-plugin-host/task-01-publish-browser-conversation-contract.md new file mode 100644 index 0000000000..767c278566 --- /dev/null +++ b/docs/plans/prompt-history-plugin-host/task-01-publish-browser-conversation-contract.md @@ -0,0 +1,70 @@ +--- +id: "01-publish-browser-conversation-contract" +title: "Publish browser conversation contract" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-PLUGINS-PROMPT-HISTORY-HOST-001 + - REQ-PLUGINS-PROMPT-HISTORY-HOST-002 + - REQ-PLUGINS-PROMPT-HISTORY-HOST-005 +acceptance_criteria: + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.1 + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.2 + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.3 + - AC-PLUGINS-PROMPT-HISTORY-HOST-005.1 +system_design: + - "../../specs/plugins/system-design/prompt-history-extraction-host.md" +--- + +# Task 01: Publish Browser Conversation Contract + +## Scope + +Publish the additive, runtime-free TypeScript contract before implementing either side. Define conversation DTOs, the `PluginConversationError` shape with stable codes and `retryable`, hook state types, task-panel context, visibility/title/navigation/favorite/mention/lifecycle contracts, and canonical documentation. This task publishes shapes only; runtime behavior belongs to later work orders. + +## Acceptance + +- `apps/packages/plugin-sdk/src/index.ts` contains the runtime-free public contract from the system design with no Kandev, Zustand, or React runtime dependency. The single declaration uses `PluginConversationMessage` (nullable `taskId`, `turnId?`, `type`, `content`, optional `promptIndex`), `PluginConversationTurn` (nullable `taskId`, optional `completedAt`), `PluginSessionMessagesQuery` (nullable session, tri-state `taskId`, `authorTypes`, `pageSize`), separate message/turn state types, `loadMore(): Promise`, and the stable `PluginConversationError` code union with required `retryable`; the first three codes are `false`, authorized upstream 5xx is `true`, and binding-only `generation_superseded` never reaches plugin code. +- `docs/plans/plugins/PLUGIN-API.md` updates both existing task-panel listings and adds that exact conversation DTO/hook/error/lifecycle contract, `HostReact.useLayoutEffect`, and `host.ui.PromptMentionText`, including the exact error envelope and retryability mapping. +- `apps/web/lib/plugins/types.ts` aliases/refines the SDK types rather than redeclaring a second shape; `PluginUIShape` and `HostReact` include the same UI/component additions. +- SDK/internal-alias assignability tests cover the published shapes, UI additions, and existing registration compatibility; concrete Host implementations and runtime behavior are owned by Tasks 02-04. +- Message and turn state types include the observable terminal `removed` flag; the contract does not expose Host-only cursors, snapshot tokens, sidecars, or event payloads. +- The contract records that the loader serializes per-plugin stages, routes global registration through a private `(pluginId,generation,stageId)` context, rejects late callbacks after staging closes, and atomically swaps registry contributions only at successor commit. + +## TDD sequence + +1. RED: add type/assignability fixtures for the new contract and existing-registration compatibility. +2. GREEN: add only the SDK, internal aliases, and canonical contract documentation. +3. REFACTOR: remove duplicated type declarations and keep the runtime-free package dependency-free. + +## Likely files + +- `apps/packages/plugin-sdk/src/index.ts` +- `apps/web/lib/plugins/types.ts` +- `apps/web/lib/plugins/*.test.ts` +- `docs/plans/plugins/PLUGIN-API.md` +- `apps/web/lib/plugins/host-api.test.ts` or the focused contract-consistency test + +## Verification + +```bash +cd apps/web && pnpm run typecheck +cd apps && pnpm --filter @kandev/web test -- --run lib/plugins +``` + +Do not implement routes, hooks, panel plumbing, or fixture UI in this task. + +## Result + +Published the runtime-free conversation, panel-context, navigation, favorite, +mention-rendering, and lifecycle types in `@kandev/plugin-sdk`; internal web +types alias that declaration. Assignability coverage preserves existing +registrations and verifies the new Host surface. + +Verified: + +- `cd apps/web && pnpm run typecheck` +- `cd apps && pnpm --filter @kandev/web test -- --run lib/plugins` (28 files, + 210 tests) diff --git a/docs/plans/prompt-history-plugin-host/task-02-add-plugin-conversation-reads.md b/docs/plans/prompt-history-plugin-host/task-02-add-plugin-conversation-reads.md new file mode 100644 index 0000000000..9b66ca2fae --- /dev/null +++ b/docs/plans/prompt-history-plugin-host/task-02-add-plugin-conversation-reads.md @@ -0,0 +1,161 @@ +--- +id: "02-add-plugin-conversation-reads" +title: "Add plugin conversation read routes" +status: done +wave: 2 +depends_on: + - "01-publish-browser-conversation-contract" +plan: "plan.md" +requirements: + - REQ-PLUGINS-PROMPT-HISTORY-HOST-002 +acceptance_criteria: + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.1 + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.2 + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.4 + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.7 + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.8 + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.10 + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.13 + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.15 + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.16 +system_design: + - "../../specs/plugins/system-design/prompt-history-extraction-host.md" +--- + +# Task 02: Add Plugin Conversation Read Routes + +## Scope + +Add the authenticated backend and Host-only transport prerequisites for browser +conversation reads. Reuse task services and repositories, return only the +sanitized SDK DTOs, enforce plugin capability and session access, and provide +snapshot, keyset pagination, binding, continuation renewal, and ordered event +transport. This task does not implement React hooks, task-panel UI, or the +external prompt-history plugin. + +## Acceptance + +- `GET /api/plugins/{pluginId}/conversation/task-sessions/{sessionId}/messages` + and `GET /api/plugins/{pluginId}/conversation/task-sessions/{sessionId}/turns` + require an active plugin, `api_read:messages`, the current user, and access + to the requested session. Unknown, inactive, missing-capability, + malformed-query, inaccessible-session, and invalid-cursor cases return the + documented typed JSON error envelope. +- Query decoding preserves tri-state task selection: omitted `task_id` inherits + the resolved panel task only at the Host facade, explicit `task_id=null` + selects all tasks in the session, and an explicit string must match the panel + task. The route never broadens an inherited or explicit task query and never + accepts a task from another session. +- Message rows map to the public sanitized shape, including nullable task and + turn IDs, stripped content, author/type, timestamps, absolute prompt index, + and only the approved sender task ID. Raw content, system blocks, arbitrary + metadata, credentials, and store objects are omitted. Every returned DTO has + a non-empty `updatedAt`. +- Pages use deterministic `(created_at,id)` keyset ordering, bounded page size, + opaque session/query/generation-bound cursors, and stable prompt indexes. + Cursor tampering, expiry, query mismatch, generation mismatch, and session + mismatch fail closed. Reads do not initialize or mutate the chat transcript + cache. +- Binding and renewal are Host-loader-only operations. Binding responses and + every error use `Cache-Control: no-store`; a binding `409` maps to bounded + loader retry and never reaches plugin code. Continuation renewal preserves + cutoff and fingerprint, rotates expiry/token encoding only, and maps an + authorized `409` to retryable `upstream_failure` without changing committed + page state. Binding tokens expire no later than 10 minutes after issuance. +- The shared ordered session stream uses Host-only messages and the production + gateway/client path. Plugin panels carry a Host-minted opaque `consumer_id` + and core consumers carry a distinct Host-minted `wire_id`; neither identity + is caller-selectable or shared. Consumer binding includes plugin, generation, + session, and the applicable identity, and reconnect preserves the same + consumer cursor within the bounded window. +- The raw stream envelope preserves numeric protocol version, arbitrary event + type, session/task identity, sequence, event ID, and unknown payload. The + compatibility adapter validates the versioned registry, requires outer and + payload event types to match, and projects only the migrated vocabulary: + `message.added`, `message.updated`, `message.deleted`, + `session.turn.started`, `session.turn.completed`, and `session.removed`. + Unknown or malformed events remain durable poison and never advance ACK. +- Subscribe and ACK responses use the strict response envelope and explicit + success/failure discriminated payloads. A success returns the ordered + watermark, committed snapshot cutoff, snapshot token, result-specific replay + fields, current resume token, and applicable identity. A failure returns only + the known session and `{code,message,retryable}`. ACK rejects forward gaps, + malformed identity/range/token input, and stale generations without advancing + the durable cursor; only the highest contiguous sequence is acknowledged. +- Event append, version rows, deletion tombstones, and sequence allocation share + one locked transaction. `SessionEventLog` stores immutable rows and + `SessionDeliveryCursor` stores ACK, owner epoch, lease/retry, and generation + state. `SessionDeliveryDispatcher` owns poison records with states + `pending -> leased -> exhausted -> requeued`, a 30-second lease, five + attempts, and exponential backoff capped at 30 seconds. The Host-only + `session.event.poison.requeue` command requires `session_events:requeue`, + accepts session, event ID, and expected owner epoch, audits actor, prior + state, attempts, and timestamp, increments owner epoch, resets attempts, and + returns `pending` without changing sequence or payload. Requeue is idempotent; + stale epochs fail without mutation. Startup reclaims expired leases and never + drops or silently skips poison. Retention preserves cursors, event rows, and + terminal tombstones through the 10-minute token expiry and 10-minute + reconnect grace. +- `session.removed` is a terminal barrier in both orderings relative to poison. + A live removal and a reconnect that returns `session_removed` or a terminal + replacement cursor expose the same terminal state to the facade: committed + rows remain, `removed` is true, message `hasMore` is false, and later reads + and retries do not issue network requests. A fresh not-found lookup does not + reveal prior existence. + +## TDD sequence + +1. RED: add focused route, cursor, binding, transaction, stream-envelope, ACK, + poison, retention, and terminal-removal failures. +2. GREEN: implement the smallest service, route, compatibility-adapter, and + durable-delivery changes that satisfy the contract. +3. REFACTOR: keep HTTP DTOs separate from private repository rows and keep the + Host-only stream types out of the plugin SDK. + +## Likely files + +- `apps/backend/internal/agentctl/` or the existing plugin HTTP route package +- `apps/backend/internal/plugins/` +- `apps/backend/internal/office/` and session repositories +- `apps/backend/internal/websocket/` or the shared ordered event gateway +- `apps/backend/proto/` +- `apps/packages/plugin-sdk/` only if the published type contract needs alignment +- Focused backend route, repository, stream, and compatibility tests + +## Verification + +Recovery follow-up: [Task 01](../pr-3588-conversation-recovery/task-01-correct-replay-grants.md) +owns replay grant repair. [Task 03](../pr-3588-conversation-recovery/task-03-recover-expired-continuations.md) +owns expiry rejection coverage. Original results do not cover those pending regressions. + +```bash +cd apps/backend && go test ./internal/plugins/... ./internal/office/... +cd apps/backend && go test ./... +``` + +Do not implement browser hooks, task-panel plumbing, fixture UI, or core prompt +history extraction in this task. + +## Current result + +Added authenticated, capability-gated message and turn reads with sanitized +DTOs, signed binding/snapshot/cursor/resume tokens, deterministic repository +pagination, and typed error envelopes. The durable primary SQLite journal now +records source mutations, immutable versions/tombstones, and event sequences in +the same transaction, with startup backfill and idempotent sidecar sync. +Ordered Host subscriptions use strict identity, atomic registration, poison +delivery state, operator-only requeue, startup lease recovery, terminal +deletion fencing, atomic replacement-cursor rebind, and live retention +maintenance. + +Verified: + +- Focused journal, plugin, and WebSocket backend tests +- Primary journal retention test, backend `golangci-lint run ./...` (0 issues) +- `make build` + +The bounded repository-wide test run was attempted with +`go test -p 2 -tags fts5 ./...`; it reached unrelated pre-existing +environment-sensitive failures in agentctl config, update-channel, and invalid +metadata tests, plus temporary disk-quota linker failures. The changed +packages passed their focused tests. diff --git a/docs/plans/prompt-history-plugin-host/task-03-extend-task-panel-capabilities.md b/docs/plans/prompt-history-plugin-host/task-03-extend-task-panel-capabilities.md new file mode 100644 index 0000000000..4f94b2fec0 --- /dev/null +++ b/docs/plans/prompt-history-plugin-host/task-03-extend-task-panel-capabilities.md @@ -0,0 +1,87 @@ +--- +id: "03-extend-task-panel-capabilities" +title: "Extend generic task panel capabilities" +status: done +wave: 2 +depends_on: + - "01-publish-browser-conversation-contract" +plan: "plan.md" +requirements: + - REQ-PLUGINS-PROMPT-HISTORY-HOST-001 + - REQ-PLUGINS-PROMPT-HISTORY-HOST-003 +acceptance_criteria: + - AC-PLUGINS-PROMPT-HISTORY-HOST-001.1 + - AC-PLUGINS-PROMPT-HISTORY-HOST-001.2 + - AC-PLUGINS-PROMPT-HISTORY-HOST-001.3 + - AC-PLUGINS-PROMPT-HISTORY-HOST-001.4 + - AC-PLUGINS-PROMPT-HISTORY-HOST-001.5 + - AC-PLUGINS-PROMPT-HISTORY-HOST-001.6 + - AC-PLUGINS-PROMPT-HISTORY-HOST-001.7 + - AC-PLUGINS-PROMPT-HISTORY-HOST-003.1 + - AC-PLUGINS-PROMPT-HISTORY-HOST-003.2 + - AC-PLUGINS-PROMPT-HISTORY-HOST-003.3 + - AC-PLUGINS-PROMPT-HISTORY-HOST-003.4 +system_design: + - "../../specs/plugins/system-design/prompt-history-extraction-host.md" +--- + +# Task 03: Extend Generic Task Panel Capabilities + +## Scope + +Extend the existing generic task-panel contribution rather than adding prompt-history-specific host branches. Add localized title resolution, typed visibility context, session kind, and a generation-bound native message-navigation capability on desktop and mobile. + +## Acceptance + +- Desktop add-panel menus, mobile Panels picker, live tabs, restored layouts, and layout previews consume the single reactive host resolver `resolveTaskPanelTitle(registration, i18n)` with the literal title fallback; locale changes update open dockview `api.title` without changing panel identity. +- `visible(context)` receives no private state and is applied consistently to desktop and mobile choices; omission preserves existing visibility. Predicate exceptions are caught by the contribution boundary, logged with plugin/panel identity, and treated as hidden without affecting other entries. +- `PluginTaskPanel` passes current context, injects the Host-created panel-scoped conversation history handle, and revokes the prior navigation/history capabilities on any plugin/panel/task/session/presentation identity change or unmount. +- Desktop uses `scrollTranscriptToMessage`; mobile retains `MobileSessionLayout`'s local scroll target and switches to Chat. Neither path moves mobile state into global Zustand. +- Existing plugin panel registration, persistence, lifecycle failure handling, and mobile full-height presentation stay green. +- Saved-layout validation, serializer/restore, layout-editor registration, and plugin-panel removal/drop handling accept `plugin::` when the plugin is registered, preserve unknown plugin panels as droppable records when it is absent, and refresh titles through the same resolver after late registration or locale changes. + +## TDD sequence + +1. RED: add registry/component/menu/mobile/layout-editor tests for title fallback/reactivity, saved-layout round-trip, late registration/removal, visibility success/exception, context propagation, accepted/unavailable outcomes, and stale capability revocation. +2. GREEN: add the optional registration fields and inject the scoped adapters through existing renderers. +3. REFACTOR: centralize context/title resolution without adding a prompt-history condition to generic plugin code. + +## Likely files + +- `apps/web/lib/plugins/registry.ts` +- `apps/web/components/task/plugin-task-panel.tsx` +- `apps/web/components/task/dockview-add-panel-items.tsx` +- `apps/web/components/task/dockview-panel-content.tsx` +- `apps/web/components/task/dockview-shared.tsx` +- `apps/web/components/task/mobile/plugin-panel-picker.tsx` +- `apps/web/components/task/mobile/session-mobile-layout.tsx` +- `apps/web/lib/state/layout-manager/serializer.ts` +- `apps/web/lib/state/layout-manager/panel-titles.ts` +- `apps/web/lib/state/layout-manager/plugin-panels.test.ts` +- `apps/web/lib/state/layout-manager/serializer.test.ts` +- `apps/web/lib/state/layout-manager/panel-titles.test.ts` +- `apps/web/lib/layout/layout-profiles.ts` +- `apps/web/lib/layout/layout-profiles.test.ts` +- focused registry, task-panel, layout, and mobile tests + +## Verification + +```bash +cd apps && pnpm --filter @kandev/web test -- --run lib/plugins components/task/plugin-task-panel.test.tsx components/task/dockview-add-panel-items.test.tsx components/task/mobile lib/state/layout-manager/serializer.test.ts lib/state/layout-manager/panel-titles.test.ts lib/state/layout-manager/plugin-panels.test.ts lib/layout/layout-profiles.test.ts +cd apps/web && pnpm run typecheck +``` + +Do not register a prompt-history plugin or remove the built-in panel in this task. + +## Result + +Extended generic task panels with reactive localized titles, typed visibility +context, session kind, independent conversation scope, and revocable native +message navigation. Desktop, restored-layout, layout-editor, mobile picker, and +full-height mobile rendering paths use the shared contracts without a +prompt-history-specific branch. + +Verified: + +- Focused browser Host and task-panel suite (49 files, 442 tests) +- `cd apps/web && pnpm run typecheck` diff --git a/docs/plans/prompt-history-plugin-host/task-04-build-conversation-host-facade.md b/docs/plans/prompt-history-plugin-host/task-04-build-conversation-host-facade.md new file mode 100644 index 0000000000..4aecd47fa6 --- /dev/null +++ b/docs/plans/prompt-history-plugin-host/task-04-build-conversation-host-facade.md @@ -0,0 +1,103 @@ +--- +id: "04-build-conversation-host-facade" +title: "Build browser conversation Host facade" +status: done +wave: 3 +depends_on: + - "01-publish-browser-conversation-contract" + - "02-add-plugin-conversation-reads" + - "03-extend-task-panel-capabilities" +plan: "plan.md" +requirements: + - REQ-PLUGINS-PROMPT-HISTORY-HOST-002 + - REQ-PLUGINS-PROMPT-HISTORY-HOST-003 + - REQ-PLUGINS-PROMPT-HISTORY-HOST-004 + - REQ-PLUGINS-PROMPT-HISTORY-HOST-005 +acceptance_criteria: + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.1 + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.5 + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.6 + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.9 + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.12 + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.14 + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.15 + - AC-PLUGINS-PROMPT-HISTORY-HOST-004.1 + - AC-PLUGINS-PROMPT-HISTORY-HOST-004.2 + - AC-PLUGINS-PROMPT-HISTORY-HOST-004.3 +system_design: + - "../../specs/plugins/system-design/prompt-history-extraction-host.md" +--- + +# Task 04: Build Browser Conversation Host Facade +## Scope +Implement `host.conversation` and the curated prompt-mention renderer behind the published SDK types. Own request/subscription races, pagination, lifecycle cleanup, DTO mapping, favorite reactivity, and private custom-prompt access inside the host. + +## Acceptance + +- Nullable-session hooks return stable empty state, authorized snapshots, bounded older pages, and `PluginConversationError` values whose required `retryable` field follows the 400/401/404 versus authorized-5xx mapping. Each panel scope owns a monotonic `continuation_revision` from sentinel `0`, incremented on query change, retry, and close, over cursor/token/cutoff/fingerprint/generation; renewal and `loadMore` serialize dispatch per scope and CAS-install only matching revisions, atomically commit page plus next cursor/`hasMore`, discard stale responses, and leave state unchanged on failed renewal. +- `loadMore()` resolves to the number of newly projected messages. Concurrent + calls for one committed continuation join and resolve to the same count; + exhausted, closed, and terminal handles resolve `0` without network activity. + Retryable transport or renewal failures reject with `PluginConversationError` + while preserving committed state; non-retryable query failures also reject + and remain in `error`. `retry()` starts a fresh snapshot only for a live + retryable handle. +- Each mounted `PluginTaskPanel` has a Host-owned scope provider with independent cache, consumer identity, and abort controller. `conversation.history` is authoritative; global `host.conversation` resolves only the nearest scope. Unmount or plugin/panel/task/session/generation change aborts work, releases the consumer, and makes the handle return stable empty state without network activity. Two concurrent panels cannot share scope state; wrong IDs are rejected. The loader's per-generation isolated registry/resource ledger is staged without global visibility, rejects duplicate or foreign ownership, atomically swaps contributions at commit, and discards failed stages without partial notifications. +- Subscription readiness precedes snapshot resolution; message and turn start/completion events reconcile deterministically; reconnect reconciles; deletes and session removal cannot be resurrected by queued updates. +- `session.removed` retains projected rows, sets the hook's `removed` state and message `hasMore` to `false`, and makes later reads/retries no-ops without network activity. +- The facade does not initialize or mutate the chat transcript cache and exposes no Kandev HTTP/event/store types. +- `useMessageFavorite` reacts to the existing session-storage store without exposing toggle/store access. +- `host.ui.PromptMentionText` owns custom-prompt loading and native fine/coarse-pointer previews through a minimal public prop shape. +- The facade filters every buffered/live event by exact task/session identity before timestamp or sequence merging; focused tests cover wrong-session and wrong-task payloads. Omitted `taskId` resolves to the panel task before sending, explicit `null` sends as absent `task_id`, and mismatched strings fail without network activity. +- Snapshot tests block the HTTP response while add/update/delete/session-removal events arrive, then assert ordered replay and no resurrection through reconnect. +- Facade integration tests consume the production ordered WebSocket envelope and `session.removed` terminal event; unit tests may use fixtures only for reducer edge cases. +- Every snapshot/live DTO has required `updatedAt`; the facade applies the documented timestamp-less-add/update policy. + +- Reconciliation uses event sequence as ordering and fencing authority; `updatedAt` is freshness metadata only, equal or missing timestamps defer to sequence, and buffered events are released only after the snapshot-cutoff commit. + +## TDD sequence + +1. RED: add hook/runtime tests for null state, authorization errors, readiness race, pagination joining, concurrent renewal/expiry-between-pages, update/delete ordering, turn completion updates, reconnect, stale generations, two concurrent panels, unmount races, failed/overlapping successor staging with no partial notifications, duplicate ownership, lifecycle cleanup, favorites, prompt mentions, and concrete Host-to-SDK assignability. +2. GREEN: implement the plugin-scoped client, hook cache/reconciler, and UI wrapper in the Host builder. +3. REFACTOR: share existing message timestamp/merge helpers where their contracts match; keep the public DTO independent from private state. + +## Likely files + +- `apps/web/lib/plugins/host-api.ts` +- new focused conversation client/hook modules under `apps/web/lib/plugins/` +- `apps/web/lib/plugins/types.ts` +- `apps/web/components/task/chat/messages/prompt-mention-components.tsx` only for a narrow wrapper if required +- `apps/web/lib/plugins/host.ts` lifecycle resource tracking and staged generation registration +- `apps/web/lib/plugins/registry.ts` isolated contribution/resource ledgers and atomic commit +- focused tests beside the implementation + +## Verification + +```bash +cd apps && pnpm --filter @kandev/web test -- --run lib/plugins components/task/chat/messages/prompt-mention-components +cd apps/web && pnpm run typecheck +``` + +Do not copy the core prompt-history component into Kandev or expose `host.store` fields. + +## Current result + +Recovery follow-up: [Task 02](../pr-3588-conversation-recovery/task-02-restore-core-snapshots.md) +and [Task 03](../pr-3588-conversation-recovery/task-03-recover-expired-continuations.md) +own the pending core and plugin recovery repairs. The results below remain +the original delivery record, not evidence for the new regressions. + +Implemented the panel-scoped browser facade with subscription-before-snapshot +readiness, hydration, joined continuation paging, renewal, ordered +reconciliation, reconnect, poison fencing, atomic replacement-cursor rebind, +terminal removal, lifecycle abort, generation refresh, read-only favorites, +curated prompt mentions, and scoped desktop/mobile navigation. The production +core adapter validates the same versioned envelope and preserves +projection-before-ACK behavior. Concurrent panel isolation and registry +successor staging are covered by focused tests. + +Verified: + +- Focused browser Host, scope, registry, and task-panel suites +- `cd apps/web && pnpm run typecheck` +- `cd apps/web && pnpm run lint` diff --git a/docs/plans/prompt-history-plugin-host/task-05-prove-plugin-parity.md b/docs/plans/prompt-history-plugin-host/task-05-prove-plugin-parity.md new file mode 100644 index 0000000000..c98ba9baa6 --- /dev/null +++ b/docs/plans/prompt-history-plugin-host/task-05-prove-plugin-parity.md @@ -0,0 +1,95 @@ +--- +id: "05-prove-plugin-parity" +title: "Prove prompt-history plugin parity" +status: done +wave: 4 +depends_on: + - "04-build-conversation-host-facade" +plan: "plan.md" +requirements: + - REQ-PLUGINS-PROMPT-HISTORY-HOST-002 + - REQ-PLUGINS-PROMPT-HISTORY-HOST-005 +acceptance_criteria: + - AC-PLUGINS-PROMPT-HISTORY-HOST-005.3 + - AC-PLUGINS-PROMPT-HISTORY-HOST-005.2 + - AC-PLUGINS-PROMPT-HISTORY-HOST-002.11 +system_design: + - "../../specs/plugins/system-design/prompt-history-extraction-host.md" +--- + +# Task 05: Prove Prompt-History Plugin Parity + +## Scope + +Add a test-only external-style fixture plugin that implements prompt history through `@kandev/plugin-sdk` and the published Host APIs only. Use it to prove the prerequisite boundary; do not ship it as the production plugin or delete core code. + +## Acceptance + +- The fixture package manifest declares `capabilities.api_read: ["messages"]`, `min_kandev_version: "0.91.1"`, uses a distinct fixture panel ID, and its generated bundle is installed by the existing plugin-package E2E setup. +- It derives newest-first prompt entries, absolute numbering, bounded durations, sender-task marker, expansion, empty/loading/error states, and older-page loading from public DTOs only. +- It uses host UI/i18n/responsive/favorite/mention/navigation contracts and contains no `apps/web` import, `/api/v1` URL, `host.store` access, or raw WS registration. +- Desktop E2E covers panel entry, initial and older pages, live add/update/delete, live turn completion updating duration, localized title/content behavior, favorite highlight, prompt alias preview, and native transcript navigation. +- `mobile-*.spec.ts` Pixel 5 E2E proves the grouped Panels entry, full-height single-scroll surface, 44 px touch controls, older loading, alias drawer, target navigation, and no document horizontal overflow. +- The fixture's turn-completion scenario proves a live completed turn updates the derived duration without retry or remount. +- The fixture's transition controls causally cover message update/delete and turn completion; assertions wait for the control response and then observe the corresponding facade update. +- Existing built-in prompt-history tests and E2E remain unchanged and green because the ordered stream's `legacy-core` adapter projects exactly one legacy notification to existing core handlers; its durable cursor, projection-before-ACK order, event-id/sequence dedupe, reconnect replay, interleaving, and terminal-removal cases are covered. Backend direct message/turn broadcasts are removed rather than duplicated. +- The package build removes the prior generated UI output, then runs the declared fixture source build (`cd apps/web && pnpm run build:e2e-plugin -- --outDir ../../apps/backend/cmd/plugin-fixture/fixture-package/ui`), and packages only after it succeeds. `e2e-plugin-package` depends on the `.PHONY` `e2e-plugin-ui` target, so stale checked-in UI cannot be packaged. +- The E2E runner and CI invoke the exact order `build backend -> build:e2e -> e2e-plugin-ui -> e2e-plugin-package -> install/spec` in host and container modes. Freshness checks hash fixture source/output plus manifest capability, min version, and panel key; the fixture parity spec exercises the production ordered WebSocket protocol, including session removal and reconnect replay. CI uploads `e2e-plugin-identity.json` with the package, every shard/container downloads both, verifies the archive SHA-256 before install, and fails before specs on missing, stale, or schema-mismatched identity. +- `apps/web/package.json` adds the `build:e2e-plugin` script and `apps/web/e2e/fixtures/plugins/prompt-history-plugin/` is its sole source; the script accepts the declared output directory and fails on missing/stale output. +- Host, container, CI, package, and global-setup paths consume one immutable fixture identity at `apps/backend/.build/e2e-plugin-identity.json`, produced by `e2e-plugin-package` after `e2e-plugin-ui` and archive creation, with schema version `2` and `{schemaVersion,pluginId,pluginVersion,bundlePath,sourceHash,generatedOutputHash,manifestHash,archiveSha256,manifestCapability,minKandevVersion,panelKey}`. The archive SHA-256 covers the exact packaged file; every consumer verifies plugin ID/version, bundle path, manifest hash, and archive digest before install or spec execution. `fixture-package/ui/` remains excluded from backend source-mtime checks. Existing generic backend identity remains separate. + +## TDD sequence + +1. RED: write desktop and mobile fixture-plugin scenarios against the public contract. +2. GREEN: add the smallest fixture bundle/seed/page-object support needed to exercise the Host APIs. +3. REFACTOR: keep test helpers provider-neutral and remove any shortcut into internal state or routes. + +## Likely files + +- `apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml` with `capabilities.api_read: ["messages"]`, `min_kandev_version: "0.91.1"`, and a distinct fixture panel identity. +- `apps/web/e2e/scripts/run-e2e.sh` and the owning CI workflow, so host builds call `e2e-plugin-ui` before `e2e-plugin-package` and preserve the same order in container/host modes. +- External-style fixture source under `apps/web/e2e/fixtures/plugins/prompt-history-plugin/`, with a distinct `prompt-history-plugin` panel key and public-SDK-only imports. The backend `e2e-plugin-package` target copies its built UI output into `apps/backend/cmd/plugin-fixture/fixture-package/ui/` before packaging; the generated archive is the only installed artifact. +- `apps/backend/Makefile` and the fixture-package build/copy step, with assertions that the archive contains the manifest capability and fixture bundle/panel key. +- `apps/web/e2e/helpers/api-client.ts`, `apps/backend/internal/office/testharness/routes.go`, and deterministic test-only update/delete/turn-transition control helpers. +- Root `Makefile`, `.github/workflows/e2e-tests.yml`, `apps/web/e2e/scripts/run-e2e.sh`, `apps/web/e2e/global-setup.ts`, and `apps/backend/Makefile` all use the same fixture identity path/schema and build order in host and container modes, including upload/download handoff for every shard. +- `apps/web/e2e/global-setup.ts`, package/archive assertions, and shard setup consume the fixture identity, including source/output hashes, manifest capability, minimum version, and panel key; schema-version mismatch fails before specs. +- `apps/web/e2e/README.md` documents the source-build order, `e2e-plugin-ui` prerequisite, identity artifact, and package freshness checks; a stale-invocation check rejects direct package-before-build usage. +- New desktop spec: `apps/web/e2e/tests/plugins/prompt-history-plugin.spec.ts`. +- `apps/web/e2e/helpers/api-client.ts` methods for `PATCH /api/v1/_test/messages/:id`, `DELETE /api/v1/_test/messages/:id`, and `POST /api/v1/_test/turns/:id/complete`. +- New mobile spec: `apps/web/e2e/tests/plugins/mobile-prompt-history-plugin.spec.ts`. +- Shared prompt-history seed/page-object helpers where existing ones already fit. + +## Verification + +The [recovery E2E matrix](../pr-3588-conversation-recovery/plan.md#e2e-tests) +adds replay, connected core recovery, and expired continuation cases. +Those cases remain pending and supplement the original parity evidence here. + +```bash +make -C apps/backend build +cd apps/web && pnpm run build:e2e +make -C apps/backend e2e-plugin-ui +make -C apps/backend e2e-plugin-package +cd apps/web && pnpm e2e:raw --project=chromium e2e/tests/plugins/prompt-history-plugin.spec.ts +cd apps/web && pnpm e2e:raw --project=mobile-chrome e2e/tests/plugins/mobile-prompt-history-plugin.spec.ts +cd apps/web && pnpm e2e:raw --project=chromium e2e/tests/task/prompt-history-panel.spec.ts +``` + +Use the repository's one-worker/shard guards. Do not package or publish the fixture. + +## Result + +Added one public-SDK-only fixture source, deterministic source/output and +archive identity checks, response-bounded transition controls, and shared +desktop/mobile parity helpers. CI, host, container, package, and global-setup +paths now consume identity schema version 2 after the required fresh build +order. + +Verified: + +- `cd apps/web && pnpm run build:e2e` +- `make -C apps/backend e2e-plugin-ui` +- `make -C apps/backend e2e-plugin-package` +- Desktop fixture E2E: 1 passed +- Pixel 5 mobile fixture E2E: 1 passed +- Built-in Prompt History E2E: 2 passed diff --git a/docs/plans/prompt-history-plugin-host/task-06-document-and-verify.md b/docs/plans/prompt-history-plugin-host/task-06-document-and-verify.md new file mode 100644 index 0000000000..31029fe568 --- /dev/null +++ b/docs/plans/prompt-history-plugin-host/task-06-document-and-verify.md @@ -0,0 +1,95 @@ +--- +id: "06-document-and-verify" +title: "Document and verify Host prerequisites" +status: done +wave: 5 +depends_on: + - "02-add-plugin-conversation-reads" + - "03-extend-task-panel-capabilities" + - "04-build-conversation-host-facade" + - "05-prove-plugin-parity" +plan: "plan.md" +requirements: + - REQ-PLUGINS-PROMPT-HISTORY-HOST-005 +acceptance_criteria: + - AC-PLUGINS-PROMPT-HISTORY-HOST-005.4 +system_design: + - "../../specs/plugins/system-design/prompt-history-extraction-host.md" +--- + +# Task 06: Document and Verify Host Prerequisites + +## Scope + +Finish the public reference/how-to documentation and run the package-level validation matrix. Update artifact statuses/results only after the behavior and docs are proven. Keep external-plugin and core-removal work explicitly deferred. + +## Acceptance + +- `docs/public/plugins-authoring.md` explains `host.conversation`, task-panel context/navigation, lifecycle cleanup, and a UI-only session-read example. +- `docs/public/plugins-manifest.md` documents `api_read:messages` and required `min_kandev_version: "0.91.1"` for every manifest that declares that resource, covering both Go `Messages().List` and the browser facade, while preserving the admin-action floor. `apps/backend/internal/plugins/manifest/min_version_policy.go` owns one `MinimumMessagesCapabilityVersion` constant and capability-aware predicate used by manifest validation, archive/Inspect, install/sideload, boot/load, fixture, and direct-build tests; it rejects missing/malformed/lower values in dev/E2E and stamped builds and covers missing, lower, equal, and higher values. `docs/plans/plugins/PLUGIN-API.md` remains canonical. +- Public docs distinguish browser Host reads from Go Host `Messages().List` and warn against `host.store`, raw WS payloads, and first-party `/api/v1` URLs. +- The documented route error table is reflected by public SDK retryability and browser tests; inaccessible resources never reveal existence. +- Update `docs/public/websocket-api.md` so its ordinary notification and + connection-local subscription statements explicitly exclude the Host-only + prompt-history stream, and link the canonical ordered subscribe/ACK, + per-consumer identity, replay, and terminal-removal contract. +- Focused and repository checks below pass against fresh generated/build artifacts. The fresh fixture order is backend build, web `build:e2e`, `e2e-plugin-ui`, `e2e-plugin-package`, plugin install, then desktop/mobile/core specs; every root Makefile, runner, CI host path, and container path replaces direct package invocation with this order and consumes identity schema version `2`. The identity includes plugin ID/version, bundle path, source/output hashes, manifest hash, capability, minimum version, panel key, and archive SHA-256; every shard/container verifies the digest before install/spec execution. +- Requirements, design, plan, and work-order statuses/results reflect actual completion; no core prompt-history ownership artifact is retired. + +## TDD sequence + +1. RED: add/update public-doc validation examples or reference assertions where the repository already tests contract snippets. +2. GREEN: write the smallest public reference/how-to updates and fix only failures caused by this package. +3. REFACTOR: remove duplicate prose and link the canonical contract for exhaustive field definitions. + +## Likely files + +- `docs/public/plugins-authoring.md` +- `docs/public/plugins-manifest.md` +- `docs/plans/plugins/PLUGIN-API.md` +- this requirements/design/plan package for final status and result records +- `apps/backend/internal/plugins/manifest/min_version_policy.go`, `docs/public/plugins-authoring.md`, `docs/public/plugins-manifest.md`, `docs/plans/plugins/PLUGIN-API.md`, `apps/web/e2e/README.md`, `apps/web/e2e/global-setup.ts`, `.github/workflows/e2e-tests.yml`, `apps/web/e2e/scripts/run-e2e.sh`, and root/backend Makefiles + +## Verification + +```bash +python3 scripts/lint-spec-files.py --all +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +cd apps/web && pnpm run typecheck +cd apps/web && pnpm run i18n:check && pnpm run i18n:ratchet +cd apps && pnpm --filter @kandev/web test -- --run lib/plugins components/task/plugin-task-panel.test.tsx components/task/dockview-add-panel-items.test.tsx components/task/mobile lib/state/layout-manager/serializer.test.ts lib/state/layout-manager/panel-titles.test.ts lib/state/layout-manager/plugin-panels.test.ts lib/layout/layout-profiles.test.ts +make -C apps/backend test +make -C apps/backend lint +make -C apps/backend build +cd apps/web && pnpm run build:e2e +make -C apps/backend e2e-plugin-ui +make -C apps/backend e2e-plugin-package +cd apps/web && pnpm e2e:raw --project=chromium e2e/tests/plugins/prompt-history-plugin.spec.ts +cd apps/web && pnpm e2e:raw --project=mobile-chrome e2e/tests/plugins/mobile-prompt-history-plugin.spec.ts +cd apps/web && pnpm e2e:raw --project=chromium e2e/tests/task/prompt-history-panel.spec.ts +``` +Do not commit unless the user explicitly authorizes a commit. Do not implement the external plugin, remove core code, or migrate saved layout IDs. + +## Current result + +Published the browser facade, manifest/version floor, lifecycle, navigation, +and ordered WebSocket guidance while preserving the separate Go Host API and +core ownership. Final status reconciliation is complete. + +Verified: + +- `python3 scripts/lint-spec-files.py --all` +- Public-doc validator tests: 61 passed; 46 pages validated +- `cd apps/web && pnpm run typecheck` +- Frontend i18n checks and ratchet +- Focused browser Host, scope, registry, task-panel, and retention suites +- Backend `golangci-lint run ./...` (0 issues) +- `make -C apps/backend build` +- Fresh fixture desktop/mobile and built-in Prompt History E2E: 4 tests passed + +The bounded repository-wide backend test run was attempted with +`go test -p 2 -tags fts5 ./...`; unrelated environment-sensitive failures +remain in agentctl config, update-channel, and invalid metadata tests, along +with temporary disk-quota linker failures. The changed packages passed focused +verification. diff --git a/docs/public/plugins-authoring.md b/docs/public/plugins-authoring.md index 7df218b6d0..218fdaa58e 100644 --- a/docs/public/plugins-authoring.md +++ b/docs/public/plugins-authoring.md @@ -312,7 +312,18 @@ of truth and must be updated together when the contract changes: - Wire contract: apps/backend/proto/kandev/plugin/v1/plugin.proto. - Manifest model and semantic validation: apps/backend/internal/plugins/manifest. - Package integrity and installation: apps/backend/internal/plugins/pkgtar. -- Durable decisions: [ADR 0043](../decisions/0043-plugin-host-data-api.md), [ADR 0047](../decisions/0047-plugin-host-conversation-reads.md), [explicit plugin utility selection](../decisions/2026-09-14-explicit-plugin-utility-selection.md), and [ADR 0050](../decisions/0050-plugin-external-auth-capability.md). +- Durable decisions: [ADR 0043](../decisions/0043-plugin-host-data-api.md), + [ADR 0047](../decisions/0047-plugin-host-conversation-reads.md), + [ADR 0048](../decisions/0048-plugin-host-utility-agent-invoke.md), + [ADR 0050](../decisions/0050-plugin-external-auth-capability.md), + [explicit plugin utility selection](../decisions/2026-09-14-explicit-plugin-utility-selection.md), and + [Browser conversation facade ADR](../decisions/2026-09-06-browser-plugin-conversation-facade.md). + +The browser conversation facade ADR is still proposed while this prerequisite +package is under review. Until that ADR is accepted, normative authority is +split deliberately: requirements define observable behavior, the system design +defines Host architecture, and `PLUGIN-API.md` defines the Host-only wire +contract. ## Frontend contract @@ -362,6 +373,48 @@ If a bundle registers a component at its exact plugin detail route, Kandev keeps that component and renders the host-owned shortcut card alongside it. Nested plugin settings routes remain fully plugin-owned. +### Browser conversation facade + +Native UI plugins read prompt history through `host.conversation`, not through +`host.store`, raw WebSocket frames, first-party `/api/v1` URLs, or the Go Host +reader. The facade requires `capabilities.api_read: ["messages"]`, and every +manifest declaring that capability requires `min_kandev_version: "0.91.1"` or +later. The same floor applies to Go `host.Messages().List` plugins. It returns +sanitized public SDK DTOs. Inside a task panel, prefer the injected +`conversation.history` handle so the read stays bound to that panel's task, +session, and plugin generation: + +```tsx +function PromptHistory({ sessionId, conversation }: PluginTaskPanelProps) { + const { messages, loading, loadMore } = + conversation.history.useSessionMessages({ sessionId }); + return ; +``` + +Panel handles are independently scoped and become inert on unmount, identity +change, disable, or reload. `host.conversation` is the equivalent nearest-scope +accessor; outside a panel it returns stable empty state. Use the canonical +[PLUGIN-API contract](../plans/plugins/PLUGIN-API.md) for DTOs, pagination, +ordered updates, lifecycle, and retryable errors. +Browser route errors use `unauthenticated`/non-retryable for `401`, +`not_found`/non-retryable for `404`, `invalid_query`/non-retryable for `400`, +and `upstream_failure`/retryable for every authorized `5xx`. +Within a task panel, omitted `taskId` inherits the panel task, explicit +`null` reads the whole selected session, and an explicit task ID must match the +panel task. `loadMore()` resolves to the number of newly projected messages; +joined, exhausted, closed, and removed calls are deterministic, while +transport failures reject with the typed error and preserve committed state. +The turns hook follows the same scope and lifecycle rules, favorites remain +read-only and reactive, and `session.removed` retains visible rows while +closing future reads. + +| Need | Browser facade | Go Host API | +| --- | --- | --- | +| Surface | `host.conversation` or `conversation.history` | `host.Messages().List` | +| Runtime | Native UI bundle | Plugin server process | +| Data | Sanitized browser DTOs and ordered live updates | Typed paginated reader | +| Forbidden shortcut | `host.store`, raw WS, `/api/v1` | Private application imports | + ### Frontend hook/API matrix | Surface | Location and input | Manifest requirement | Cleanup/lifecycle | Small example | @@ -377,7 +430,7 @@ Nested plugin settings routes remain fully plugin-owned. | registerRepositoryProvider | Provider-owned paged/searchable repository list, URL match/inspect, branches, and optional native `createChangeRequest` transport | ui.bundle and matching `repository_providers[]` id | Registration and in-flight callbacks are result-fenced on unload; host owns native task and Create PR UI | registry.registerRepositoryProvider({ id: "acme", ...provider }) | | registerTaskAction | Child action inside the task menu's native Link section | Active ui.bundle | Action is revoked on unload; host supplies current task/workspace and desktop/mobile presentation | registry.registerTaskAction({ id: "link-pr", placement: "link", ... }) | | registerReviewProvider | Normalized task reviews, workspace associations, unlink, and shared Review panel | ui.bundle and matching `repository_providers[]` id | Snapshots/subscriptions are owner-scoped and revoked on unload; host owns status chrome, indicators, unlink UI, and responsive Review placement | registry.registerReviewProvider({ id: "acme", ...reviews }) | -| registerTaskPanel | { id, title, icon?, Component, mobileEnabled? }; adds a row to the task workspace's "+" (add panel) menu; Component receives { panelId, taskId, sessionId, presentation } | Active ui.bundle | Panel renders behind its own error boundary; slow/failed reloads preserve it, a ready generation missing it closes it, and disable/uninstall closes every owned instance | registry.registerTaskPanel({ id: "notes", title: "Notes", Component: NotesPanel }) | +| registerTaskPanel | { id, title, titleKey?, icon?, Component, mobileEnabled?, visible?(context) }; adds a row to the task workspace's "+" (add panel) menu; Component receives { panelId, taskId, sessionId, sessionKind, presentation, conversation: { openMessage(messageId), history } }; `titleKey` is a plugin translation key with literal `title` fallback | Active ui.bundle | Panel renders behind its own error boundary with reactive localized titles; a throwing `visible` hides the item; handles are generation-bound and independently scoped, inert after unmount, identity change, disable, reload, or uninstall; `host.conversation` outside a panel returns stable empty state; desktop preserves layout identity on navigation and mobile uses the full-height Chat surface | registry.registerTaskPanel({ id: "notes", title: "Notes", titleKey: "panels.notes", Component: NotesPanel }) | | registerTaskMenuAction | { id, label, icon?, group: "edit" \| "primary", visible?(context), run(context) }; "edit" is card-only inside Edit, while "primary" is a flat item on cards and desktop/mobile task-row menus | Active ui.bundle | Action is revoked on disable/uninstall; a throwing/rejecting run is caught and logged | registry.registerTaskMenuAction({ id: "enhance", label: "Enhance", group: "primary", run: doEnhance }) | | registerTaskFilter | { id, label, getOptions(), matches(context, selected) }; adds a client-side, multi-select filter section to the kanban board's display dropdown, alongside Workflow/Repository | Active ui.bundle | Filter is revoked on disable/uninstall; selections are ephemeral (not persisted); matches is only called for a non-empty selection, and a throw is caught, logged, and treated as non-matching | registry.registerTaskFilter({ id: "tags", label: "Tags", getOptions: listTagOptions, matches: taskHasSelectedTag }) | | registerTaskListFacet | { id, label, getValues({ taskId, workspaceId }), subscribe? }; adds page-local Sort and Group choices on `/tasks` | Active ui.bundle | Values apply only to the loaded page, callbacks are isolated, and registrations are revoked on disable/unload | registry.registerTaskListFacet({ id: "tags", label: "Tag", getValues: taskTags }) | diff --git a/docs/public/plugins-manifest.md b/docs/public/plugins-manifest.md index a3c46bf922..c1b83444b8 100644 --- a/docs/public/plugins-manifest.md +++ b/docs/public/plugins-manifest.md @@ -43,7 +43,7 @@ runtime: server/plugin-windows-amd64.exe # any subset; the host's - key # is required at install time -min_kandev_version: "0.91.1" # required for admin actions +min_kandev_version: "0.91.1" # required by api_read:messages or admin actions capabilities: events: ["task.created", "task.state_changed", "agent.completed"] @@ -200,7 +200,7 @@ for a complete authoring path. | `repo_url` | no | string | Absolute `http(s)` URL to the plugin's source repository. Rendered as a "Repo" link in Settings > Plugins (both the installed list and the plugin detail). Any other scheme (e.g. `javascript:`) is rejected at registration. Distinct from the marketplace card's `repo_url`, which the registry derives from `plugins.yaml`; declare this in the manifest so sideloaded and directly-installed plugins also carry the link. | | `runtime.type` | conditionally | string | `"binary"` is the only supported value. Setting it (vs. leaving it empty) makes the manifest **runtime-managed**; see "Managed vs. legacy" below. | | `runtime.executables` | required when `runtime.type: binary` | map\ | Key is `-` (e.g. `linux-amd64`, `darwin-arm64`, `windows-amd64`); value is a clean, package-relative path under `server/` (no leading `/`, no `..` segments). At least one entry required; the running host's key must be present at install time. Windows values end in `.exe`. | -| `min_kandev_version` | conditionally | string | Lowest released Kandev version this plugin supports. Required to be at least `0.91.1` when any action uses `access: admin`. Enforced at **install time**: installing onto an older release fails with `requires kandev >= , running ` and nothing is registered. Already-installed plugins are never re-checked at load or start. Release values use up to three dotted numeric segments (`0.78.0`) and may have a leading `v`; malformed minimums are rejected. Non-release development and git-describe builds skip the compatibility gate. | +| `min_kandev_version` | conditionally | string | Lowest released Kandev version this plugin supports. A manifest declaring `capabilities.api_read: messages` (for Go `Messages().List` or the browser conversation facade) must set at least `0.91.1`; `access: admin` actions have the same floor. One capability-aware validator enforces missing, malformed, and lower values at manifest/archive/install/boot boundaries in stamped, development, and E2E builds; equal or higher values pass. Release values use up to three dotted numeric segments (`0.78.0`) and may have a leading `v`. | | `capabilities.events` | no | string[] | Bus subjects (or wildcard patterns) this plugin subscribes to. See "Event subscription vocabulary" below. | | `capabilities.api_read` | no | string[] | Gates the Host data API's read-only accessors. Each entry is a resource name: `tasks`, `sessions`, `messages`, `workspaces`, `workflows`, `agent_profiles`, `executor_profiles`, `repositories`. Calling the matching `Host` accessor (e.g. `Tasks()`) without its resource declared returns gRPC `PermissionDenied`. See "Host data API resource vocabulary" below. | | `capabilities.api_write` | no | string[] | Gates Host writes independently of `api_read`. `tasks` permits `Host.Tasks().Create` and `.Update`; `messages` permits `Host.Messages().Send`. Undeclared writes return gRPC `PermissionDenied`. | @@ -405,6 +405,10 @@ A delivered event's `event_type` (`pluginsdk.Event.EventType`) is the matched, so a plugin subscribed to `shell.output.*` reads the session id off `event_type` as its last segment. For an unsuffixed subject that string is just the subject itself (`task.created`). +The table above documents backend `capabilities.events` bus vocabulary. The +browser conversation facade uses a separate Host-only ordered vocabulary for +live history, including `session.turn.started` and `session.turn.completed`; +those names do not change the backend subjects listed here. | Domain | Events | | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/docs/public/websocket-api.md b/docs/public/websocket-api.md index 6b7aa7c6ad..04b6821104 100644 --- a/docs/public/websocket-api.md +++ b/docs/public/websocket-api.md @@ -133,7 +133,12 @@ Malformed JSON produces a `BAD_REQUEST` error with empty `id` and `action`, beca | Hub-wide broadcast queue | 256 messages | Global publishers block when this internal queue is saturated; delivery to each client can still drop at that client's queue. | | Request dispatch | One goroutine per inbound message | Handlers execute concurrently and responses can arrive out of request order. Correlate only by `id`. | -There is no sequence number, durable replay, acknowledgement, or exactly-once guarantee. Notifications are invalidation hints: after a reconnect, gap, or dropped frame, refetch authoritative state through the appropriate list/get request or HTTP route. +Ordinary notifications have no sequence number, durable replay, +acknowledgement, or exactly-once guarantee. They are invalidation hints: after +a reconnect, gap, or dropped frame, refetch authoritative state through the +appropriate list/get request or HTTP route. The Host-owned prompt-history +conversation stream is a separate ordered internal exception documented in +the plugin contract below. ### Prompt attachments @@ -164,10 +169,27 @@ Subscription actions are handled by the gateway before the normal dispatcher: | `run.subscribe` / `run.unsubscribe` | `{"run_id":"..."}` | Routes future `run.event.appended` messages for one Office run. No snapshot is replayed. | | `system.metrics.subscribe` / `system.metrics.unsubscribe` | `{}` | Starts/stops delivery of live `system.metrics.updated` snapshots and contributes to metrics collection interest. | -All subscriptions are connection-local and are removed on disconnect. `session.subscribe` and `session.focus` can send an initial live snapshot, but other subscriptions do not replay missed notifications. For an Office run, fetch the REST snapshot before subscribing and reconcile again after a gap. +All ordinary subscriptions are connection-local and are removed on +disconnect. Ordinary `session.subscribe` and `session.focus` can send an +initial live snapshot, but other ordinary subscriptions do not replay missed +notifications. For an Office run, fetch the REST snapshot before subscribing +and reconcile again after a gap. The first-party web client reconnects by default: at most 10 attempts, starting at 1 second, multiplying delay by 1.5, capped at 30 seconds. On open it flushes frames queued while disconnected, then resubscribes to tasks, sessions, focus state, runs, the default user, and metrics. Its ordinary request timeout defaults to 5 seconds; selected Git and PR requests use longer timeouts. A custom client must implement its own backoff, resubscription, refetch, and timeout policy. +### Host-only prompt-history conversation stream + +The browser `host.conversation` facade uses an internal ordered session stream +that is not a caller-visible WebSocket API. It has Host-minted per-consumer +identities, monotonic sequence numbers, durable cursors, subscribe readiness, +ACKs, reconnect replay, and terminal session-removal barriers. Its migrated +event names are `message.added`, `message.updated`, `message.deleted`, +`session.turn.started`, `session.turn.completed`, and `session.removed`. +The authoritative request, ACK, result, event, and compatibility rules are in +the Host-only conversation wire contract in +`docs/plans/plugins/PLUGIN-API.md`; ordinary subscription behavior above does +not override that internal contract. + ## Core task and session requests The typical product flow is workspace → workflow → task → session → messages. IDs are opaque strings; do not derive one resource ID from another. diff --git a/docs/specs/plugins/requirements/prompt-history-extraction-host.md b/docs/specs/plugins/requirements/prompt-history-extraction-host.md new file mode 100644 index 0000000000..df2edae2c1 --- /dev/null +++ b/docs/specs/plugins/requirements/prompt-history-extraction-host.md @@ -0,0 +1,111 @@ +--- +status: active +system: plugins +created: 2026-09-06 +owners: + - kandev +--- +# Prompt History Plugin Host Prerequisites Requirements + +## Overview + +Prompt History can move to an external plugin only when the Host exposes the +narrow browser contracts that the existing core panel currently obtains from +private stores, task services, WebSocket reconciliation, task-panel context, +native navigation, custom-prompt rendering, and browser-local favorites. This +package inventories those contact points and defines the additive boundary. It +does not extract or remove the shipped core panel. + +## Requirements + +### REQ-PLUGINS-PROMPT-HISTORY-HOST-001: Prompt History Contact-Point Inventory + +**Intent:** Identify every core-owned dependency that a future external prompt-history plugin must consume through a public Host contract or that must remain Host-owned. + +#### Acceptance criteria + +- **AC-PLUGINS-PROMPT-HISTORY-HOST-001.1:** The inventory covers task/session identity, task-panel registration and placement, desktop and native-mobile navigation, message and turn reads, live WebSocket events, session removal, transcript cache boundaries, task generation and lifecycle invalidation, custom-prompt aliases, favorites, and localization. +- **AC-PLUGINS-PROMPT-HISTORY-HOST-001.2:** Each contact point is classified as a public plugin contract, a Host-owned prerequisite, a core-only implementation detail, or an extraction blocker. No future plugin contract requires importing `apps/web`, reading Zustand state, or calling an unscoped `/api/v1` route. +- **AC-PLUGINS-PROMPT-HISTORY-HOST-001.3:** The inventory distinguishes the prompt-history plugin's responsibilities (derivation, filtering, and presentation) from Host responsibilities (authorization, persistence, sanitization, transport ordering, lifecycle, navigation, and private state). +- **AC-PLUGINS-PROMPT-HISTORY-HOST-001.4:** Desktop and native-mobile entry points remain part of one task-panel contract. Mobile uses native panel navigation and touch behavior rather than a compressed desktop layout. +- **AC-PLUGINS-PROMPT-HISTORY-HOST-001.5:** The inventory records that core prompt history remains registered and behaviorally unchanged until a separate extraction package proves parity, migrates saved layout IDs, and removes core ownership. +- **AC-PLUGINS-PROMPT-HISTORY-HOST-001.6:** The inventory identifies generic task-panel title, visibility, saved-layout, lifecycle, and mobile-picker contact points as shared infrastructure to extend without prompt-history-specific branches. +- **AC-PLUGINS-PROMPT-HISTORY-HOST-001.7:** The inventory identifies the browser-facade requirements, system design, SDK/API reference, ADR, and implementation plan as the durable artifacts that must agree before extraction work begins. + +### REQ-PLUGINS-PROMPT-HISTORY-HOST-002: Browser Conversation Host Boundary + +**Intent:** Provide a sanitized, capability-gated, generation-safe browser conversation facade with deterministic pagination and ordered live reconciliation. + +#### Acceptance criteria + +- **AC-PLUGINS-PROMPT-HISTORY-HOST-002.1:** The public message shape contains only plugin-safe fields: message, session, turn, and nullable task IDs; author and message type; stripped content; created and updated timestamps; durable prompt index; and an optional sender task ID derived from approved metadata. It never exposes raw content, injected system blocks, arbitrary metadata, credentials, or private store objects. The query has tri-state `taskId`: `undefined` inherits the panel task, `null` selects the complete session, and a string must match the panel task. +- **AC-PLUGINS-PROMPT-HISTORY-HOST-002.2:** `host.conversation.useSessionMessages` exposes loading, hydrated, pagination, retryable error, and observable terminal `removed` state. `loadMore()` resolves the count of newly projected messages; concurrent calls for one continuation join; exhausted, closed, or terminal handles resolve `0` without network activity; failures reject with `PluginConversationError` and preserve committed state. +- **AC-PLUGINS-PROMPT-HISTORY-HOST-002.3:** `host.conversation.useSessionTurns` returns typed turn IDs, start/completion timestamps, and freshness state. It applies the same task-selection rules and terminal lifecycle as messages. +- **AC-PLUGINS-PROMPT-HISTORY-HOST-002.4:** Message pages preserve deterministic `(created_at,id)` ordering, use opaque keyset cursors, expose absolute prompt indexes that do not renumber after deletion, and can load the selected session without initializing or changing the chat transcript cache. +- **AC-PLUGINS-PROMPT-HISTORY-HOST-002.5:** The Host establishes subscription readiness before resolving the initial snapshot, buffers and merges later adds and updates by stable ID, removes deleted messages, and reconciles after reconnect. No message created between subscription and snapshot is lost or duplicated. +- **AC-PLUGINS-PROMPT-HISTORY-HOST-002.6:** Requests, pages, and events are scoped to the requested session generation and every mounted panel owns an independent Host scope, cache, consumer identity, and abort controller. Session switch, unmount, plugin reload, disable, or uninstall aborts pending reads, releases the consumer, closes the handle, and prevents stale completion from mutating a replacement surface. +- **AC-PLUGINS-PROMPT-HISTORY-HOST-002.7:** Authenticated browser read routes require an active plugin declaring `api_read:messages`, verify access to the task session, and reuse task services and repositories. Unknown or inactive plugins, missing capabilities, invalid cursors, and inaccessible sessions fail closed with typed HTTP errors. +- **AC-PLUGINS-PROMPT-HISTORY-HOST-002.8:** External plugins do not need a Go backend to consume the browser facade. The existing capability-gated Go Host `Messages().List` API remains available for backend plugins but is not the browser contract and is not expanded solely for prompt history. +- **AC-PLUGINS-PROMPT-HISTORY-HOST-002.9:** The turns facade consumes typed `session.turn.started` and `session.turn.completed` events after readiness, merges completion by turn ID and freshness, and updates derived prompt durations without retry or remount. Session removal is a terminal barrier for queued turn events. +- **AC-PLUGINS-PROMPT-HISTORY-HOST-002.10:** Browser failures use `{"error":{"code":"...","message":"...","retryable":false|true}}`: 401 maps to `unauthenticated`, 404 to `not_found`, 400 to `invalid_query`, and authorized upstream 5xx to retryable `upstream_failure`. The first three are non-retryable. Binding-only `409/generation_superseded` is Host-loader-only; continuation-renewal 409 is retryable `upstream_failure`; binding and renewal responses use `Cache-Control: no-store`. +- **AC-PLUGINS-PROMPT-HISTORY-HOST-002.11:** Test-only fixture controls can causally request message update/delete and turn-completion transitions. Each control response is tied to its emitted event, so parity tests do not use timing sleeps. +- **AC-PLUGINS-PROMPT-HISTORY-HOST-002.12:** Every snapshot and public live message/turn DTO has a non-empty `updatedAt`. A timestamp-less unseen add may normalize to its valid `createdAt`; a timestamp-less update to an existing ID is rejected and repaired by the next snapshot. Fixture transitions provide explicit update/completion timestamps. +- **AC-PLUGINS-PROMPT-HISTORY-HOST-002.13:** Subscription readiness yields a per-session monotonic watermark. Plugin subscribe requests carry an authenticated loader binding for plugin, consumer kind, generation, and a Host-minted per-panel `consumer_id`; core wires carry a distinct Host-minted `wire_id`. Each identity survives reconnect, is never shared, and is released on unsubscribe or after retention. Binding tokens expire no later than 10 minutes after issuance, and the loader rebinds before expiry. Strict results include event watermark, committed snapshot cutoff, and Host-only snapshot token. +- **AC-PLUGINS-PROMPT-HISTORY-HOST-002.14:** Before buffering or merging, the Host applies the selected task/session matching rule. Inherited `undefined` and explicit task strings require exact session and task equality. Explicit `null` accepts every task ID in the selected session, including `event.task_id:null`. `session.removed` is accepted for the selected session regardless of event task ID and closes both hooks. Wrong-session and wrong-task events do not mutate selected state. +- **AC-PLUGINS-PROMPT-HISTORY-HOST-002.15:** The facade consumes the production ordered WebSocket envelope and readiness handshake. Raw envelopes are validated against a versioned event registry before projection; outer and payload event types must match. Missing, malformed, unsupported-version, and unknown-type events are durable poison: advancement stops, diagnostics retain event ID and version, and atomic rebind returns a replacement cursor/token past the poison. `session.removed` is terminal whether it precedes or follows poison; its replacement cursor is closed. ACK accepts only the highest contiguous sequence and rejects forward gaps, malformed identity, and invalid tokens without advancing state. The protocol has explicit success and failure ACK envelopes, with no success-only fields on failure. +- **AC-PLUGINS-PROMPT-HISTORY-HOST-002.16:** Event append, version rows, deletion tombstones, and sequence allocation share one transaction and lock. Delivery cursors persist ACK, owner epoch, lease, retry, and generation state. Poison dispatch is owned by `SessionDeliveryDispatcher`: records use `pending`, `leased`, `exhausted`, and `requeued` states, a 30-second lease, five attempts, exponential backoff capped at 30 seconds, immutable diagnostics, and audited idempotent operator requeue. Startup reclaims expired leases and never silently skips poison. Tokens and reconnect grace are each bounded at 10 minutes; retention preserves resumable cursors, event rows, and terminal tombstones through both bounds. +- **AC-PLUGINS-PROMPT-HISTORY-HOST-002.17:** A previously authorized consumer that reconnects to a removed session enters the same terminal state as a live removal event: committed rows remain visible, `removed` is true, `hasMore` is false, and later reads and retries make no network request. A fresh unauthorized or nonexistent lookup remains an ordinary not-found response and does not reveal prior existence. + +### REQ-PLUGINS-PROMPT-HISTORY-HOST-003: Task-Panel and Navigation Capabilities + +**Intent:** Make the future plugin appear in the existing task workbench without taking ownership of native navigation or mobile composition. + +#### Acceptance criteria + +- **AC-PLUGINS-PROMPT-HISTORY-HOST-003.1:** Task-panel registration supports an optional localized title, visibility predicate, session kind, and generation-bound message-navigation capability while preserving existing registrations. +- **AC-PLUGINS-PROMPT-HISTORY-HOST-003.2:** The navigation capability accepts a public message ID and returns a typed outcome. It validates session and generation, opens the native chat surface, selects the message, and scrolls through the existing local adapter without exposing transcript stores or scroll refs. +- **AC-PLUGINS-PROMPT-HISTORY-HOST-003.3:** Desktop uses the existing add-panel menu and dockview panel. Native mobile uses grouped Panels bottom navigation, the existing inset picker, a full-height panel, one vertical scroll area, and a distinct 44 px expansion control. +- **AC-PLUGINS-PROMPT-HISTORY-HOST-003.4:** Panel and navigation capabilities fail closed after unmount, task/session switch, plugin reload, disable, uninstall, or generation change. The capability never mutates the chat transcript cache. + +### REQ-PLUGINS-PROMPT-HISTORY-HOST-004: Host-Owned Display Dependencies + +**Intent:** Keep private settings, browser stores, and locale behavior behind small Host-owned capabilities. + +#### Acceptance criteria + +- **AC-PLUGINS-PROMPT-HISTORY-HOST-004.1:** `host.ui.PromptMentionText` renders a curated custom-prompt alias or title through Host-owned settings and localization, without exposing settings or arbitrary markdown rendering. +- **AC-PLUGINS-PROMPT-HISTORY-HOST-004.2:** `host.conversation.useMessageFavorite` observes the existing session-storage favorite store and exposes read-only favorite state with a stable subscribe lifecycle. The plugin cannot access or mutate the store directly. +- **AC-PLUGINS-PROMPT-HISTORY-HOST-004.3:** All new public copy uses the reactive locale API and the five-language catalog contract. Pseudo-locale and mobile previews remain valid. + +### REQ-PLUGINS-PROMPT-HISTORY-HOST-005: Compatibility and Extraction Boundary + +**Intent:** Make the prerequisite package independently adoptable while preventing accidental extraction of the core implementation. + +#### Acceptance criteria + +- **AC-PLUGINS-PROMPT-HISTORY-HOST-005.1:** The SDK contract is runtime-free and assignable to the web internal aliases. Existing plugin registrations remain source-compatible; no public contract imports Kandev stores, React runtime, or backend types. +- **AC-PLUGINS-PROMPT-HISTORY-HOST-005.2:** A fixture plugin consumes only the public SDK and proves message pagination, live add/update/delete, turn completion, removal, favorites, alias display, task navigation, and desktop/mobile panel placement. +- **AC-PLUGINS-PROMPT-HISTORY-HOST-005.3:** Parity verification compares the fixture plugin with the shipped core panel for ordering, filtering inputs, navigation, alias display, favorites, live updates, removal, and mobile behavior. It includes wrong-session, wrong-task, reconnect, gap, poison, and terminal-removal cases. +- **AC-PLUGINS-PROMPT-HISTORY-HOST-005.4:** The package documents the future extraction sequence. It leaves core registrations, layout IDs, stores, locale keys, and core tests untouched; external plugin creation, publication, saved-layout migration, and core removal require a later explicitly approved package. + +## Source inventory + +The implementation plan and system design record the current contact-point +inventory. The public browser contract is canonical in +[`docs/plans/plugins/PLUGIN-API.md`](../../../plans/plugins/PLUGIN-API.md). The +existing UI prompt-history requirements remain authoritative for shipped +product behavior; this document is authoritative only for Host prerequisites. + +## Decision and implementation boundary + +This is a draft prerequisite package, not approval to implement or extract the +plugin. The plan assigns each criterion to one work order and stops after a +fixture parity proof. Any implementation must preserve the Host-owned security, +lifecycle, and transport invariants above. + +See [the system design](../system-design/prompt-history-extraction-host.md) and +[the implementation plan](../../../plans/prompt-history-plugin-host/plan.md). + +The [PR #3588 recovery package](../../../plans/pr-3588-conversation-recovery/plan.md) +repairs conformance with existing criteria 002.2, 002.5-7, 002.9-10, +002.13, 002.15, and 005.3. It adds no requirement IDs. diff --git a/docs/specs/plugins/system-design/conversation-recovery.md b/docs/specs/plugins/system-design/conversation-recovery.md new file mode 100644 index 0000000000..0bc3701ad6 --- /dev/null +++ b/docs/specs/plugins/system-design/conversation-recovery.md @@ -0,0 +1,151 @@ +--- +status: draft +system: plugins +requirements: + - REQ-PLUGINS-PROMPT-HISTORY-HOST-002 + - REQ-PLUGINS-PROMPT-HISTORY-HOST-005 +created: 2026-09-14 +owners: + - kandev +--- + +# Conversation recovery + +## Boundary and references + +This document refines recovery in the +[Host prerequisite design](prompt-history-extraction-host.md). +It uses the existing +[requirements](../requirements/prompt-history-extraction-host.md) and +[browser API contract](../../../plans/plugins/PLUGIN-API.md). +The [repair package](../../../plans/pr-3588-conversation-recovery/plan.md) +owns implementation evidence and work orders. + +The public SDK, error codes, database schema, token TTL, retention, and plugin +ownership remain unchanged. This design does not approve the larger durable +transport decision or complete the outstanding PR review. + +| Requirement | Design section | +| --- | --- | +| REQ-PLUGINS-PROMPT-HISTORY-HOST-002 | Replay grants, Core recovery, Continuation expiry | +| REQ-PLUGINS-PROMPT-HISTORY-HOST-005 | Compatibility and evidence | + +## Replay grants + +A subscription has two distinct positions: snapshot cutoff W and effective +registered cursor C. Replay starts after C. The snapshot grant uses W. +The initial resume grant uses C, never a later event that the client has not +acknowledged. For a fresh or replacement subscription, C equals W. + +The gateway resolves retained gaps and poison delivery before it selects C. +It then registers or replaces the cursor and returns matching grants. +A mint or registration failure must release claimed replay work and must not +publish a successful subscription with inconsistent state. + +`MintSessionStreamGrant` needs explicit internal inputs for these positions. +All call sites must supply them. An ACK at sequence S produces the next resume +grant at S. No public wire field changes. + +`ValidateSessionResume` retains signature, expiry, identity, and lower-bound +checks. `SessionEventLog.Acknowledge` remains the authority for contiguous +advancement, retained events, and poison. Failed ACKs cannot move the cursor. + +## Core recovery + +`WebSocketClient` owns the per-session transport cursor and recovery generation. +The existing core data owners retain message and turn hydration and store writes. +An internal recovery coordinator connects those responsibilities with an +awaitable completion result. It is not a plugin SDK API or a general event bus. + +The recovery states are live, subscribing, hydrating, and live again. +A failure remains recoverable. Removal or disposal terminates the cycle. +Only one cycle can own a session generation. + +1. Pause normal event projection and capture the recovery generation. +2. Request the replacement subscription and validate the response. +3. Make transport readiness available to snapshot requests. +4. Request fresh messages and turns through the existing core read paths. +5. Commit both results only if the session and recovery generation still match. +6. Resume buffered events only after successful reconciliation. + +The subscribe response is not proof of snapshot completion. +A replacement cursor can advance on the server while local projection remains +paused. The client must not treat the skipped interval as repaired until the +snapshot commit succeeds. + +Current `fetchAndStoreMessagesAttempt` awaits subscription readiness and +starts turn hydration without awaiting success. +Current `ensureSessionTurnsLoaded` can return after exhausted retries. +The recovery path must obtain explicit success or failure from both owners. +It must bypass same-generation hydration reuse without breaking normal loading. +Its callbacks must not wait on the recovery completion barrier that they satisfy. + +Reconciliation must repair skipped additions, updates, deletions, and turn +completion. The latest message window does not prove that older cached rows +remain valid. Invalidate or reconcile stale cached windows using existing +pagination ownership. Retain pending local messages under their existing rules. +Keep rich core message fields and active-turn reconciliation semantics. + +Buffer events during reads. Prevent pre-snapshot events from replacing newer +snapshot state. Preserve existing sequence and freshness guards and define the +cutoff handoff explicitly in the implementation tests. +A transport-only unit test is not sufficient proof of this behavior. + +A mounted idle session must recover without visibility change or disconnection. +For a subscribed session without a mounted data owner, retain a dirty generation. +Its next data owner must hydrate before consuming buffered updates. +Dispose abandoned generations and subscriptions without unbounded buffers. + +A failed read must not release projection as healthy. +Use existing bounded retry/backoff and visible error behavior. +Session removal and lifecycle changes cancel stale completion. +Recovery must never mutate plugin page caches. + +## Continuation expiry + +The scope owns expiry decisions. Plugins continue to call `loadMore()` and +`retry()` without transport knowledge. + +Before expiry, renewal retains the original cutoff and query fingerprint. +At known expiry, the Host must not submit the expired tuple for renewal. +It creates one fresh binding/subscription/snapshot cycle through its existing +rebind mechanisms. A fresh authorized snapshot may use a new cutoff. + +Join concurrent recovery requests per scope. Increment the page revision before +invalidating the old cursor. Replace cursor, snapshot token, cutoff, and page +state as one matching revision. Notify message and turn readers through the +existing rebind listeners. Fence old renewal and page responses. + +After fresh hydration, continue the original load-more intent once with the new +cursor if a next page exists. Return the number of newly projected message IDs +relative to the call's initial view. Concurrent callers share that result. +If the refreshed page is exhausted, no continuation request is necessary. + +Keep committed rows until a successful replacement snapshot or terminal response. +On transient recovery failure, preserve state and expose the existing retryable +upstream error. Explicit retry must start a fresh recovery, not reuse the expired +tuple. Bound each automatic recovery cycle to one rebind and one continuation. + +A non-expired token rejected for tampering or query mismatch remains +non-retryable. Do not convert all HTTP 400 responses into retries. +For expiry between dispatch and response, use the captured tuple's expiry to +permit one recovery. Server expiry checks remain strict. +Client/server clock uncertainty must not cause a retry loop. + +An expired resume token on the first idle live event uses the same scope recovery +path instead of leaving `sequenceBlocked` permanent. +Do not increase token lifetime to mask recovery failures. +A timer can optimize renewal, but a suspended tab must recover on demand. + +Removal stops recovery and sets terminal state. +Access revocation stays unauthorized or not found. +No recovery accepts an expired grant as authorization. +Plugin disable, unmount, session switch, or generation change cancels pending work. + +## Compatibility and evidence + +Desktop and mobile use the same repaired data paths and existing controls. +No new public copy, surface, storage, or message type is required. +The original package's tests remain historical evidence. +New RED/GREEN results belong to the +[repair work orders](../../../plans/pr-3588-conversation-recovery/plan.md#work-orders). diff --git a/docs/specs/plugins/system-design/plugins-01.md b/docs/specs/plugins/system-design/plugins-01.md index f8230113eb..bcec2f0fc4 100644 --- a/docs/specs/plugins/system-design/plugins-01.md +++ b/docs/specs/plugins/system-design/plugins-01.md @@ -135,7 +135,7 @@ runtime: linux-amd64: server/plugin-linux-amd64 darwin-arm64: server/plugin-darwin-arm64 # ... any subset; kandev requires the running host's platform key at install time -min_kandev_version: "0.91.1" # required for admin actions +- `min_kandev_version: "0.91.1"` # required for api_read:messages or admin actions capabilities: events: ["task.created", "task.state_changed", "agent.completed"] diff --git a/docs/specs/plugins/system-design/plugins-02.md b/docs/specs/plugins/system-design/plugins-02.md index 9288639b76..aae302c298 100644 --- a/docs/specs/plugins/system-design/plugins-02.md +++ b/docs/specs/plugins/system-design/plugins-02.md @@ -66,6 +66,12 @@ each referenced workspace, task, or repository. It derives a task's workspace server-side. `access` defaults to `authenticated`. `admin` requires `min_kandev_version: "0.91.1"` or later; validation rejects older/missing minimums. Non-admins are rejected before envelope parsing or plugin invocation. +The same centralized minimum-version policy applies when +`capabilities.api_read` includes `messages`, for both Go `Messages().List` and +the browser facade, including development and E2E installs. The policy is +shared by manifest, archive, install, and boot/load validators; missing, +malformed, or lower versions reject, while equal or higher versions pass. +It preserves the `admin` action rule above. Verified context is separate from untrusted JSON; calls are bounded/cancellable and response headers allowlisted. Public `/webhooks/` callbacks cannot serve browser actions. A task action may select one attached persisted repository; other IDs are diff --git a/docs/specs/plugins/system-design/prompt-history-extraction-host.md b/docs/specs/plugins/system-design/prompt-history-extraction-host.md new file mode 100644 index 0000000000..c8bd242bfb --- /dev/null +++ b/docs/specs/plugins/system-design/prompt-history-extraction-host.md @@ -0,0 +1,211 @@ +--- +status: current +system: plugins +requirements: + - REQ-PLUGINS-PROMPT-HISTORY-HOST-001 + - REQ-PLUGINS-PROMPT-HISTORY-HOST-002 + - REQ-PLUGINS-PROMPT-HISTORY-HOST-003 + - REQ-PLUGINS-PROMPT-HISTORY-HOST-004 + - REQ-PLUGINS-PROMPT-HISTORY-HOST-005 +created: 2026-09-06 +owners: + - kandev +--- + +# Prompt History Plugin Host Prerequisites System Design + +## Purpose and boundaries + +This design adds the narrow public browser Host contracts required for a future external prompt-history plugin. The host continues to own authorization, transcript persistence, transport reconciliation, task-panel placement, native navigation, custom-prompt disclosure, and browser-local favorite state. The future plugin owns prompt-list derivation and presentation through those contracts. + +The existing core feature remains the reference implementation. This package does not extract it. The [UI prompt-history requirements](../../ui/requirements/prompt-history-panel.md) remain authoritative for the product outcome; this plugin-system design is authoritative for the prerequisite boundary. + +## Requirement mapping + +|Requirement|Design sections| +|-------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +|`REQ-PLUGINS-PROMPT-HISTORY-HOST-001`|[Task-panel contract](#task-panel-contract), [Scoped transcript navigation](#scoped-transcript-navigation)| +|`REQ-PLUGINS-PROMPT-HISTORY-HOST-002`|[Browser conversation facade](#browser-conversation-facade), [Backend read routes](#backend-read-routes)| +|`REQ-PLUGINS-PROMPT-HISTORY-HOST-003`|[Scoped transcript navigation](#scoped-transcript-navigation)| +|`REQ-PLUGINS-PROMPT-HISTORY-HOST-004`|[Host-owned display dependencies](#host-owned-display-dependencies)| +|`REQ-PLUGINS-PROMPT-HISTORY-HOST-005`|[Compatibility and extraction sequence](#compatibility-and-extraction-sequence), [Verification architecture](#verification-architecture)| + +## Contact-point inventory and classification + +Classification meanings: + +- **sufficient API contract:** a future plugin can consume the existing public contract without depending on Kandev internals; +- **lacking API contract:** behavior exists only as private application code or state; +- **covered but contract change required:** a public or shared contract exists, but lacks fields, scoping, lifecycle, or presentation behavior required for parity. + +### Frontend integration points + +|Current contact point|Current responsibility|Classification|Target| +|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +|`apps/web/components/task/prompt-history-panel-content.tsx` (`PromptHistoryPanelContent`)|Coordinates active session, prompt pages, turns, loading/error/empty state, pagination sentinel, and rows.|lacking API contract|Future plugin composes public conversation hooks; core component stays until extraction.| +|`apps/web/components/task/prompt-history-panel-row.tsx` (`PromptHistoryRow`)|Expansion, timestamps, durations, favorites, prompt mentions, accessible row navigation, touch/fine-pointer sizing.|lacking API contract|`host.ui.PromptMentionText`, `host.conversation.useMessageFavorite`, scoped `openMessage`, existing UI/i18n utilities.| +|`apps/web/lib/prompt-history.ts` (`buildPromptHistoryEntries`)|Filters user prompts, stable ordering, duration bounds, sender-task marker, absolute prompt index.|lacking API contract, but plugin-owned logic|Copy behavior into the future plugin through public DTOs; no host utility is required.| +|`apps/web/hooks/domains/session/use-session-prompts.ts`|Gap-aware initial user-message read and private prompt projection.|lacking API contract|`host.conversation.useSessionMessages`.| +|`apps/web/hooks/use-lazy-load-prompts.ts` and `use-lazy-load-sentinel.ts`|Older-page loading, stale request guards, and intersection rearming.|lacking API contract for data; UI sentinel is plugin-owned|Host hook owns request lifecycle and `loadMore`; plugin may own intersection presentation.| +|`apps/web/lib/state/slices/session/prompt-message-actions.ts`, `session-slice.ts`, and `types.ts`|Private prompt cache, ordering, merge/delete, loading metadata, generation fencing.|lacking API contract|Private implementation behind the Host hook; never exposed to SDK consumers.| +|`apps/web/lib/api/domains/session-api.ts` (`listTaskSessionMessages`, `listSessionTurns`)|First-party REST wrappers.|lacking API contract|Host implementation calls plugin-scoped routes; plugins never import or call these wrappers.| +|`apps/web/lib/ws/handlers/messages.ts` and `lib/types/session-events.ts`|Typed core handling for added/updated/deleted message events.|covered but contract change required|Host facade consumes the transport and publishes stable SDK DTOs; raw `registerWsHandler` remains available but is not the prompt-history contract.| +|`apps/web/hooks/domains/session/use-session-turns.ts` and `turns.bySession`|Private turn hydration and live completion state.|lacking API contract|`host.conversation.useSessionTurns`.| +|`apps/web/components/task/prompt-history-panel-host.tsx` and `lib/state/dockview-store.ts` (`scrollTranscriptToMessage`)|Desktop chat focus/create, around-window target load, and scroll ownership.|lacking API contract|Generation-bound task-panel `conversation.openMessage`.| +|`apps/web/components/task/mobile/session-mobile-layout.tsx` (`handleNavigateToPrompt`, `mobileScrollTarget`)|Mobile switch-to-chat and local scroll target.|lacking API contract|Mobile panel host injects the same scoped navigation capability.| +|`apps/web/hooks/domains/session/use-message-favorite.ts` and `lib/state/slices/message-favorites/`|Reactive per-tab favorite state in `sessionStorage`.|lacking API contract|Read-only `host.conversation.useMessageFavorite`.| +|`apps/web/components/task/chat/messages/prompt-mention-components.tsx` and `hooks/domains/settings/use-custom-prompts.ts`|Prompt alias recognition, previews, live prompt settings, hover-card/drawer adaptation.|lacking API contract|Host-owned `host.ui.PromptMentionText`; custom-prompt data remains private.| +|`apps/packages/plugin-sdk/src/index.ts` (`HostReact`)|Runtime-free React structural type.|covered but contract change required|Add `useLayoutEffect`; do not add a React runtime dependency.| +|`apps/packages/plugin-sdk/src/index.ts`, `apps/web/lib/plugins/types.ts`, `apps/web/lib/plugins/host-api.ts`|Public Host interface and concrete implementation.|lacking API contract|Add `host.conversation` and display dependencies as one mirrored contract.| +|`apps/packages/plugin-sdk/src/index.ts` (`TaskPanelRegistration`, `PluginTaskPanelProps`)|Generic desktop/mobile panel contribution.|covered but contract change required|Add localized title key, visibility context, session kind, and scoped navigation capability.| +|`apps/web/components/task/plugin-task-panel.tsx`, `lib/plugins/registry.ts`, and `lib/state/layout-manager/plugin-panels.ts`|Resolve, render, persist, and revoke generic plugin panels.|sufficient API contract for identity/lifecycle; contract change required for new props|Keep one `plugin-panel` component and extend its context plumbing only.| +|`apps/web/components/task/dockview-add-panel-items.tsx`|Desktop add-panel entry and passthrough filtering for the core panel.|covered but contract change required|Evaluate generic task-panel visibility instead of prompt-history-specific plugin logic.| +|`apps/web/components/task/mobile/plugin-panel-picker.tsx`, `session-mobile-bottom-nav.tsx`, `session-mobile-layout.tsx`|Grouped mobile Panels entry and full-height plugin panels.|sufficient API contract for placement; contract change required for visibility/navigation|Reuse the picker/full-height surface and inject context/capability.| +|`apps/web/lib/state/layout-manager/constants.ts`, serializer, `apps/web/lib/layout/layout-profiles.ts`, `lib/state/layout-manager/plugin-panels.ts`, and layout editor|Built-in `prompt-history` identity and saved layout behavior.|covered but contract change required|Extend reusable-panel validation, serializer/restore, late plugin registration, removal/drop behavior, and editor definitions for `plugin::`. All title reads use the reactive `resolveTaskPanelTitle`; built-in-to-plugin saved-ID migration remains later extraction work.| +|`apps/web/src/locales/*/task.json` prompt-history keys|Core panel copy in five shipped locales.|sufficient core contract, not public plugin API|Future plugin registers its own catalogs through existing `host.i18n`; core keys stay in this package.| +|Existing `host.ui`, `host.i18n`, `host.utils`, and `useResponsiveBreakpoint`|Design-system primitives, translation, relative time, `cn`, and pointer/viewport state.|sufficient API contract|Reuse unchanged except for the prompt-mention component and React lifecycle type above.| + +### Backend integration points + +|Current contact point|Current responsibility|Classification|Target| +|---------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------|---------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +|`internal/task/repository/sqlite/message_prompt_index.go`, `message.go`, and migrations|Atomic durable `prompt_seq`, absolute prompt indexes, deterministic keyset pagination, SQLite/Postgres parity.|sufficient API contract behind services|Reuse unchanged.| +|`internal/task/handlers/message_handlers.go` and `internal/task/dto/dto.go`|First-party session message endpoint with user filtering, order, cursor, around-window, and `prompt_index`.|covered but contract change required|Share parsing/service behavior with a plugin-scoped authenticated read route; do not expose the first-party URL as plugin API.| +|`internal/task/handlers/task_http_handlers.go` and `dto.TurnDTO`|First-party session turn list.|covered but contract change required|Add a plugin-safe route that omits metadata and runtime fields.| +|`internal/task/models.Message.ToAPI` and `pkg/api/v1.Message`|Browser message DTO including metadata and optional raw content.|covered but contract change required|Map a narrower browser-plugin DTO with sanitized content and explicit `senderTaskId`; never reuse raw fields blindly.| +|`internal/task/service.Service` and repository interfaces|Authorized task/session reads and message/turn access.|covered but contract change required|Add a narrow conversation read interface exposing actor/session authorization, plural author filters, deterministic keyset pages, turns, and minimal DTO inputs; plugin handlers call this interface, never SQLite directly.| +|`internal/backendapp/helpers.go`, backend composition, and plugin route registration|Wires plugin handlers with their dependencies.|lacking API contract|Inject the narrow conversation read interface into `internal/plugins` and preserve normal user/workspace authorization; add composition-level tests proving inaccessible sessions do not leak existence.| +|`internal/plugins/host_data.go`, mappers, `pkg/pluginsdk`, `plugin.proto`|Separate Go backend-plugin conversation contract.|unchanged|No browser prompt-history change; it is not used by the UI bundle.| +|Task message/turn event publication and WebSocket session subscription|Sanitized message/turn/removal events.|contract change|`SessionEventStream` is the sole durable per-session log, sequencer, and fanout. Mutation services append transactionally; migrate subject subscriptions and direct broadcasts. Client coalescing preserves sequence, holds gaps, and never publishes a second stream.| + +### Shared types, contracts, and documentation + +|Current contact point|Classification|Target| +|------------------------------------------------------------------------|------------------------------------|-------------------------------------------------------------------------------------------------------| +|`docs/plans/plugins/PLUGIN-API.md`|covered but contract change required|Canonical prose/type contract gains conversation and task-panel additions.| +|`apps/packages/plugin-sdk/src/index.ts`|covered but contract change required|Runtime-free source of consumer types for the same additions.| +|`apps/web/lib/plugins/types.ts`|covered but contract change required|Internal aliases and concrete refinements remain assignable to the SDK.| +|`docs/public/plugins-authoring.md` and `docs/public/plugins-manifest.md`|covered but contract change required|Document browser reads, `api_read:messages`, lifecycle, and minimum host version during implementation.| +|`docs/specs/ui/requirements/prompt-history-panel.md` and system design|sufficient product behavior contract|Remain unchanged; link this prerequisite package.| +|ADR 0047 and ADR 2026-08-01 task-panel contributions|sufficient related decisions|New browser-facade ADR narrows the missing browser boundary without changing either decision.| + +### Current test coverage + +|Coverage|Current evidence|Classification|Planned proof| +|-----------------------------------------------------------------------|----------------------------------------------------------------------------|---------------------------------------|----------------------------------------------------------------------------------------------------------------| +|Prompt ordering, durable indexes, deletion, pagination, SQLite/Postgres|backend prompt-index and message-list repository/handler tests|sufficient existing regression coverage|Keep; add plugin-route mapping/authorization cases only.| +|Prompt derivation and formatting|`apps/web/lib/prompt-history.test.ts`|sufficient core reference|Future plugin parity fixture asserts public DTOs permit the same output; do not move core tests in this package.| +|Prompt panel states, aliases, favorites, accessibility, pagination|`prompt-history-panel-content*.test.tsx`, row tests|sufficient core reference|Add Host facade/component tests through public APIs.| +|WS message add/update/delete and prompt-index mapping|`apps/web/lib/ws/handlers/messages.test.ts`|sufficient transport coverage|Add facade reconciliation and lifecycle tests.| +|Desktop navigation/around-window|dockview renderer, task-chat scroll-target, and load-message-window tests|sufficient core mechanism|Add scoped capability tests and fixture-plugin E2E.| +|Plugin panel registration, restore, lifecycle, and mobile picker|registry, plugin-task-panel, layout manager, and mobile plugin-panel tests|sufficient base coverage|Add title, visibility, session-kind, and navigation-capability cases.| +|End-to-end prompt history and saved layouts|`apps/web/e2e/tests/task/prompt-history-panel.spec.ts`, layout profile specs|sufficient core reference|Add external-style fixture plugin desktop and `mobile-*.spec.ts` parity paths.| + +## Public browser API contract + +[`PLUGIN-API.md`](../../../plans/plugins/PLUGIN-API.md#browser-conversation-facade) is the canonical declaration for the runtime-free TypeScript types, Host-only wire envelopes, retryability mapping, lifecycle, and compatibility policy. The SDK and web Host must remain structurally assignable to that declaration. + +Implementation invariants: + +- Browser conversation state exposes sanitized message and turn DTOs, including non-empty `updatedAt`, nullable task selection, hydration, pagination, retry, and terminal removal state. It never exposes cursors, tokens, sidecars, event payloads, Kandev stores, or React runtime values. +- `taskId: undefined` inherits the active panel task, `null` selects all tasks in the session, and an explicit string must equal the panel task. Mismatches fail before network activity. +- `HostReact` includes structural `useLayoutEffect`; task-panel registration keeps literal `title` as the fallback and adds optional localized title, typed visibility, session kind, and scoped message navigation. +- The Host owns custom-prompt disclosure and read-only favorite observation. Plugins receive only `host.ui.PromptMentionText` and `host.conversation.useMessageFavorite`. +- Persisted update timestamps map to `updatedAt`; a missing row update timestamp falls back to `createdAt`. An unseen timestamp-less add may use `createdAt`, while a timestamp-less update is rejected for snapshot repair. Deletes remain terminal by ID. + +## Browser conversation facade + +`buildHostApi` creates a plugin-scoped conversation implementation alongside `storage`. The task-panel wrapper injects `history` as an opaque, generation-bound handle; its hook IDs must match the wrapper's task/session context, while no panel context yields only the nullable empty state. Hook returns are immutable SDK DTOs; no SDK type imports `AppState`, Zustand, Kandev HTTP types, or session-event payloads. Before import, the loader calls authenticated `GET /api/plugins/{id}/conversation/binding`. Success is `{bindingToken,generation,expiresAt}` with RFC3339 UTC `expiresAt` <=10m; every response, including errors, sends `Cache-Control: no-store`. Plugin-visible errors use the canonical envelope with only `401/unauthenticated/false` or `404/not_found/false`; binding-only `409/generation_superseded/true` stays loader-internal with bounded retry and never reaches plugin code. On `401` or `404` the current load is skipped without disabling persisted state; the loader never imports until binding succeeds. revoked grants rebind or abort. `host.ts` keeps grants Host-only; reload stages grant/import/init, preserving old state until successor commit and fencing only pending state on failure. Disable, uninstall, or replacement revokes old grants. + +Messages are stored internally in ascending deterministic order and projected into requested order. Equal in-flight requests join per scope/query. Cache identity includes panel/plugin ID, session ID, tri-state task ID, author filter, sort, page size, and generation; cursor fingerprints use the same identity. Live matching requires exact session/task equality for inherited or explicit task strings, accepts every task ID for explicit null, and accepts `session.removed` for the selected session regardless of event task ID. Each panel has an independent cache, Host-minted consumer identity, and abort controller. Concurrent calls for one continuation join; exhausted and removed states perform no network activity. Reads never initialize `messages.bySession`. + +## Backend read routes + +Add authenticated plugin-scoped routes before the wildcard webhook routes: + +```text +GET /api/plugins/{pluginId}/conversation/task-sessions/{sessionId}/messages +GET /api/plugins/{pluginId}/conversation/task-sessions/{sessionId}/turns +GET /api/plugins/{pluginId}/conversation/binding +POST /api/plugins/{pluginId}/conversation/continuation/renew +``` + +Queries bound `limit` (default 20, max 100), opaque keyset `cursor`, repeated supported `author_type`, and `sort`; invalid/empty filters return `400`. Public responses are `{ messages, hasMore, cursor }` with `cursor: string | null` and `{ turns }`; `cursor` is null when no next page exists. Exact Host-only wire request/response and event payload DTOs are canonical in the `PLUGIN-API.md` Host-only conversation wire contract. Internally responses carry `snapshotCutoff` and row-sequence sidecars. The Host sends the subscribe ACK's binding `snapshot_token` in the Host-only `X-Kandev-Snapshot-Token` header; server validation applies the committed cutoff and `rowSequence <= cutoff` to every page, seeking the cursor key directly. The cursor encodes `(created_at,id,direction,cutoff,fingerprint,expiry,generation)` and rejects tampering or mismatch with `400`; it is never an offset. Before 80% expiry Host-only `continuation/renew` accepts cursor/token and returns the same cutoff/fingerprint with new expiry; renewal never picks a new cutoff. Renewal success and error responses use `Cache-Control: no-store`. If retention/generation cannot preserve it, the Host-only renew returns `409`, surfaced to plugin code as retryable `upstream_failure`; `loadMore` leaves state/cursor unchanged and explicit retry starts fresh. Each panel scope has monotonic `continuation_revision` sentinel `0` over `{cursor,snapshot_token,cutoff,fingerprint,generation}`. Renewal and `loadMore` serialize per scope and CAS-install matching revisions; page, cursor, and `hasMore` commit atomically. Stale responses drop; failed renewal leaves the tuple unchanged; close/retry invalidates it. + +Both handlers: + +1. require an authenticated Kandev identity; +2. resolve an active installed plugin and require `api_read:messages`; +3. resolve the task session through the normal user/workspace authorization boundary; +4. call task services and repository interfaces, not SQL; +5. map to the narrow camel-case browser-plugin DTO; +6. strip Kandev system content and omit raw content and arbitrary metadata; +7. return stable error codes for authentication, capability, input, inaccessible session, and server failures. + +Handlers use a conversation-specific resolver: absent, inactive, and capability-missing plugins collapse to sanitized `404` before session authorization, unlike `activeRecord`'s `503`. All responses use `{"error":{"code":"...","message":"...","retryable":false|true}}`; the stable errors are `401/unauthenticated/false`, `404/not_found/false`, `400/invalid_query/false`, and authorized `5xx/upstream_failure/true`. Binding and continuation-renewal success and error responses use `Cache-Control: no-store`. A narrow exact-path auth error-writer branch emits the same `401` envelope; no broad plugin-route exemption is allowed. + +Message mapping reads only the allowlisted `metadata.sender_task_id` key; non-empty string values become `senderTaskId`, while missing, empty, or non-string values omit that field. Turns omit metadata/runtime; first-party REST and Go RPC contracts remain unchanged. + +`SessionEventStream` owns session sequencing and fanout for message, turn, and removal events. Subscribe is discriminated: a core request requires the Host-minted `wire_id`; a plugin request requires `plugin_id`, `generation`, `binding_token`, and its Host-minted `consumer_id`. Identity fields are mutually exclusive. Bindings check user, capability, and generation, and registration atomically captures the event watermark. + +The exact Host-only subscribe/ACK envelopes, legacy parsing rules, result states, and token field presence are canonical in the `PLUGIN-API.md` Host-only conversation wire contract. `SessionEventStream` validates outer IDs, session/binding identity, integer ranges, contiguous ACK advancement, and current/prior retry tokens. It binds resume and snapshot tokens to user, plugin, session, consumer, generation, and cutoff; snapshot commit precedes ACK and releases buffered post-cutoff events. `fresh`, `replay`, `gap`, and `invalid_resume` retain their documented reconciliation semantics. Events use `{type:"session.event",protocol_version,event_type,session_id,task_id, sequence,event_id,payload}`. Canonical policy is `{1:{ignorable:["session.workspace_sources.updated"]}}`; only a type listed by its matching version may advance ACK without projection. Missing, malformed, or unsupported versions and other unknown types remain durable poison: the cursor stays before the event, diagnostics retain its ID/version, and advancement stops. Rebind atomically snapshots past poison at a new cutoff, commits that cutoff, and returns a replacement token/cursor; the poison is not projected. `session.removed` is terminal regardless of whether it precedes or follows poison, and never becomes a legacy data update. + +Snapshot reads are current-state reads captured at committed cutoff, not an as-of query. `SessionMessageVersion` and `SessionTurnVersion` are immutable, keyed/indexed by `(entity_id,row_sequence)` plus session/task identity; each entity selects maximum `row_sequence <= cutoff`. A tombstone at or below cutoff excludes its entity, while a later tombstone remains post-cutoff; apply the terminal session barrier after selection. The adapter strips sidecars, seeds row sequences, buffers post-cutoff events, deduplicates reflected rows, then replays. Core delivery uses a per-wire `legacy-core/` cursor: reconnecting the same wire resumes its cursor, a new wire starts at its registration watermark, and each wire gets at most one projection per `event_id`/sequence. Projection precedes ACK; `message.added|updated|deleted` and `session.turn.started|session.turn.completed` map to existing core notifications, while removal is terminal. Merge uses ID with sequence as ordering/fencing authority; `updatedAt` is freshness only. + +The durable append API returns `appended`, `duplicate`, `late_terminal`, or `sequence_conflict`; identity/payload conflicts are poison and late-terminal events drop after fencing. Version, tombstone, and log rows allocate one locked per-session sequence in one transaction; immutable `SessionEventLog` rows and `SessionDeliveryCursor` rows hold ACK, identity, epoch, lease/retry/generation. `SessionDeliveryDispatcher` owns poison: 30s lease, five attempts, 30s-capped backoff. Host-only `session.event.poison.requeue` requires `session_events:requeue`, audits actor/epoch, is idempotent, and never auto-skips. Startup reclaims leases. Tokens and reconnect grace are 10m; GC and tombstones through both bounds. Bus publication is not replay truth. + +## Test-only transition controls + +The fixture exposes response-bounded message patch/delete and turn-completion controls. Handlers persist before typed events; completion takes RFC3339. Fixture source is `apps/web/e2e/fixtures/plugins/prompt-history-plugin/`; `e2e-plugin-ui` cleans/builds before packaging, and archive checks source/output hashes, capability, min version, and panel key. + +Deletes and session removal are terminal barriers. All production callers, including plugin `PluginOwnedTaskTree`, workspace resource/task FK cascades, task/session repositories, handoff/task-agent cascades, quick-chat expiry, profile/automation/office cleanup, workflow replacement/compensation, reference cleanup, and MCP handlers invoke one transaction-aware `SessionDeletionService`. It locks/captures sessions, reserves terminal sequences, and inserts unique outbox rows before deletion/commit; pre-listing, standalone deletes, and unobserved FK cascades are forbidden. Partial cascades, rollback, retries, and duplicates are idempotent; leased post-commit dispatch handles startup drain, recovery, backoff, requeue, duplicate ACK, and poison. + +## Task-panel contract + +`visible` receives typed context only; omitted predicates stay visible. With no task, desktop/mobile choices hide plugin panels without calling predicates; restored panels are unavailable until context exists. Predicate exceptions are caught, logged with plugin/panel identity, and treated as hidden. `resolveTaskPanelTitle(registration,i18n)` drives menus, tabs, restored layouts, and previews; locale changes update titles without changing panel identity. `PluginTaskPanel` passes current context and revokes its generation-bound conversation capability on plugin/panel/task/session/presentation changes. + +## Scoped transcript navigation + +The task-panel capability exposes only `openMessage(messageId)`. `accepted` means a current mounted adapter validated the ID/context and queued asynchronous native processing; it does not mean the target was found. `unavailable` covers empty ID, stale/unmounted context, missing adapter, or mismatch. Target loading, failure, deletion, supersession, and timeout remain native Chat outcomes. + +Desktop delegates to `scrollTranscriptToMessage`; mobile delegates to `MobileSessionLayout`'s local `mobileScrollTarget` and Chat switch. Neither path globalizes mobile state. The adapter validates identity before returning and Chat owns around-window loading, scroll completion, timeout, and token clearing. + +## Host-owned display dependencies + +`useMessageFavorite` exposes only existing boolean favorite state. `host.ui.PromptMentionText` owns alias loading, Markdown, and fine/coarse disclosure; the plugin owns list markup, expansion, duration, formatting, translation, and icons through host UI/i18n/responsive utilities. The surface is a persistent full-height panel with one `min-h-0 flex-1` scroller; picker remains a temporary inset drawer. Shared data, pagination, derivation, state, and navigation remain shared; desktop/mobile vary only in entry, hit areas, and preview hover-card versus drawer. Touch targets are at least 44 px; Pixel 5 coverage proves older loading, preview, navigation, and no overflow. + +## Security and privacy + +Browser routes are authenticated/capability-gated; plugins remain same-origin, not a hostile sandbox. Routes return authorized sanitized data only, never system blocks, `raw_content`, metadata, runtime configuration, credentials, or content values in logs. + +## Compatibility and extraction sequence + +All API additions are optional/additive under the current plugin API version: + +- old task-panel registrations omit `titleKey` and `visible`; +- old task-panel components ignore added props; +- any `api_read:messages` manifest requires `min_kandev_version: "0.91.1"`; one validator enforces this in manifest/archive/install, including dev/E2E. +- existing REST and Go RPC contracts remain unchanged; + +Required sequence: + +1. land and verify this host prerequisite package while the core panel remains active; +2. in a separate repository/package, implement the plugin through the public SDK only and prove parity; +3. in a later Kandev extraction package, migrate saved built-in panel identities, remove core panel/state/tests/locales, and update ownership documentation; +4. publish/enable the plugin according to the release decision for that later package. + +## Verification architecture + +Each work order uses RED-GREEN-REFACTOR. Backend tests cover auth, capability, authorization, filters/cursors, sanitized DTOs, event ordering, deletion, outbox, and restart. Frontend tests cover readiness, deterministic merge, stale generations, reconnect, lifecycle abort, typed failures, panel title/visibility/context/navigation, and host UI privacy. SDK assignability proves runtime-free types match the Host; desktop/mobile fixture Playwright uses public APIs and keeps the core prompt-history E2E green. + +## Related decisions + +Recovery details are in [Conversation recovery](conversation-recovery.md). +The [PR #3588 repair package](../../../plans/pr-3588-conversation-recovery/plan.md) +tracks replay grants, core snapshot recovery, and continuation expiry. +This supplement does not approve the broader durable transport decision. + +- [ADR 0047: Plugins read conversation content via a capability-gated Host RPC](../../../decisions/0047-plugin-host-conversation-reads.md) +- [ADR: Browser plugin conversation facade](../../../decisions/2026-09-06-browser-plugin-conversation-facade.md) +- [ADR: Plugin task panel contributions](../../../decisions/2026-08-01-plugin-task-panel-contributions.md) +- [ADR: Plugin contribution lifecycle authority](../../../decisions/2026-08-04-plugin-contribution-lifecycle-authority.md) diff --git a/docs/specs/ui/requirements/prompt-history-panel.md b/docs/specs/ui/requirements/prompt-history-panel.md index 0a7b519581..1dee53283f 100644 --- a/docs/specs/ui/requirements/prompt-history-panel.md +++ b/docs/specs/ui/requirements/prompt-history-panel.md @@ -35,3 +35,11 @@ Reviewing what was asked of an agent requires scrolling the transcript; past pro ## System design The migrated technical source is split into [part 1](../system-design/prompt-history-panel.md). + +## Related plugin prerequisites + +The public Host contracts required before a later external plugin extraction +are owned by +[Prompt History Plugin Host Prerequisites](../../plugins/requirements/prompt-history-extraction-host.md). +The core panel remains authoritative until a separate extraction package +migrates product ownership. diff --git a/docs/specs/ui/system-design/prompt-history-panel.md b/docs/specs/ui/system-design/prompt-history-panel.md index 51983c3ff9..f02da6a187 100644 --- a/docs/specs/ui/system-design/prompt-history-panel.md +++ b/docs/specs/ui/system-design/prompt-history-panel.md @@ -132,3 +132,9 @@ The field is persisted per user message at creation: `prompt_seq` is allocated f ## Open questions None. + +## Related plugin prerequisites + +The current core implementation remains authoritative. The typed browser Host +boundary required before an external plugin can replace it is defined by +[Prompt History Plugin Host Prerequisites](../../plugins/system-design/prompt-history-extraction-host.md).