From e0fceff3c401675e58707e4fbc92cb175e77d30b Mon Sep 17 00:00:00 2001 From: cfal Date: Sun, 6 Sep 2026 21:19:03 +0000 Subject: [PATCH 1/5] feat: add durable per-chat preamble selections --- .../chat-preamble-selection-contracts.test.js | 352 +++++++++++ common/__tests__/preambles.test.js | 22 +- .../transcript-notice-contract.test.js | 39 +- common/__tests__/ws-events.test.js | 24 + common/chat-command-contracts.ts | 69 ++- common/chat-fork-command-parsing.ts | 45 ++ common/chat-preamble-selection-contracts.ts | 401 +++++++++++++ common/error-codes.ts | 7 + common/package.json | 1 + common/preambles.ts | 185 +++++- common/transcript-notice-details.ts | 68 ++- common/ws-events.ts | 27 + integration-tests/support/garcon-client.ts | 4 + .../carryover-bootstrap-migration.test.ts | 1 + .../tests/server/per-chat-preambles.test.ts | 402 +++++++++++++ server/__tests__/server-event-wiring.test.js | 36 ++ .../__tests__/registry-session-cache.test.js | 359 +++++++----- server/agents/fork-native-history-reader.ts | 30 + server/agents/index.ts | 1 + server/agents/registry.ts | 71 ++- server/agents/session-types.ts | 5 +- .../__tests__/queue-drainer.test.js | 60 ++ .../chat-execution/accepted-input-handler.ts | 6 +- .../chat-execution-coordinator.ts | 3 + server/chat-execution/queue-drainer.ts | 47 +- .../__tests__/agent-ownership-journal.test.js | 30 + server/chats/__tests__/fork-chat.test.js | 9 +- .../chats/__tests__/share-transcript.test.js | 34 +- server/chats/__tests__/store.test.js | 231 +++++++- server/chats/fork-chat.ts | 7 + server/chats/registry-entry-codec.ts | 303 ++++++++++ server/chats/share-transcript.ts | 11 +- server/chats/store.ts | 498 +++++++--------- .../__tests__/markdown.test.js | 27 +- .../transcript-export/__tests__/xml.test.js | 28 +- server/chats/transcript-export/markdown.ts | 9 +- server/chats/transcript-export/values.ts | 40 +- server/chats/transcript-export/xml.ts | 13 +- .../__tests__/chat-command-service.test.js | 3 + .../__tests__/self-handoff-commands.test.js | 2 + server/commands/command-support.ts | 25 +- server/commands/self-handoff-commands.ts | 6 + server/commands/start-commands.ts | 27 +- .../__tests__/codec-user-presentation.test.js | 24 +- server/ledger/__tests__/export-fold.test.js | 5 +- .../__tests__/native-history-seed.test.js | 11 +- .../ledger/__tests__/preamble-history.test.js | 32 +- .../ledger/__tests__/read-fold-matrix.test.js | 5 +- server/ledger/__tests__/reload.test.js | 13 +- server/ledger/__tests__/service.test.js | 5 +- server/ledger/codec.ts | 36 +- server/ledger/connection-entry.ts | 23 + server/ledger/connection-setup.ts | 165 ++++++ server/ledger/contracts.ts | 80 ++- server/ledger/preamble-application.ts | 13 + server/ledger/presentation.ts | 14 + server/ledger/producer-lease.ts | 31 + server/ledger/service.ts | 55 +- server/ledger/store.ts | 237 +++----- server/lib/json-file-store.ts | 30 +- .../__tests__/chat-selection-service.test.js | 547 ++++++++++++++++++ server/preambles/__tests__/selection.test.js | 184 ++++++ server/preambles/__tests__/service.test.js | 39 +- server/preambles/__tests__/store-v2.test.js | 323 +++++++++++ server/preambles/__tests__/store.test.js | 52 +- server/preambles/boundary.ts | 2 +- server/preambles/chat-selection-service.ts | 389 +++++++++++++ server/preambles/selection.ts | 144 +++++ server/preambles/service.ts | 83 ++- server/preambles/setup.ts | 29 + server/preambles/store.ts | 273 +++++++-- .../routes/__tests__/chat-preambles.test.js | 203 +++++++ .../__tests__/chat-routes-test-utils.js | 1 + server/routes/__tests__/preambles.test.js | 16 +- server/routes/chat-preambles.ts | 205 +++++++ server/routes/index.ts | 8 + server/routes/preambles.ts | 21 +- server/server-event-wiring.ts | 35 ++ server/server.ts | 50 +- 79 files changed, 5990 insertions(+), 961 deletions(-) create mode 100644 common/__tests__/chat-preamble-selection-contracts.test.js create mode 100644 common/chat-fork-command-parsing.ts create mode 100644 common/chat-preamble-selection-contracts.ts create mode 100644 integration-tests/tests/server/per-chat-preambles.test.ts create mode 100644 server/agents/fork-native-history-reader.ts create mode 100644 server/chats/registry-entry-codec.ts create mode 100644 server/ledger/connection-entry.ts create mode 100644 server/ledger/connection-setup.ts create mode 100644 server/ledger/producer-lease.ts create mode 100644 server/preambles/__tests__/chat-selection-service.test.js create mode 100644 server/preambles/__tests__/selection.test.js create mode 100644 server/preambles/__tests__/store-v2.test.js create mode 100644 server/preambles/chat-selection-service.ts create mode 100644 server/preambles/selection.ts create mode 100644 server/routes/__tests__/chat-preambles.test.js create mode 100644 server/routes/chat-preambles.ts diff --git a/common/__tests__/chat-preamble-selection-contracts.test.js b/common/__tests__/chat-preamble-selection-contracts.test.js new file mode 100644 index 0000000000..95353ee748 --- /dev/null +++ b/common/__tests__/chat-preamble-selection-contracts.test.js @@ -0,0 +1,352 @@ +import { describe, expect, it } from 'bun:test'; +import { + CHAT_PREAMBLE_SELECTION_BODY_MAX_BYTES, + parseChatPreambleSelectionTargetResponse, + parsePreambleSelectionPartialError, + parsePreambleSelectionPreviewRequest, + parsePreambleSelectionPreviewResponse, + parseUpdateChatPreambleSelectionRequest, + parseUpdateChatPreambleSelectionResponse, +} from '../chat-preamble-selection-contracts.js'; + +const ID_A = '3502b645-222b-49d2-ac39-1c91f9fb1174'; +const ID_B = '80becfa6-c9c7-4b31-9190-fd23c0bedf9c'; +const ID_C = '936903ad-8b98-43eb-a7d4-c17ce0dc18d8'; +const VIEW = '12345678-1234-4123-8123-123456789abc'; + +describe('chat preamble selection contracts', () => { + it('parses a strict update request and rejects malformed identities', () => { + expect(parseUpdateChatPreambleSelectionRequest({ + chatId: '1783725900000200', + transcriptViewId: VIEW, + clientRequestId: 'req-1', + clientMessageId: 'msg-1', + expectedRevision: 3, + orderedPreambleIds: [ID_B, ID_A], + })).toEqual({ + chatId: '1783725900000200', + transcriptViewId: VIEW, + clientRequestId: 'req-1', + clientMessageId: 'msg-1', + expectedRevision: 3, + orderedPreambleIds: [ID_B, ID_A], + }); + expect(() => parseUpdateChatPreambleSelectionRequest({ + chatId: '1783725900000200', + transcriptViewId: VIEW, + clientRequestId: 'req-1', + clientMessageId: 'msg-1', + expectedRevision: 3, + orderedPreambleIds: [ID_A, ID_A], + })).toThrow(); + expect(() => parseUpdateChatPreambleSelectionRequest({ + chatId: '1783725900000200', + transcriptViewId: VIEW, + clientRequestId: 'req-1', + clientMessageId: 'msg-1', + expectedRevision: 3, + orderedPreambleIds: ['not-a-uuid'], + })).toThrow(); + expect(() => parseUpdateChatPreambleSelectionRequest({ + chatId: '1783725900000200', + transcriptViewId: VIEW, + clientRequestId: 'req-1', + clientMessageId: 'msg-1', + expectedRevision: -1, + orderedPreambleIds: [], + })).toThrow(); + expect(() => parseUpdateChatPreambleSelectionRequest({ + chatId: '1783725900000200', + transcriptViewId: VIEW, + clientRequestId: 'req-1', + clientMessageId: 'msg-1', + expectedRevision: 3, + orderedPreambleIds: [], + extra: true, + })).toThrow('extra is not supported'); + // Arbitrary nonblank identities and non-chat IDs reject. + expect(() => parseUpdateChatPreambleSelectionRequest({ + chatId: 'not-a-chat-id', + transcriptViewId: VIEW, + clientRequestId: 'req-1', + clientMessageId: 'msg-1', + expectedRevision: 3, + orderedPreambleIds: [], + })).toThrow(); + expect(() => parseUpdateChatPreambleSelectionRequest({ + chatId: '1783725900000200', + transcriptViewId: 'not-a-view', + clientRequestId: 'req-1', + clientMessageId: 'msg-1', + expectedRevision: 3, + orderedPreambleIds: [], + })).toThrow(); + expect(() => parsePreambleSelectionPreviewRequest({ + projectPath: '/repo', + unexpected: 1, + })).toThrow('unexpected is not supported'); + }); + + it('requires canonical UUID order in preview requests but allows omitted IDs', () => { + expect(parsePreambleSelectionPreviewRequest({ projectPath: '/repo' })) + .toEqual({ projectPath: '/repo' }); + expect(parsePreambleSelectionPreviewRequest({ + projectPath: '/repo', + orderedPreambleIds: [], + })).toEqual({ projectPath: '/repo', orderedPreambleIds: [] }); + expect(() => parsePreambleSelectionPreviewRequest({ + projectPath: '/repo', + orderedPreambleIds: [ID_A.toUpperCase()], + })).toThrow(); + expect(() => parsePreambleSelectionPreviewRequest({})).toThrow(); + }); + + it('parses target, update, and preview responses strictly', () => { + const projection = { + catalogRevision: 2, + eligiblePreambles: [{ id: ID_A, title: 'Repository conventions' }], + unavailable: [{ id: ID_B, reason: 'disabled' }], + }; + expect(parseChatPreambleSelectionTargetResponse({ + success: true, + chatId: '1783725900000200', + transcriptViewId: VIEW, + canonicalProjectPath: '/repo', + selection: { revision: 1, orderedPreambleIds: [ID_A, ID_B] }, + projection, + })).toEqual({ + success: true, + chatId: '1783725900000200', + transcriptViewId: VIEW, + canonicalProjectPath: '/repo', + selection: { revision: 1, orderedPreambleIds: [ID_A, ID_B] }, + projection, + }); + // A projection that does not exactly partition the selection rejects. + expect(parseChatPreambleSelectionTargetResponse({ + success: true, + chatId: '1783725900000200', + transcriptViewId: VIEW, + canonicalProjectPath: '/repo', + selection: { revision: 1, orderedPreambleIds: [ID_A, ID_B] }, + projection: { + catalogRevision: 2, + eligiblePreambles: [{ id: ID_A, title: 'Repository conventions' }], + unavailable: [], + }, + })).toBeNull(); + + expect(parseChatPreambleSelectionTargetResponse({ + success: true, + chatId: '1783725900000200', + transcriptViewId: VIEW, + canonicalProjectPath: '/repo', + selection: { revision: 1, orderedPreambleIds: [ID_A, ID_B, ID_C] }, + projection: { + catalogRevision: 2, + eligiblePreambles: [ + { id: ID_A, title: 'Repository conventions' }, + { id: ID_C, title: 'Security constraints' }, + ], + unavailable: [{ id: ID_B, reason: 'missing' }], + }, + })).not.toBeNull(); + expect(parseChatPreambleSelectionTargetResponse({ + success: true, + chatId: '1783725900000200', + transcriptViewId: VIEW, + selection: { revision: 1, orderedPreambleIds: [ID_A, ID_A] }, + projection, + })).toBeNull(); + + const partitioned = { + catalogRevision: 2, + eligiblePreambles: [{ id: ID_A, title: 'Repository conventions' }], + unavailable: [{ id: ID_B, reason: 'disabled' }], + }; + expect(parseUpdateChatPreambleSelectionResponse({ + success: true, + commandType: 'chat-preambles-update', + clientRequestId: 'req-1', + clientMessageId: 'msg-1', + chatId: '1783725900000200', + transcriptViewId: VIEW, + status: 'updated', + mutationRevision: 2, + noticeOrdinal: 5, + selection: { revision: 2, orderedPreambleIds: [ID_A, ID_B] }, + projection: partitioned, + })).toMatchObject({ status: 'updated', mutationRevision: 2, noticeOrdinal: 5 }); + expect(parseUpdateChatPreambleSelectionResponse({ + success: true, + commandType: 'chat-preambles-update', + clientRequestId: 'req-1', + clientMessageId: 'msg-1', + chatId: '1783725900000200', + transcriptViewId: VIEW, + status: 'bogus', + mutationRevision: 2, + noticeOrdinal: null, + selection: { revision: 2, orderedPreambleIds: [ID_A, ID_B] }, + projection: partitioned, + })).toBeNull(); + + expect(parsePreambleSelectionPreviewResponse({ + success: true, + canonicalProjectPath: '/repo', + orderedPreambleIds: [ID_A, ID_B], + projection, + })).toEqual({ + success: true, + canonicalProjectPath: '/repo', + orderedPreambleIds: [ID_A, ID_B], + projection, + }); + // Unbounded, duplicate, or mispartitioned preview IDs reject. + expect(parsePreambleSelectionPreviewResponse({ + success: true, + canonicalProjectPath: '/repo', + orderedPreambleIds: [ID_A, ID_A], + projection, + })).toBeNull(); + expect(parsePreambleSelectionPreviewResponse({ + success: true, + canonicalProjectPath: '/repo', + orderedPreambleIds: [ID_A, ID_B], + projection: { + catalogRevision: 2, + eligiblePreambles: [{ id: ID_A, title: 'Repository conventions' }], + unavailable: [], + }, + })).toBeNull(); + }); + + it('rejects unknown keys, malformed identities, and inconsistent update outcomes', () => { + const projection = { + catalogRevision: 2, + eligiblePreambles: [{ id: ID_A, title: 'Repository conventions' }], + unavailable: [], + }; + const target = { + success: true, + chatId: '1783725900000200', + transcriptViewId: VIEW, + canonicalProjectPath: '/repo', + selection: { revision: 2, orderedPreambleIds: [ID_A] }, + projection, + }; + expect(parseChatPreambleSelectionTargetResponse({ ...target, extra: true })).toBeNull(); + expect(parseChatPreambleSelectionTargetResponse({ ...target, chatId: 'chat-1' })).toBeNull(); + expect(parseChatPreambleSelectionTargetResponse({ ...target, transcriptViewId: 'view-1' })) + .toBeNull(); + + const update = { + success: true, + commandType: 'chat-preambles-update', + clientRequestId: 'req-1', + clientMessageId: 'msg-1', + chatId: target.chatId, + transcriptViewId: VIEW, + status: 'updated', + mutationRevision: 2, + noticeOrdinal: 5, + selection: target.selection, + projection, + }; + expect(parseUpdateChatPreambleSelectionResponse({ ...update, extra: true })).toBeNull(); + expect(parseUpdateChatPreambleSelectionResponse({ ...update, clientRequestId: ' req-1' })) + .toBeNull(); + expect(parseUpdateChatPreambleSelectionResponse({ + ...update, + selection: { revision: 3, orderedPreambleIds: [ID_A] }, + })).toBeNull(); + expect(parseUpdateChatPreambleSelectionResponse({ + ...update, + status: 'unchanged', + noticeOrdinal: null, + selection: { revision: 3, orderedPreambleIds: [ID_A] }, + })).toBeNull(); + expect(parseUpdateChatPreambleSelectionResponse({ + ...update, + status: 'duplicate', + noticeOrdinal: null, + })).toBeNull(); + expect(parseUpdateChatPreambleSelectionResponse({ + ...update, + status: 'duplicate', + mutationRevision: 3, + })).toBeNull(); + + expect(parsePreambleSelectionPreviewResponse({ + success: true, + canonicalProjectPath: '/repo', + orderedPreambleIds: [ID_A], + projection, + extra: true, + })).toBeNull(); + }); + + it('parses typed partial errors with committed state and rejects mismatches', () => { + expect(parsePreambleSelectionPartialError({ + success: false, + errorCode: 'PREAMBLE_SELECTION_NOTICE_FAILED', + message: 'The selection was saved, but its transcript notice could not be recorded.', + retryable: false, + selectionCommitted: true, + selection: { revision: 4, orderedPreambleIds: [] }, + })).toEqual({ + success: false, + errorCode: 'PREAMBLE_SELECTION_NOTICE_FAILED', + message: 'The selection was saved, but its transcript notice could not be recorded.', + retryable: false, + selectionCommitted: true, + selection: { revision: 4, orderedPreambleIds: [] }, + }); + expect(parsePreambleSelectionPartialError({ + success: false, + errorCode: 'PREAMBLE_SELECTION_SAVE_UNKNOWN', + message: 'unknown', + retryable: false, + selectionCommitted: 'unknown', + })).toMatchObject({ selectionCommitted: 'unknown' }); + expect(parsePreambleSelectionPartialError({ + success: false, + errorCode: 'PREAMBLE_SELECTION_REVISION_CONFLICT', + message: 'conflict', + retryable: true, + selectionCommitted: true, + })).toBeNull(); + expect(parsePreambleSelectionPartialError({ + success: false, + errorCode: 'PREAMBLE_SELECTION_SAVE_UNKNOWN', + message: 'unknown', + retryable: true, + selectionCommitted: 'unknown', + })).toBeNull(); + expect(parsePreambleSelectionPartialError({ + success: false, + errorCode: 'PREAMBLE_SELECTION_NOTICE_FAILED', + message: 'x', + retryable: false, + })).toBeNull(); + expect(parsePreambleSelectionPartialError({ + success: false, + errorCode: 'PREAMBLE_SELECTION_NOTICE_FAILED', + message: 'notice failed', + retryable: false, + selectionCommitted: true, + })).toBeNull(); + expect(parsePreambleSelectionPartialError({ + success: false, + errorCode: 'PREAMBLE_SELECTION_SAVE_UNKNOWN', + message: 'unknown', + retryable: false, + selectionCommitted: 'unknown', + extra: true, + })).toBeNull(); + }); + + it('advertises the request body limit used by the routes', () => { + expect(CHAT_PREAMBLE_SELECTION_BODY_MAX_BYTES).toBe(32 * 1024); + }); +}); diff --git a/common/__tests__/preambles.test.js b/common/__tests__/preambles.test.js index eee67ed60a..47020caf73 100644 --- a/common/__tests__/preambles.test.js +++ b/common/__tests__/preambles.test.js @@ -21,6 +21,15 @@ import { const CREATED_AT = '2026-09-03T10:00:00.000Z'; +// Deterministic canonical UUID v4 fixtures. +const TEST_ID_ONE = '3502b645-222b-49d2-ac39-1c91f9fb1174'; +const TEST_ID_TWO = '80becfa6-c9c7-4b31-9190-fd23c0bedf9c'; + +function testIdForIndex(index) { + const suffix = `a${(index % 4096).toString(16).padStart(3, '0')}`; + return `${TEST_ID_ONE.slice(0, 19)}${suffix}${TEST_ID_ONE.slice(23)}`; +} + function definition(overrides = {}) { return { enabled: true, @@ -111,14 +120,19 @@ describe('preamble contracts', () => { }); it('normalizes snapshots defensively and rejects duplicate IDs or excess entries', () => { - const snapshot = normalizePreamblesSnapshot({ revision: 2, preambles: [preamble('a')] }); - expect(snapshot).toEqual({ revision: 2, preambles: [preamble('a')] }); - expect(normalizePreamblesSnapshot({ revision: 2, preambles: [preamble('a'), preamble('a')] })).toBeNull(); + const snapshot = normalizePreamblesSnapshot({ revision: 2, preambles: [preamble(TEST_ID_ONE)] }); + expect(snapshot).toEqual({ revision: 2, preambles: [preamble(TEST_ID_ONE)] }); + expect(normalizePreamblesSnapshot({ revision: 2, preambles: [preamble(TEST_ID_ONE), preamble(TEST_ID_ONE)] })).toBeNull(); expect(normalizePreamblesSnapshot({ revision: 2, - preambles: Array.from({ length: PREAMBLE_MAX_COUNT + 1 }, (_, index) => preamble(String(index))), + preambles: Array.from( + { length: PREAMBLE_MAX_COUNT + 1 }, + (_, index) => preamble(testIdForIndex(index)), + ), })).toBeNull(); expect(normalizePreamblesSnapshot({ revision: 2, preambles: [], extra: true })).toBeNull(); + expect(normalizePreamblesSnapshot({ revision: 2, preambles: [preamble('not-a-uuid')] })).toBeNull(); + expect(normalizePreamblesSnapshot({ revision: 2, preambles: [preamble(TEST_ID_ONE.toUpperCase())] })).toBeNull(); }); it('parses only complete mutation responses and pending boundaries', () => { diff --git a/common/__tests__/transcript-notice-contract.test.js b/common/__tests__/transcript-notice-contract.test.js index e4f35bd47f..dcebd87053 100644 --- a/common/__tests__/transcript-notice-contract.test.js +++ b/common/__tests__/transcript-notice-contract.test.js @@ -10,18 +10,53 @@ import { isHandoffSummaryNoticeDetail, isInterAgentMessageOutcomeNoticeDetail, isInterAgentMessageReceivedNoticeDetail, + isPreambleSelectionChangedNoticeDetail, parseTranscriptNoticeDetail, } from '../transcript-notice-details.ts'; const AT = '2026-08-16T00:00:00.000Z'; +describe('preamble selection-changed notice contracts', () => { + it('[PREAMBLE-SELECTION.01-CONTRACT-01] round-trips the update notice and permits zero entries', () => { + const empty = { type: 'preamble-selection-changed', preambles: [] }; + const populated = { + type: 'preamble-selection-changed', + preambles: [{ id: '3502b645-222b-49d2-ac39-1c91f9fb1174', title: 'Repository conventions' }], + }; + expect(parseTranscriptNoticeDetail(empty)).toEqual(empty); + expect(parseTranscriptNoticeDetail(populated)).toEqual(populated); + expect(isPreambleSelectionChangedNoticeDetail(empty)).toBe(true); + expect(isPreambleSelectionChangedNoticeDetail(populated)).toBe(true); + // Zero entries remain invalid for the application notice. + expect(isPreambleSelectionChangedNoticeDetail({ + type: 'preamble-application', + preambles: [], + })).toBe(false); + expect(parseTranscriptNoticeDetail({ + type: 'preamble-selection-changed', + preambles: [{ id: 'a', title: '' }], + })).toBeNull(); + }); + + it('[PREAMBLE-SELECTION.01-CONTRACT-02] round-trips through a notice message', () => { + const detail = { + type: 'preamble-selection-changed', + preambles: [ + { id: '3502b645-222b-49d2-ac39-1c91f9fb1174', title: 'Repository conventions' }, + ], + }; + const message = new TranscriptNoticeMessage(AT, 'Preambles updated', detail); + expect(parseChatMessage(JSON.parse(JSON.stringify(message)))).toEqual(message); + }); +}); + describe('transcript notice contracts', () => { it('[TLV5-PREAMBLE.01-CONTRACT-01] round-trips immutable preamble title snapshots without accepting private fields', () => { const detail = { type: 'preamble-application', preambles: [ - { id: 'preamble-a', title: 'Repository conventions' }, - { id: 'preamble-b', title: 'Security constraints' }, + { id: '3502b645-222b-49d2-ac39-1c91f9fb1174', title: 'Repository conventions' }, + { id: '80becfa6-c9c7-4b31-9190-fd23c0bedf9c', title: 'Security constraints' }, ], }; const message = new TranscriptNoticeMessage(AT, 'Preambles applied', detail); diff --git a/common/__tests__/ws-events.test.js b/common/__tests__/ws-events.test.js index 1f6cd475d6..423da95db0 100644 --- a/common/__tests__/ws-events.test.js +++ b/common/__tests__/ws-events.test.js @@ -1,6 +1,7 @@ import { describe, expect, it } from 'bun:test'; import { ChatOperationalNoticeMessage, + ChatPreamblesInvalidatedMessage, PreamblesInvalidatedMessage, parseServerWsMessage, TranscriptSearchStatusMessage, @@ -17,6 +18,29 @@ describe('parseServerWsMessage preambles-invalidated', () => { reason: 'applied', })).toBeNull(); }); + + it('parses the body-free per-chat selection invalidation strictly', () => { + expect(parseServerWsMessage({ + type: 'chat-preambles-invalidated', + chatId: '1783725900000200', + revision: 3, + })).toEqual(new ChatPreamblesInvalidatedMessage('1783725900000200', 3)); + expect(parseServerWsMessage({ + type: 'chat-preambles-invalidated', + chatId: '1783725900000200', + revision: -1, + })).toBeNull(); + expect(parseServerWsMessage({ + type: 'chat-preambles-invalidated', + chatId: '', + revision: 0, + })).toBeNull(); + expect(parseServerWsMessage({ + type: 'chat-preambles-invalidated', + chatId: 'chat-1', + revision: 0, + })).toBeNull(); + }); }); describe('parseServerWsMessage chat-operational-notice', () => { diff --git a/common/chat-command-contracts.ts b/common/chat-command-contracts.ts index 5e3a307408..45bc4ec4b3 100644 --- a/common/chat-command-contracts.ts +++ b/common/chat-command-contracts.ts @@ -16,6 +16,13 @@ import type { HttpErrorResponse } from './http-error.js'; import type { ChatListEntry } from './chat-list.js'; import type { ErrorCode } from './error-codes.js'; import { normalizeTags } from './tags.js'; +import { parseHandoffForkConsent } from './chat-fork-command-parsing.js'; + +export { + parseDeleteChatCommandRequest, + parseForkChatCommandRequest, +} from './chat-fork-command-parsing.js'; +import { isPreambleId, PREAMBLE_MAX_COUNT, type PreambleId } from './preambles.js'; import { parseUserMessagePresentation, type ChatStopOutcome, @@ -83,6 +90,7 @@ export type CommandErrorCode = Extract< | 'CARRYOVER_HISTORY_UNAVAILABLE' | 'CONTEXT_ENVELOPE_MISMATCH' | 'PREAMBLE_SLASH_COMMAND_BLOCKED' + | 'PREAMBLE_SELECTION_COMPOSITION_INVALID' | 'CHAT_DELETED' | 'OPERATION_UNSUPPORTED' | 'SOURCE_REVISION_CHANGED' @@ -183,6 +191,9 @@ export interface StartChatCommandRequest { images?: AgentCommandImage[]; tags?: string[]; userMessagePresentation?: UserMessagePresentation; + // Omitted means the server resolves current defaults at actual creation; an + // explicit list, including empty, is stored exactly as supplied. + orderedPreambleIds?: readonly PreambleId[]; } export interface AgentRunCommandRequest { @@ -486,6 +497,7 @@ export function parseStartChatCommandRequest(value: unknown): StartChatCommandRe const command = contentOrImages(body, 'command', images).trim(); const agentSettings = requiredAgentSettings(body.agentSettings, 'agentSettings'); const userMessagePresentation = parseCommandUserMessagePresentation(body.userMessagePresentation); + const orderedPreambleIds = optionalOrderedPreambleIds(body.orderedPreambleIds); if (agentSettings.ownerId !== agentId) { throw new CommandRequestValidationError('agentSettings must be owned by agentId'); } @@ -508,9 +520,31 @@ export function parseStartChatCommandRequest(value: unknown): StartChatCommandRe ...(images === undefined ? {} : { images }), tags: normalizeTags(Array.isArray(body.tags) ? body.tags : []), ...(userMessagePresentation === undefined ? {} : { userMessagePresentation }), + ...(orderedPreambleIds === undefined ? {} : { orderedPreambleIds }), }; } +function optionalOrderedPreambleIds(value: unknown): readonly PreambleId[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value)) { + throw new CommandRequestValidationError('orderedPreambleIds must be an array'); + } + if (value.length > PREAMBLE_MAX_COUNT) { + throw new CommandRequestValidationError('orderedPreambleIds is too long'); + } + const ids: PreambleId[] = []; + for (const item of value) { + if (!isPreambleId(item)) { + throw new CommandRequestValidationError('orderedPreambleIds contains an invalid preamble ID'); + } + if (ids.includes(item)) { + throw new CommandRequestValidationError('orderedPreambleIds contains a duplicate ID'); + } + ids.push(item); + } + return ids; +} + function clientChatStartOrigin(value: unknown): ClientChatStartOrigin { if (value === 'interactive' || value === 'cli') return value; throw new CommandRequestValidationError('origin must be interactive or cli'); @@ -660,43 +694,8 @@ export function parseForkRunCommandRequest(value: unknown): ForkRunCommandReques }; } -export function parseForkChatCommandRequest(value: unknown): ForkChatCommandRequest { - const body = requestRecord(value); - const upToOrdinal = body.upToOrdinal; - const transcriptViewId = optionalNonEmptyString(body, 'transcriptViewId'); - if ( - upToOrdinal !== undefined - && (!Number.isSafeInteger(upToOrdinal) || Number(upToOrdinal) <= 0) - ) { - throw new CommandRequestValidationError('upToOrdinal must be a positive integer'); - } - if (transcriptViewId !== undefined && upToOrdinal === undefined) { - throw new CommandRequestValidationError('transcriptViewId requires upToOrdinal'); - } - if (upToOrdinal !== undefined && transcriptViewId === undefined) { - throw new CommandRequestValidationError('upToOrdinal requires transcriptViewId'); - } - const allowHandoffFork = parseHandoffForkConsent(body); - return { - sourceChatId: requiredChatId(body, 'sourceChatId'), - chatId: requiredChatId(body, 'chatId'), - ...(upToOrdinal === undefined ? {} : { upToOrdinal: Number(upToOrdinal) }), - ...(allowHandoffFork ? { allowHandoffFork: true } : {}), - ...(transcriptViewId === undefined ? {} : { transcriptViewId }), - }; -} -function parseHandoffForkConsent(body: Record): true | undefined { - const consent = body.allowHandoffFork; - if (consent !== undefined && typeof consent !== 'boolean') { - throw new CommandRequestValidationError('allowHandoffFork must be a boolean'); - } - return consent === true ? true : undefined; -} -export function parseDeleteChatCommandRequest(value: unknown): DeleteChatCommandRequest { - return { chatId: requiredChatId(requestRecord(value), 'chatId') }; -} export function parseQueueEntryCreateCommandRequest(value: unknown): QueueEntryCreateCommandRequest { const body = requestRecord(value); diff --git a/common/chat-fork-command-parsing.ts b/common/chat-fork-command-parsing.ts new file mode 100644 index 0000000000..5facc73bea --- /dev/null +++ b/common/chat-fork-command-parsing.ts @@ -0,0 +1,45 @@ +import { + CommandRequestValidationError, + optionalNonEmptyString, + requestRecord, + requiredChatId, +} from './command-request-validation.js'; +import type { DeleteChatCommandRequest, ForkChatCommandRequest } from './chat-command-contracts.js'; + +export function parseForkChatCommandRequest(value: unknown): ForkChatCommandRequest { + const body = requestRecord(value); + const upToOrdinal = body.upToOrdinal; + const transcriptViewId = optionalNonEmptyString(body, 'transcriptViewId'); + if ( + upToOrdinal !== undefined + && (!Number.isSafeInteger(upToOrdinal) || Number(upToOrdinal) <= 0) + ) { + throw new CommandRequestValidationError('upToOrdinal must be a positive integer'); + } + if (transcriptViewId !== undefined && upToOrdinal === undefined) { + throw new CommandRequestValidationError('transcriptViewId requires upToOrdinal'); + } + if (upToOrdinal !== undefined && transcriptViewId === undefined) { + throw new CommandRequestValidationError('upToOrdinal requires transcriptViewId'); + } + const allowHandoffFork = parseHandoffForkConsent(body); + return { + sourceChatId: requiredChatId(body, 'sourceChatId'), + chatId: requiredChatId(body, 'chatId'), + ...(upToOrdinal === undefined ? {} : { upToOrdinal: Number(upToOrdinal) }), + ...(allowHandoffFork ? { allowHandoffFork: true } : {}), + ...(transcriptViewId === undefined ? {} : { transcriptViewId }), + }; +} + +export function parseHandoffForkConsent(body: Record): true | undefined { + const consent = body.allowHandoffFork; + if (consent !== undefined && typeof consent !== 'boolean') { + throw new CommandRequestValidationError('allowHandoffFork must be a boolean'); + } + return consent === true ? true : undefined; +} + +export function parseDeleteChatCommandRequest(value: unknown): DeleteChatCommandRequest { + return { chatId: requiredChatId(requestRecord(value), 'chatId') }; +} diff --git a/common/chat-preamble-selection-contracts.ts b/common/chat-preamble-selection-contracts.ts new file mode 100644 index 0000000000..6d61ee3f5b --- /dev/null +++ b/common/chat-preamble-selection-contracts.ts @@ -0,0 +1,401 @@ +import { + CommandRequestValidationError, + isCommandCorrelationIdWithinLimit, + requestRecord, + requiredChatId, + requiredCommandCorrelationId, +} from './command-request-validation.js'; +import { InvalidChatIdError, parseChatId } from './chat-id.js'; +import { + isPreambleId, + normalizeChatPreambleSelection, + normalizePreambleSelectionProjection, + PREAMBLE_MAX_COUNT, + type ChatPreambleSelection, + type PreambleId, + type PreambleSelectionProjection, +} from './preambles.js'; +import { isRecord } from './json.js'; + +export const CHAT_PREAMBLE_SELECTION_BODY_MAX_BYTES = 32 * 1024; + +export interface ChatPreambleSelectionTargetResponse { + readonly success: true; + readonly chatId: string; + readonly transcriptViewId: string; + // The chat's own canonical project path, needed to project an unsaved draft + // client-side; it is not preamble scope or path data. + readonly canonicalProjectPath: string; + readonly selection: ChatPreambleSelection; + readonly projection: PreambleSelectionProjection; +} + +export interface UpdateChatPreambleSelectionRequest { + readonly chatId: string; + readonly transcriptViewId: string; + readonly clientRequestId: string; + readonly clientMessageId: string; + readonly expectedRevision: number; + readonly orderedPreambleIds: readonly PreambleId[]; +} + +export interface UpdateChatPreambleSelectionResponse { + readonly success: true; + readonly commandType: 'chat-preambles-update'; + readonly clientRequestId: string; + readonly clientMessageId: string; + readonly chatId: string; + readonly transcriptViewId: string; + readonly status: 'updated' | 'unchanged' | 'duplicate'; + readonly mutationRevision: number; + readonly noticeOrdinal: number | null; + readonly selection: ChatPreambleSelection; + readonly projection: PreambleSelectionProjection; +} + +export interface PreambleSelectionPreviewRequest { + readonly projectPath: string; + readonly orderedPreambleIds?: readonly PreambleId[]; +} + +export interface PreambleSelectionPreviewResponse { + readonly success: true; + readonly canonicalProjectPath: string; + readonly orderedPreambleIds: readonly PreambleId[]; + readonly projection: PreambleSelectionProjection; +} + +export type PreambleSelectionCommittedState = true | 'unknown'; + +export interface PreambleSelectionPartialErrorBody { + readonly success: false; + readonly errorCode: + | 'PREAMBLE_SELECTION_NOTICE_FAILED' + | 'PREAMBLE_SELECTION_SAVE_UNKNOWN'; + readonly message: string; + readonly retryable: boolean; + readonly selectionCommitted: PreambleSelectionCommittedState; + readonly selection?: ChatPreambleSelection; +} + +function requiredOrderedPreambleIds(value: readonly unknown[]): readonly PreambleId[] { + if (value.length > PREAMBLE_MAX_COUNT) { + throw new CommandRequestValidationError('orderedPreambleIds is too long'); + } + const ids: PreambleId[] = []; + for (const item of value) { + if (!isPreambleId(item)) { + throw new CommandRequestValidationError('orderedPreambleIds contains an invalid preamble ID'); + } + if (ids.includes(item)) { + throw new CommandRequestValidationError('orderedPreambleIds contains a duplicate ID'); + } + ids.push(item); + } + return ids; +} + +export function parseUpdateChatPreambleSelectionRequest( + value: unknown, +): UpdateChatPreambleSelectionRequest { + const body = strictRecord(value, [ + 'chatId', + 'transcriptViewId', + 'clientRequestId', + 'clientMessageId', + 'expectedRevision', + 'orderedPreambleIds', + ]); + const orderedPreambleIds = body.orderedPreambleIds; + if (!Array.isArray(orderedPreambleIds)) { + throw new CommandRequestValidationError('orderedPreambleIds is required'); + } + const expectedRevision = body.expectedRevision; + if ( + typeof expectedRevision !== 'number' + || !Number.isSafeInteger(expectedRevision) + || expectedRevision < 0 + ) { + throw new CommandRequestValidationError('expectedRevision must be a nonnegative integer'); + } + return { + chatId: requiredChatId(body, 'chatId'), + transcriptViewId: requiredTranscriptViewId(body, 'transcriptViewId'), + clientRequestId: requiredCommandCorrelationId(body, 'clientRequestId'), + clientMessageId: requiredCommandCorrelationId(body, 'clientMessageId'), + expectedRevision, + orderedPreambleIds: requiredOrderedPreambleIds(orderedPreambleIds), + }; +} + +export function parsePreambleSelectionPreviewRequest( + value: unknown, +): PreambleSelectionPreviewRequest { + const body = strictRecord(value, ['projectPath', 'orderedPreambleIds']); + const projectPath = body.projectPath; + if (typeof projectPath !== 'string' || projectPath.trim().length === 0) { + throw new CommandRequestValidationError('projectPath is required'); + } + if (body.orderedPreambleIds === undefined) { + return { projectPath }; + } + if (!Array.isArray(body.orderedPreambleIds)) { + throw new CommandRequestValidationError('orderedPreambleIds must be an array'); + } + return { projectPath, orderedPreambleIds: requiredOrderedPreambleIds(body.orderedPreambleIds) }; +} + +function projectionPartitionsSelection( + projection: PreambleSelectionProjection, + selection: ChatPreambleSelection, +): boolean { + const eligibleIds = new Set(projection.eligiblePreambles.map((entry) => entry.id)); + const unavailableIds = new Set(projection.unavailable.map((entry) => entry.id)); + if (eligibleIds.size + unavailableIds.size !== selection.orderedPreambleIds.length) { + return false; + } + const expectedEligible: PreambleId[] = []; + const expectedUnavailable: PreambleId[] = []; + for (const id of selection.orderedPreambleIds) { + if (eligibleIds.has(id)) expectedEligible.push(id); + else if (unavailableIds.has(id)) expectedUnavailable.push(id); + else return false; + } + return expectedEligible.every( + (id, index) => id === projection.eligiblePreambles[index]?.id, + ) && expectedUnavailable.every( + (id, index) => id === projection.unavailable[index]?.id, + ); +} + +// Status and notice ordinal must agree: `updated` always records its notice +// row; `unchanged` never does; `duplicate` reports the original row. +function updateStatusConsistent( + status: unknown, + noticeOrdinal: unknown, +): boolean { + if (status === 'updated') return noticeOrdinal !== null; + if (status === 'unchanged') return noticeOrdinal === null; + return status === 'duplicate' && noticeOrdinal !== null; +} + +export function parseChatPreambleSelectionTargetResponse( + value: unknown, +): ChatPreambleSelectionTargetResponse | null { + const response = responseRecord(value, [ + 'success', + 'chatId', + 'transcriptViewId', + 'canonicalProjectPath', + 'selection', + 'projection', + ]); + if (!response || response.success !== true) return null; + const selection = normalizeChatPreambleSelection(response.selection); + const projection = normalizePreambleSelectionProjection(response.projection); + if (!isResponseChatId(response.chatId) + || !isTranscriptViewId(response.transcriptViewId) + || !isNonEmptyString(response.canonicalProjectPath) + || !selection + || !projection + || !projectionPartitionsSelection(projection, selection)) return null; + return { + success: true, + chatId: response.chatId, + transcriptViewId: response.transcriptViewId, + canonicalProjectPath: response.canonicalProjectPath, + selection, + projection, + }; +} + +export function parseUpdateChatPreambleSelectionResponse( + value: unknown, +): UpdateChatPreambleSelectionResponse | null { + const response = responseRecord(value, [ + 'success', + 'commandType', + 'clientRequestId', + 'clientMessageId', + 'chatId', + 'transcriptViewId', + 'status', + 'mutationRevision', + 'noticeOrdinal', + 'selection', + 'projection', + ]); + if ( + !response + || response.success !== true + || response.commandType !== 'chat-preambles-update' + || !isResponseCorrelationId(response.clientRequestId) + || !isResponseCorrelationId(response.clientMessageId) + || !isResponseChatId(response.chatId) + || !isTranscriptViewId(response.transcriptViewId) + || !updateStatusConsistent(response.status, response.noticeOrdinal) + || !Number.isSafeInteger(response.mutationRevision) + || Number(response.mutationRevision) < 0 + || !( + response.noticeOrdinal === null + || (Number.isSafeInteger(response.noticeOrdinal) && Number(response.noticeOrdinal) >= 1) + ) + ) return null; + const selection = normalizeChatPreambleSelection(response.selection); + const projection = normalizePreambleSelectionProjection(response.projection); + if ( + !selection + || !projection + || !projectionPartitionsSelection(projection, selection) + ) return null; + if (response.status !== 'updated' + && response.status !== 'unchanged' + && response.status !== 'duplicate') { + return null; + } + const mutationRevision = Number(response.mutationRevision); + if (response.status === 'duplicate' + ? selection.revision < mutationRevision + : selection.revision !== mutationRevision) return null; + return { + success: true, + commandType: 'chat-preambles-update', + clientRequestId: response.clientRequestId, + clientMessageId: response.clientMessageId, + chatId: response.chatId, + transcriptViewId: response.transcriptViewId, + status: response.status, + mutationRevision, + noticeOrdinal: response.noticeOrdinal as number | null, + selection, + projection, + }; +} + +export function parsePreambleSelectionPreviewResponse( + value: unknown, +): PreambleSelectionPreviewResponse | null { + const response = responseRecord(value, [ + 'success', + 'canonicalProjectPath', + 'orderedPreambleIds', + 'projection', + ]); + if (!response || response.success !== true) return null; + const projection = normalizePreambleSelectionProjection(response.projection); + if ( + !isNonEmptyString(response.canonicalProjectPath) + || !Array.isArray(response.orderedPreambleIds) + || response.orderedPreambleIds.length > PREAMBLE_MAX_COUNT + || new Set(response.orderedPreambleIds).size !== response.orderedPreambleIds.length + || !response.orderedPreambleIds.every((id): id is PreambleId => isPreambleId(id)) + || !projection + || !projectionPartitionsSelection( + projection, + { revision: 0, orderedPreambleIds: response.orderedPreambleIds as PreambleId[] }, + ) + ) return null; + return { + success: true, + canonicalProjectPath: response.canonicalProjectPath, + orderedPreambleIds: [...response.orderedPreambleIds], + projection, + }; +} + +export function parsePreambleSelectionPartialError( + value: unknown, +): PreambleSelectionPartialErrorBody | null { + const response = responseRecord(value, [ + 'success', + 'errorCode', + 'message', + 'retryable', + 'selectionCommitted', + 'selection', + ]); + if ( + !response + || response.success !== false + || (response.errorCode !== 'PREAMBLE_SELECTION_NOTICE_FAILED' + && response.errorCode !== 'PREAMBLE_SELECTION_SAVE_UNKNOWN') + || !isNonEmptyString(response.message) + || response.retryable !== false + || (response.selectionCommitted !== true && response.selectionCommitted !== 'unknown') + ) return null; + // The commit state and error code must agree, and a supplied selection must + // be well formed; malformed payloads reject instead of dropping fields. + const codeMatchesCommit = response.errorCode === 'PREAMBLE_SELECTION_NOTICE_FAILED' + ? response.selectionCommitted === true + : response.selectionCommitted === 'unknown'; + if (!codeMatchesCommit) return null; + let selection: ChatPreambleSelection | undefined; + if (response.selection !== undefined) { + const parsed = normalizeChatPreambleSelection(response.selection); + if (!parsed) return null; + selection = parsed; + } + if (response.errorCode === 'PREAMBLE_SELECTION_NOTICE_FAILED' && !selection) return null; + return { + success: false, + errorCode: response.errorCode, + message: response.message, + retryable: response.retryable, + selectionCommitted: response.selectionCommitted, + ...(selection === undefined ? {} : { selection }), + }; +} + +function strictRecord(value: unknown, keys: readonly string[]): Record { + const body = requestRecord(value); + const allowed = new Set(keys); + for (const key of Object.keys(body)) { + if (!allowed.has(key)) { + throw new CommandRequestValidationError(`${key} is not supported`); + } + } + return body; +} + +function requiredTranscriptViewId(body: Record, field: string): string { + const value = requiredCommandCorrelationId(body, field); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test(value)) { + throw new CommandRequestValidationError(`${field} must be a transcript view UUID`); + } + return value; +} + +function responseRecord( + value: unknown, + keys: readonly string[], +): Record | null { + if (!isRecord(value)) return null; + const allowed = new Set(keys); + return Object.keys(value).every((key) => allowed.has(key)) ? value : null; +} + +function isResponseChatId(value: unknown): value is string { + try { + parseChatId(value); + return true; + } catch (error) { + if (error instanceof InvalidChatIdError) return false; + throw error; + } +} + +function isTranscriptViewId(value: unknown): value is string { + return typeof value === 'string' + && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test(value); +} + +function isResponseCorrelationId(value: unknown): value is string { + return typeof value === 'string' + && value.length > 0 + && value.trim() === value + && isCommandCorrelationIdWithinLimit(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} diff --git a/common/error-codes.ts b/common/error-codes.ts index 28faf13af5..15022e07a1 100644 --- a/common/error-codes.ts +++ b/common/error-codes.ts @@ -124,6 +124,13 @@ export const ERROR_CODES = [ 'PREAMBLE_PROJECT_PATH_NOT_DIRECTORY', 'PREAMBLE_ENVELOPE_MISMATCH', 'PREAMBLE_SLASH_COMMAND_BLOCKED', + 'PREAMBLE_ID_COLLISION', + 'PREAMBLE_ID_LIFETIME_LIMIT_REACHED', + 'PREAMBLE_CATALOG_SAVE_UNKNOWN', + 'PREAMBLE_SELECTION_REVISION_CONFLICT', + 'PREAMBLE_SELECTION_COMPOSITION_INVALID', + 'PREAMBLE_SELECTION_NOTICE_FAILED', + 'PREAMBLE_SELECTION_SAVE_UNKNOWN', ] as const; export type ErrorCode = (typeof ERROR_CODES)[number]; diff --git a/common/package.json b/common/package.json index d6281194b9..13fc9bd9b8 100644 --- a/common/package.json +++ b/common/package.json @@ -24,6 +24,7 @@ "./chat-parentage": "./chat-parentage.ts", "./chat-modes": "./chat-modes.ts", "./chat-order-contracts": "./chat-order-contracts.ts", + "./chat-preamble-selection-contracts": "./chat-preamble-selection-contracts.ts", "./chat-row-contracts": "./chat-row-contracts.ts", "./chat-snapshot": "./chat-snapshot.ts", "./chat-transient-feed": "./chat-transient-feed.ts", diff --git a/common/preambles.ts b/common/preambles.ts index 89caf0ee55..c5869784d5 100644 --- a/common/preambles.ts +++ b/common/preambles.ts @@ -7,6 +7,9 @@ export const PREAMBLE_COMBINED_MAX_LENGTH = 64_000; export const PREAMBLE_PATH_RULE_MAX_COUNT = 32; export const PREAMBLE_FILE_CONTEXT_SEPARATOR = '\n\nReferenced file contents from @file mentions:\n\n'; export const PREAMBLE_CHAT_ID_TOKEN = CHAT_ID_TEMPLATE_TOKEN; +// Lifetime bounds cover active plus retired IDs; tombstones are never pruned. +export const PREAMBLE_ID_LIFETIME_MAX_COUNT = 100_000; +export const PREAMBLES_FILE_MAX_BYTES = 64 * 1024 * 1024; const PREAMBLE_TEMPLATE_VARIABLES = [CHAT_ID_TEMPLATE_VARIABLE] as const; @@ -15,13 +18,81 @@ export const PREAMBLE_BOUNDARY_KINDS = [ 'fork', 'continuation', 'agent-switch', + 'selection-change', ] as const; export type PreambleBoundaryKind = (typeof PREAMBLE_BOUNDARY_KINDS)[number]; -export interface PendingPreambleBoundary { - readonly kind: PreambleBoundaryKind; - readonly ownershipEpoch: string; +export type PendingPreambleBoundary = + | { + readonly kind: Exclude; + readonly ownershipEpoch: string; + } + | { + readonly kind: 'selection-change'; + readonly ownershipEpoch: string; + readonly selectionRevision: number; + }; + +// A selection-change boundary is repeatable within one ownership epoch, so its +// full identity includes the selection revision it was armed for. +export function samePreambleBoundary( + left: PendingPreambleBoundary, + right: PendingPreambleBoundary, +): boolean { + if (left.kind !== right.kind || left.ownershipEpoch !== right.ownershipEpoch) { + return false; + } + if (left.kind !== 'selection-change') return true; + return right.kind === 'selection-change' + && left.selectionRevision === right.selectionRevision; +} + +export type PreambleId = string; + +const PREAMBLE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; + +export function isPreambleId(value: unknown): value is PreambleId { + return typeof value === 'string' && PREAMBLE_ID_PATTERN.test(value); +} + +export interface ChatPreambleSelection { + readonly revision: number; + readonly orderedPreambleIds: readonly PreambleId[]; +} + +export type PreambleSelectionUnavailableReason = + | 'missing' + | 'disabled' + | 'out-of-scope'; + +export interface PreambleSelectionReference { + readonly id: PreambleId; + readonly title: string; +} + +export interface UnavailablePreambleSelectionReference { + readonly id: PreambleId; + readonly reason: PreambleSelectionUnavailableReason; +} + +export interface PreambleSelectionProjection { + readonly catalogRevision: number; + readonly eligiblePreambles: readonly PreambleSelectionReference[]; + readonly unavailable: readonly UnavailablePreambleSelectionReference[]; +} + +export const PREAMBLE_SELECTION_UNAVAILABLE_REASONS = [ + 'missing', + 'disabled', + 'out-of-scope', +] as const; + +export function isPreambleSelectionUnavailableReason( + value: unknown, +): value is PreambleSelectionUnavailableReason { + return typeof value === 'string' + && (PREAMBLE_SELECTION_UNAVAILABLE_REASONS as readonly string[]).includes(value); } export interface PreambleProjectPathRule { @@ -113,6 +184,13 @@ export const PREAMBLE_ERROR_CODES = { projectPathNotDirectory: 'PREAMBLE_PROJECT_PATH_NOT_DIRECTORY', envelopeMismatch: 'PREAMBLE_ENVELOPE_MISMATCH', slashCommandBlocked: 'PREAMBLE_SLASH_COMMAND_BLOCKED', + idCollision: 'PREAMBLE_ID_COLLISION', + idLifetimeLimitReached: 'PREAMBLE_ID_LIFETIME_LIMIT_REACHED', + catalogSaveUnknown: 'PREAMBLE_CATALOG_SAVE_UNKNOWN', + selectionRevisionConflict: 'PREAMBLE_SELECTION_REVISION_CONFLICT', + selectionCompositionInvalid: 'PREAMBLE_SELECTION_COMPOSITION_INVALID', + selectionNoticeFailed: 'PREAMBLE_SELECTION_NOTICE_FAILED', + selectionSaveUnknown: 'PREAMBLE_SELECTION_SAVE_UNKNOWN', } as const; export type PreambleErrorCode = (typeof PREAMBLE_ERROR_CODES)[keyof typeof PREAMBLE_ERROR_CODES]; @@ -128,10 +206,7 @@ export function renderPreambleContent(content: string, chatId: string): string { function asRecord(value: unknown): Record | null { if (value === null || typeof value !== 'object' || Array.isArray(value)) return null; - const prototype = Object.getPrototypeOf(value); - return prototype === null || prototype === Object.prototype - ? value as Record - : null; + return value as Record; } function hasOnlyKeys(value: Record, keys: readonly string[]): boolean { @@ -158,12 +233,102 @@ export function isPreambleBoundaryKind(value: unknown): value is PreambleBoundar export function normalizePendingPreambleBoundary(value: unknown): PendingPreambleBoundary | null { const raw = asRecord(value); - if (!raw || !hasOnlyKeys(raw, ['kind', 'ownershipEpoch'])) return null; + if (!raw || !isPreambleBoundaryKind(raw.kind)) return null; const ownershipEpoch = nonEmptyString(raw.ownershipEpoch); - if (!isPreambleBoundaryKind(raw.kind) || !ownershipEpoch) return null; + if (!ownershipEpoch) return null; + if (raw.kind === 'selection-change') { + if (!hasOnlyKeys(raw, ['kind', 'ownershipEpoch', 'selectionRevision'])) return null; + if (!Number.isSafeInteger(raw.selectionRevision) || (raw.selectionRevision as number) < 0) { + return null; + } + return { + kind: 'selection-change', + ownershipEpoch, + selectionRevision: raw.selectionRevision as number, + }; + } + if (!hasOnlyKeys(raw, ['kind', 'ownershipEpoch'])) return null; return { kind: raw.kind, ownershipEpoch }; } +// Persistence parsing performs no catalog lookup: missing IDs stay saved, order is +// preserved, and nothing is truncated, deduplicated, or sorted. +export function normalizeChatPreambleSelection(value: unknown): ChatPreambleSelection | null { + const raw = asRecord(value); + if ( + !raw + || !hasOnlyKeys(raw, ['revision', 'orderedPreambleIds']) + || !Number.isSafeInteger(raw.revision) + || (raw.revision as number) < 0 + || !Array.isArray(raw.orderedPreambleIds) + || raw.orderedPreambleIds.length > PREAMBLE_MAX_COUNT + ) return null; + const orderedPreambleIds: PreambleId[] = []; + const seen = new Set(); + for (const id of raw.orderedPreambleIds) { + if (!isPreambleId(id) || seen.has(id)) return null; + seen.add(id); + orderedPreambleIds.push(id); + } + return { revision: raw.revision as number, orderedPreambleIds }; +} + +export function normalizePreambleSelectionReference( + value: unknown, +): PreambleSelectionReference | null { + const raw = asRecord(value); + if (!raw || !hasOnlyKeys(raw, ['id', 'title'])) return null; + if (!isPreambleId(raw.id)) return null; + const title = normalizePreambleTitle(raw.title); + return title ? { id: raw.id, title } : null; +} + +export function normalizeUnavailablePreambleSelectionReference( + value: unknown, +): UnavailablePreambleSelectionReference | null { + const raw = asRecord(value); + if ( + !raw + || !hasOnlyKeys(raw, ['id', 'reason']) + || !isPreambleId(raw.id) + || !isPreambleSelectionUnavailableReason(raw.reason) + ) return null; + return { id: raw.id, reason: raw.reason }; +} + +export function normalizePreambleSelectionProjection( + value: unknown, +): PreambleSelectionProjection | null { + const raw = asRecord(value); + if ( + !raw + || !hasOnlyKeys(raw, ['catalogRevision', 'eligiblePreambles', 'unavailable']) + || !Number.isSafeInteger(raw.catalogRevision) + || (raw.catalogRevision as number) < 0 + || !Array.isArray(raw.eligiblePreambles) + || raw.eligiblePreambles.length > PREAMBLE_MAX_COUNT + || !Array.isArray(raw.unavailable) + || raw.unavailable.length > PREAMBLE_MAX_COUNT + ) return null; + const eligiblePreambles: PreambleSelectionReference[] = []; + const eligibleIds = new Set(); + for (const item of raw.eligiblePreambles) { + const reference = normalizePreambleSelectionReference(item); + if (!reference || eligibleIds.has(reference.id)) return null; + eligibleIds.add(reference.id); + eligiblePreambles.push(reference); + } + const unavailable: UnavailablePreambleSelectionReference[] = []; + const unavailableIds = new Set(); + for (const item of raw.unavailable) { + const reference = normalizeUnavailablePreambleSelectionReference(item); + if (!reference || unavailableIds.has(reference.id) || eligibleIds.has(reference.id)) return null; + unavailableIds.add(reference.id); + unavailable.push(reference); + } + return { catalogRevision: raw.catalogRevision as number, eligiblePreambles, unavailable }; +} + export function isPreamblesInvalidationReason( value: unknown, ): value is PreamblesInvalidationReason { @@ -235,7 +400,7 @@ export function normalizePreamble(value: unknown): Preamble | null { ])) { return null; } - const id = nonEmptyString(raw.id); + const id = isPreambleId(raw.id) ? raw.id : null; const definition = normalizePreambleDefinitionInput({ enabled: raw.enabled, title: raw.title, diff --git a/common/transcript-notice-details.ts b/common/transcript-notice-details.ts index 3905bddde7..ea174c7863 100644 --- a/common/transcript-notice-details.ts +++ b/common/transcript-notice-details.ts @@ -1,5 +1,6 @@ import { parseChatId } from './chat-id.js'; import { + isPreambleId, PREAMBLE_MAX_COUNT, normalizePreambleTitle, } from './preambles.js'; @@ -14,6 +15,13 @@ export interface PreambleApplicationNoticeDetail { readonly preambles: readonly AppliedPreambleReference[]; } +// Configuration-change fact, distinct from application: it may list zero entries +// and is never fed into native sanitation evidence. +export interface PreambleSelectionChangedNoticeDetail { + readonly type: 'preamble-selection-changed'; + readonly preambles: readonly AppliedPreambleReference[]; +} + export interface CarryoverMigrationQuarantineNoticeDetail { readonly type: 'carryover-migration-quarantine'; readonly artifactId: string; @@ -75,6 +83,7 @@ export type ServerControlReceiptDetail = InterAgentMessageReceivedNoticeDetail; export type TranscriptNoticeDetail = | PreambleApplicationNoticeDetail + | PreambleSelectionChangedNoticeDetail | CarryoverMigrationQuarantineNoticeDetail | HandoffSummaryNoticeDetail | ChatIdDisclosureNoticeDetail @@ -94,25 +103,47 @@ export function isCarryoverMigrationQuarantineNoticeDetail( && detail.errorCode.length > 0; } +function appliedPreambleReferences( + value: unknown, + minCount: number, +): readonly AppliedPreambleReference[] | null { + const preambles = (value as Record).preambles; + if (!Array.isArray(preambles) || preambles.length < minCount || preambles.length > PREAMBLE_MAX_COUNT) { + return null; + } + const ids = new Set(); + const references: AppliedPreambleReference[] = []; + for (const entry of preambles) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null; + const raw = entry as Record; + if (typeof raw.id !== 'string' || !isPreambleId(raw.id) || ids.has(raw.id)) return null; + const title = normalizePreambleTitle(raw.title); + // Canonical equality: the stored title must already be its normalized + // form, so edge whitespace never survives validation. + if (title === null || title !== raw.title) return null; + if (!Object.keys(raw).every((key) => key === 'id' || key === 'title')) return null; + ids.add(raw.id); + references.push({ id: raw.id, title }); + } + return references; +} + export function isPreambleApplicationNoticeDetail( value: unknown, ): value is PreambleApplicationNoticeDetail { if (!hasType(value, 'preamble-application')) return false; const detail = value as Record; if (Object.keys(detail).some((key) => key !== 'type' && key !== 'preambles')) return false; - const preambles = detail.preambles; - if (!Array.isArray(preambles) || preambles.length < 1 || preambles.length > PREAMBLE_MAX_COUNT) { - return false; - } - const ids = new Set(); - return preambles.every((entry) => { - if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return false; - const raw = entry as Record; - const id = typeof raw.id === 'string' ? raw.id.trim() : ''; - if (!id || ids.has(id) || normalizePreambleTitle(raw.title) === null) return false; - ids.add(id); - return Object.keys(raw).every((key) => key === 'id' || key === 'title'); - }); + return appliedPreambleReferences(detail, 1) !== null; +} + +export function isPreambleSelectionChangedNoticeDetail( + value: unknown, +): value is PreambleSelectionChangedNoticeDetail { + if (!hasType(value, 'preamble-selection-changed')) return false; + const detail = value as Record; + if (Object.keys(detail).some((key) => key !== 'type' && key !== 'preambles')) return false; + return appliedPreambleReferences(detail, 0) !== null; } export function isHandoffSummaryNoticeDetail( @@ -187,7 +218,16 @@ export function parseTranscriptNoticeDetail(value: unknown): TranscriptNoticeDet return { type: value.type, preambles: value.preambles.map((preamble) => ({ - id: preamble.id.trim(), + id: preamble.id, + title: normalizePreambleTitle(preamble.title)!, + })), + }; + } + if (isPreambleSelectionChangedNoticeDetail(value)) { + return { + type: value.type, + preambles: value.preambles.map((preamble) => ({ + id: preamble.id, title: normalizePreambleTitle(preamble.title)!, })), }; diff --git a/common/ws-events.ts b/common/ws-events.ts index 9074d58d6b..7808f0005e 100644 --- a/common/ws-events.ts +++ b/common/ws-events.ts @@ -42,6 +42,7 @@ import { isTranscriptSearchStatusV1, type TranscriptSearchStatusV1, } from './chat-search'; +import { InvalidChatIdError, parseChatId } from './chat-id'; export class ChatMessagesMessage { readonly type = 'chat-messages' as const; @@ -315,6 +316,15 @@ export class PreamblesInvalidatedMessage { constructor(public reason: PreamblesInvalidationReason) {} } +// Body-free per-chat invalidation; distinct from workspace catalog invalidation. +export class ChatPreamblesInvalidatedMessage { + readonly type = 'chat-preambles-invalidated' as const; + constructor( + public chatId: string, + public revision: number, + ) {} +} + export type ClientRequestErrorCode = Extract< ErrorCode, | 'MISSING_CHAT_ID' @@ -383,6 +393,7 @@ export type ServerWsMessage = | ScheduledPromptsInvalidatedMessage | SnippetsInvalidatedMessage | PreamblesInvalidatedMessage + | ChatPreamblesInvalidatedMessage | ClientRequestErrorMessage; export type EventKey = ServerWsMessage['type']; @@ -807,6 +818,13 @@ export function parseServerWsMessage( ? new PreamblesInvalidatedMessage(data.reason) : null; } + case 'chat-preambles-invalidated': { + const chatId = canonicalChatId(data.chatId); + const revision = nonNegativeInt(data.revision); + return chatId && revision !== null + ? new ChatPreamblesInvalidatedMessage(chatId, revision) + : null; + } case 'client-request-error': { const clientRequestId = requiredStr(data.clientRequestId); const requestType = requiredStr(data.requestType); @@ -835,3 +853,12 @@ export function parseServerWsMessage( return null; } } + +function canonicalChatId(value: unknown): string | null { + try { + return parseChatId(value); + } catch (error) { + if (error instanceof InvalidChatIdError) return null; + throw error; + } +} diff --git a/integration-tests/support/garcon-client.ts b/integration-tests/support/garcon-client.ts index d484532885..d18e27c4bf 100644 --- a/integration-tests/support/garcon-client.ts +++ b/integration-tests/support/garcon-client.ts @@ -149,6 +149,7 @@ export interface DirectStartInput { agent: ConfiguredDirectTestAgent; clientRequestId?: string; clientMessageId?: string; + orderedPreambleIds?: string[]; } export interface DirectRunInput { @@ -584,6 +585,9 @@ export class GarconTestClient { thinkingMode: 'none', agentSettings: input.agent.agentSettings, command: input.content, + ...(input.orderedPreambleIds === undefined + ? {} + : { orderedPreambleIds: input.orderedPreambleIds }), }; } diff --git a/integration-tests/tests/server/carryover-bootstrap-migration.test.ts b/integration-tests/tests/server/carryover-bootstrap-migration.test.ts index 3eb3963695..64c59ef506 100644 --- a/integration-tests/tests/server/carryover-bootstrap-migration.test.ts +++ b/integration-tests/tests/server/carryover-bootstrap-migration.test.ts @@ -152,6 +152,7 @@ describe('carryover bootstrap migration', () => { model: 'post-migration-model', projectPath: fixture.dirs.project, agentSettingsById: {}, + preambleSelection: { revision: 0, orderedPreambleIds: [] }, parentChat: null, }); await registry.flush(); diff --git a/integration-tests/tests/server/per-chat-preambles.test.ts b/integration-tests/tests/server/per-chat-preambles.test.ts new file mode 100644 index 0000000000..50850556ef --- /dev/null +++ b/integration-tests/tests/server/per-chat-preambles.test.ts @@ -0,0 +1,402 @@ +import { describe, expect, test } from 'bun:test'; +import type { + ChatPreambleSelectionTargetResponse, + PreambleSelectionPreviewResponse, + UpdateChatPreambleSelectionResponse, +} from '../../../common/chat-preamble-selection-contracts.js'; +import type { PreambleDefinitionInput, PreamblesSnapshot } from '../../../common/preambles.js'; +import type { ChatPreamblesInvalidatedMessage } from '../../../common/ws-events.js'; +import { messagesOfType, userContents } from '../../support/chat-assertions.js'; +import type { ChatMessagesPage } from '../../support/garcon-client.js'; +import { GarconApiError } from '../../support/garcon-client.js'; +import { + type IntegrationFixture, + withIntegrationFixture, +} from '../../support/integration-fixture.js'; + +const MISSING_ID = '936903ad-8b98-43eb-a7d4-c17ce0dc18d8'; + +async function createPreamble( + fixture: IntegrationFixture, + revision: number, + preamble: PreambleDefinitionInput, +): Promise { + return (await fixture.client.post<{ snapshot: PreamblesSnapshot }>('/api/v1/preambles', { + expectedRevision: revision, + preamble, + })).snapshot; +} + +function globalDefinition(title: string, content: string): PreambleDefinitionInput { + return { enabled: true, title, content, scope: { type: 'global' } }; +} + +function selectionTarget(fixture: IntegrationFixture, chatId: string) { + return fixture.client.get( + `/api/v1/chats/preambles?chatId=${chatId}`, + ); +} + +function saveSelection( + fixture: IntegrationFixture, + request: Parameters[1] & Record, +) { + return fixture.client.put('/api/v1/chats/preambles', request); +} + +function updateNotices(page: ChatMessagesPage) { + return messagesOfType(page.messages, 'transcript-notice').filter( + (message) => message.detail?.type === 'preamble-selection-changed', + ); +} + +function applicationNotices(page: ChatMessagesPage) { + return messagesOfType(page.messages, 'transcript-notice').filter( + (message) => message.detail?.type === 'preamble-application', + ); +} + +describe('per-chat preambles', () => { + test('[PREAMBLE-SELECTION.02-SERVER-01] saves a changed selection, notices it, and applies it in chat order on the next ordinary input', async () => { + await withIntegrationFixture('preambles', async (fixture) => { + let catalog = await createPreamble(fixture, 0, globalDefinition('First', 'SYNTHETIC_FIRST_BODY')); + catalog = await createPreamble(fixture, catalog.revision, globalDefinition('Second', 'SYNTHETIC_SECOND_BODY')); + const [idFirst, idSecond] = catalog.preambles.map((preamble) => preamble.id); + expect(typeof idFirst).toBe('string'); + expect(idFirst).not.toEqual(idSecond); + + const chatId = fixture.newChatId(); + const held = fixture.fakeProviders.openAi.holdNext({ + model: fixture.directAgents.openAi.provider.model, + }); + const started = await fixture.client.startDirectChat({ + chatId, + content: 'initial prompt without preambles', + projectPath: fixture.dirs.project, + agent: fixture.directAgents.openAi, + orderedPreambleIds: [], + }); + const firstRequest = await held.received; + expect(firstRequest.lastUserText).not.toContain(' entry.title)) + .toEqual(['Second', 'First']); + + // The update notice is committed before the per-chat invalidation fires. + const invalidation = await fixture.client.waitForEvent( + (event): event is ChatPreamblesInvalidatedMessage => + event.type === 'chat-preambles-invalidated' && event.chatId === chatId, + 'chat selection invalidation', + { afterIndex: eventsCursor }, + ); + expect(invalidation.revision).toBe(1); + + const afterSave = await fixture.client.getMessages(chatId); + expect(updateNotices(afterSave).map((entry) => entry.detail)).toEqual([{ + type: 'preamble-selection-changed', + preambles: [ + { id: idSecond, title: 'Second' }, + { id: idFirst, title: 'First' }, + ], + }]); + + const applyHeld = fixture.fakeProviders.openAi.holdNext({ + model: fixture.directAgents.openAi.provider.model, + }); + const applied = await fixture.client.runDirectChat({ + chatId, + content: 'regular prompt after selection change', + agent: fixture.directAgents.openAi, + }); + const applyRequest = await applyHeld.received; + // The prefix precedes the whole resend composition, so it must lead the + // frame with the selected-order bodies. + expect(applyRequest.lastUserText.startsWith( + '\nSYNTHETIC_SECOND_BODY\n\nSYNTHETIC_FIRST_BODY\n\n\n ', + )).toBeTrue(); + expect(applyRequest.lastUserText.indexOf('SYNTHETIC_SECOND_BODY')) + .toBeLessThan(applyRequest.lastUserText.indexOf('SYNTHETIC_FIRST_BODY')); + expect(applyHeld.releaseText('second synthetic response')).toBeTrue(); + await fixture.client.waitForTurnTerminal(chatId, applied.turnId); + + const history = await fixture.client.getMessages(chatId); + expect(applicationNotices(history).map((entry) => ( + entry.detail?.type === 'preamble-application' + ? entry.detail.preambles.map((preamble) => preamble.title) + : [] + ))).toEqual([['Second', 'First']]); + expect(updateNotices(history)).toHaveLength(1); + expect(JSON.stringify(history)).not.toContain('SYNTHETIC_FIRST_BODY'); + expect(JSON.stringify(history)).not.toContain('SYNTHETIC_SECOND_BODY'); + + // A second ordinary turn applies nothing further. + const laterHeld = fixture.fakeProviders.openAi.holdNext({ + model: fixture.directAgents.openAi.provider.model, + }); + const later = await fixture.client.runDirectChat({ + chatId, + content: 'ordinary follow-up', + agent: fixture.directAgents.openAi, + }); + const laterRequest = await laterHeld.received; + expect(laterRequest.lastUserText).not.toContain(' { + await withIntegrationFixture('preambles', async (fixture) => { + const defaultsCatalog = await createPreamble(fixture, 0, globalDefinition('Global only', 'SYNTHETIC_GLOBAL_BODY')); + + const chatId = fixture.newChatId(); + const held = fixture.fakeProviders.openAi.holdNext({ + model: fixture.directAgents.openAi.provider.model, + }); + const started = await fixture.client.startDirectChat({ + chatId, + content: 'start with defaults', + projectPath: fixture.dirs.project, + agent: fixture.directAgents.openAi, + }); + await held.received; + expect(held.releaseText('defaults response')).toBeTrue(); + await fixture.client.waitForTurnTerminal(chatId, started.turnId); + + const target = await selectionTarget(fixture, chatId); + expect(target.selection.orderedPreambleIds).toEqual([defaultsCatalog.preambles[0]!.id]); + + const saved = await saveSelection(fixture, { + chatId, + transcriptViewId: target.transcriptViewId, + clientRequestId: 'selection-empty-1', + clientMessageId: 'selection-empty-msg-1', + expectedRevision: 0, + orderedPreambleIds: [], + }); + expect(saved.status).toBe('updated'); + + const afterSave = await fixture.client.getMessages(chatId); + expect(updateNotices(afterSave).map((entry) => entry.detail)).toEqual([{ + type: 'preamble-selection-changed', + preambles: [], + }]); + + const held2 = fixture.fakeProviders.openAi.holdNext({ + model: fixture.directAgents.openAi.provider.model, + }); + const followUp = await fixture.client.runDirectChat({ + chatId, + content: 'prompt with none enabled', + agent: fixture.directAgents.openAi, + }); + const request = await held2.received; + expect(request.lastUserText).not.toContain(' { + await withIntegrationFixture('preambles', async (fixture) => { + await createPreamble(fixture, 0, globalDefinition('First', 'SYNTHETIC_FIRST_BODY')); + + const chatId = fixture.newChatId(); + const held = fixture.fakeProviders.openAi.holdNext({ + model: fixture.directAgents.openAi.provider.model, + }); + const started = await fixture.client.startDirectChat({ + chatId, + content: 'initial', + projectPath: fixture.dirs.project, + agent: fixture.directAgents.openAi, + orderedPreambleIds: [], + }); + await held.received; + expect(held.releaseText('response')).toBeTrue(); + await fixture.client.waitForTurnTerminal(chatId, started.turnId); + + const target = await selectionTarget(fixture, chatId); + const request = { + chatId, + transcriptViewId: target.transcriptViewId, + clientRequestId: 'selection-rev-1', + clientMessageId: 'selection-rev-msg-1', + expectedRevision: 4, + orderedPreambleIds: [], + }; + let conflict: unknown; + try { + await saveSelection(fixture, request); + } catch (error) { + conflict = error; + } + expect(conflict).toBeInstanceOf(GarconApiError); + expect(conflict).toMatchObject({ + status: 409, + body: { errorCode: 'PREAMBLE_SELECTION_REVISION_CONFLICT', retryable: true }, + }); + + const conflictCatalog = await fixture.client.get('/api/v1/preambles'); + const conflictFirstId = conflictCatalog.preambles[0]!.id; + const saved = await saveSelection(fixture, { + ...request, + expectedRevision: 0, + orderedPreambleIds: [conflictFirstId], + }); + expect(saved.status).toBe('updated'); + + const duplicate = await saveSelection(fixture, { + ...request, + expectedRevision: 0, + orderedPreambleIds: [conflictFirstId], + }); + expect(duplicate.status).toBe('duplicate'); + expect(duplicate.mutationRevision).toBe(1); + const history = await fixture.client.getMessages(chatId); + expect(updateNotices(history)).toHaveLength(1); + + let unsafe: unknown; + try { + await saveSelection(fixture, { + chatId, + transcriptViewId: target.transcriptViewId, + clientRequestId: 'selection-unsafe-1', + clientMessageId: 'selection-unsafe-msg-1', + expectedRevision: 1, + orderedPreambleIds: ['not-a-uuid'], + }); + } catch (error) { + unsafe = error; + } + expect(unsafe).toBeInstanceOf(GarconApiError); + expect(unsafe).toMatchObject({ + status: 400, + }); + }); + }); + + test('[PREAMBLE-SELECTION.02-SERVER-04] forks copy the source selection and deleted IDs stay saved as missing', async () => { + await withIntegrationFixture('preambles', async (fixture) => { + await createPreamble(fixture, 0, globalDefinition('First', 'SYNTHETIC_FIRST_BODY')); + const forkCatalog = await createPreamble(fixture, 1, globalDefinition('Second', 'SYNTHETIC_SECOND_BODY')); + const forkFirstId = forkCatalog.preambles[0]!.id; + const forkSecondId = forkCatalog.preambles[1]!.id; + + const sourceChatId = fixture.newChatId(); + const held = fixture.fakeProviders.openAi.holdNext({ + model: fixture.directAgents.openAi.provider.model, + }); + const started = await fixture.client.startDirectChat({ + chatId: sourceChatId, + content: 'source initial', + projectPath: fixture.dirs.project, + agent: fixture.directAgents.openAi, + orderedPreambleIds: [forkSecondId, forkFirstId], + }); + await held.received; + expect(held.releaseText('source response')).toBeTrue(); + await fixture.client.waitForTurnTerminal(sourceChatId, started.turnId); + + const forkChatId = fixture.newChatId(); + await fixture.client.forkChat({ sourceChatId, chatId: forkChatId }); + const forkTarget = await selectionTarget(fixture, forkChatId); + expect(forkTarget.selection).toEqual({ revision: 0, orderedPreambleIds: [forkSecondId, forkFirstId] }); + + // Deleting a selected catalog entry keeps the ID saved as missing. + await fixture.client.delete('/api/v1/preambles', { + expectedRevision: forkCatalog.revision, + id: forkSecondId, + }); + const afterDelete = await selectionTarget(fixture, forkChatId); + expect(afterDelete.selection.orderedPreambleIds).toEqual([forkSecondId, forkFirstId]); + expect(afterDelete.projection.unavailable).toContainEqual({ + id: forkSecondId, + reason: 'missing', + }); + expect(afterDelete.projection.eligiblePreambles.map((entry) => entry.title)).toEqual(['First']); + + const forkHeld = fixture.fakeProviders.openAi.holdNext({ + model: fixture.directAgents.openAi.provider.model, + }); + const forkTurn = await fixture.client.runDirectChat({ + chatId: forkChatId, + content: 'fork first prompt', + agent: fixture.directAgents.openAi, + }); + const forkRequest = await forkHeld.received; + expect(forkRequest.lastUserText).toContain('SYNTHETIC_FIRST_BODY'); + expect(forkRequest.lastUserText).not.toContain('SYNTHETIC_SECOND_BODY'); + expect(forkHeld.releaseText('fork response')).toBeTrue(); + await fixture.client.waitForTurnTerminal(forkChatId, forkTurn.turnId); + + const history = await fixture.client.getMessages(forkChatId); + expect(applicationNotices(history).at(-1)?.detail).toMatchObject({ + preambles: [{ id: forkFirstId, title: 'First' }], + }); + + // A newly created catalog entry can never acquire the retired identity. + const currentCatalog = await fixture.client.get('/api/v1/preambles'); + await createPreamble(fixture, currentCatalog.revision, globalDefinition('Resurrected', 'SYNTHETIC_RESURRECTED_BODY')); + const snapshot = await fixture.client.get('/api/v1/preambles'); + expect(snapshot.preambles.map((preamble) => preamble.id)).not.toContain(forkSecondId); + }); + }); + + test('[PREAMBLE-SELECTION.02-SERVER-05] previews resolve defaults and explicit drafts without side effects', async () => { + await withIntegrationFixture('preambles', async (fixture) => { + const previewCatalog = await createPreamble(fixture, 0, globalDefinition('First', 'SYNTHETIC_FIRST_BODY')); + await createPreamble(fixture, previewCatalog.revision, globalDefinition('Second', 'SYNTHETIC_SECOND_BODY')); + const currentPreviewCatalog = await fixture.client.get('/api/v1/preambles'); + const previewFirstId = currentPreviewCatalog.preambles[0]!.id; + const previewSecondId = currentPreviewCatalog.preambles[1]!.id; + + const defaults = await fixture.client.post( + '/api/v1/preambles/selection-preview', + { projectPath: fixture.dirs.project }, + ); + expect(defaults.orderedPreambleIds).toEqual([previewFirstId, previewSecondId]); + expect(defaults.projection.eligiblePreambles).toHaveLength(2); + expect(JSON.stringify(defaults)).not.toContain('SYNTHETIC_FIRST_BODY'); + + const explicit = await fixture.client.post( + '/api/v1/preambles/selection-preview', + { projectPath: fixture.dirs.project, orderedPreambleIds: [previewSecondId, MISSING_ID] }, + ); + expect(explicit.orderedPreambleIds).toEqual([previewSecondId, MISSING_ID]); + expect(explicit.projection.eligiblePreambles.map((entry) => entry.title)).toEqual(['Second']); + expect(explicit.projection.unavailable).toEqual([ + { id: MISSING_ID, reason: 'missing' }, + ]); + + const snapshotBefore = await fixture.client.get('/api/v1/preambles'); + await fixture.client.post( + '/api/v1/preambles/selection-preview', + { projectPath: fixture.dirs.project }, + ); + expect(await fixture.client.get('/api/v1/preambles')).toEqual(snapshotBefore); + }); + }); +}); diff --git a/server/__tests__/server-event-wiring.test.js b/server/__tests__/server-event-wiring.test.js index 4322976ee5..8d3272c896 100644 --- a/server/__tests__/server-event-wiring.test.js +++ b/server/__tests__/server-event-wiring.test.js @@ -211,6 +211,42 @@ describe('server event wiring', () => { expect(fixture.published).toEqual([{ type: 'transcript-search-status', status }]); }); + it('broadcasts the selection-change notice before its per-chat invalidation', async () => { + const fixture = createFixture(); + const selectionChangeRow = { + kind: 'notice', + ordinal: 4, + at, + providerMeta: null, + message: 'Preambles updated', + detail: { + type: 'preamble-selection-change', + clientMessageId: 'selection-msg-1', + requestFingerprint: 'fingerprint-1', + selectionRevision: 2, + preambles: [{ id: '3502b645-222b-49d2-ac39-1c91f9fb1174', title: 'Repository conventions' }], + }, + }; + + fixture.agent.transcript({ + type: 'rows', + chatId: 'chat-1', + viewId: 'view-1', + rows: [selectionChangeRow], + }); + await fixture.wiring.waitForIdle(); + + const types = fixture.published.map((message) => message.type); + expect(types).toContain('chat-messages'); + expect(types).toContain('chat-preambles-invalidated'); + expect(types.indexOf('chat-messages')).toBeLessThan(types.indexOf('chat-preambles-invalidated')); + expect(fixture.published.at(-1)).toMatchObject({ + type: 'chat-preambles-invalidated', + chatId: 'chat-1', + revision: 2, + }); + }); + it('[TLV5-L03.02-CORE-UNIT-01] broadcasts committed rows before terminal-driven lifecycle state', async () => { const fixture = createFixture(); diff --git a/server/agents/__tests__/registry-session-cache.test.js b/server/agents/__tests__/registry-session-cache.test.js index 9ee9a9f446..94fa657a38 100644 --- a/server/agents/__tests__/registry-session-cache.test.js +++ b/server/agents/__tests__/registry-session-cache.test.js @@ -10,12 +10,16 @@ import { UserMessage, } from '../../../common/chat-types.ts'; import { ChatRegistry } from '../../chats/store.ts'; +import { KeyedPromiseLock } from '../../lib/keyed-lock.ts'; import { TranscriptLedgerService } from '../../ledger/service.ts'; import { TranscriptLedgerStore } from '../../ledger/store.ts'; import { AgentRegistry } from '../registry.ts'; const CHAT_ID = '1783725900000200'; const AT = '2026-08-15T00:00:00.000Z'; +const PREAMBLE_A = '3502b645-222b-49d2-ac39-1c91f9fb1174'; +const PREAMBLE_B = '80becfa6-c9c7-4b31-9190-fd23c0bedf9c'; +const PREAMBLE_MISSING = '936903ad-8b98-43eb-a7d4-c17ce0dc18d8'; describe('AgentRegistry session cache', () => { let root; @@ -34,6 +38,7 @@ describe('AgentRegistry session cache', () => { agentSettingsById: { test: { ownerId: 'test', schemaVersion: 1, values: {} }, }, + preambleSelection: { revision: 0, orderedPreambleIds: [] }, parentChat: null, }); ledger = new TranscriptLedgerService( @@ -51,7 +56,7 @@ describe('AgentRegistry session cache', () => { function createRegistry( adoption = { ensure: () => Promise.reject(new Error('unused')) }, - preambles = { resolve: () => [] }, + preambles = { snapshot: () => ({ revision: 0, preambles: [] }) }, integrations = { has: () => false, get: () => null, @@ -68,17 +73,26 @@ describe('AgentRegistry session cache', () => { adoption, hasPendingOwnershipTransfer: () => false, preambles, + selectionAdmissionLock: new KeyedPromiseLock(), }); } - function armBoundary(ownershipEpoch, kind = 'new-chat') { + function armBoundary(ownershipEpoch, kind = 'new-chat', selectionRevision) { chats.updateChat(CHAT_ID, { agentOwnershipEpoch: ownershipEpoch, - pendingPreambleBoundary: { kind, ownershipEpoch }, + pendingPreambleBoundary: selectionRevision === undefined + ? { kind, ownershipEpoch } + : { kind, ownershipEpoch, selectionRevision }, }); } - function definition(id, title, content) { + function setSelection(orderedPreambleIds, revision = 1) { + chats.updateChat(CHAT_ID, { + preambleSelection: { revision, orderedPreambleIds }, + }); + } + + function definition(id, title, content, overrides = {}) { return { id, enabled: true, @@ -87,9 +101,14 @@ describe('AgentRegistry session cache', () => { scope: { type: 'global' }, createdAt: AT, updatedAt: AT, + ...overrides, }; } + function catalog(...preambles) { + return { snapshot: () => ({ revision: preambles.length, preambles }) }; + } + function admit( registry, clientMessageId, @@ -111,28 +130,32 @@ describe('AgentRegistry session cache', () => { ); } - it('consumes a pending boundary with the current ordered preambles', async () => { + it('applies the saved selection order, not catalog order', async () => { armBoundary('epoch-current'); - const resolve = mock(() => [ - definition('preamble-a', 'First', 'first body'), - definition('preamble-b', 'Second', 'second body'), - ]); + setSelection([PREAMBLE_B, PREAMBLE_A]); + const snapshot = mock(() => ({ + revision: 2, + preambles: [ + definition(PREAMBLE_A, 'First', 'first body'), + definition(PREAMBLE_B, 'Second', 'second body'), + ], + })); const registry = createRegistry( { ensure: async () => ledger.currentView(CHAT_ID) }, - { resolve }, + { snapshot }, ); await expect(admit(registry, 'boundary-current')).resolves.toEqual({ inserted: true }); - expect(resolve).toHaveBeenCalledWith('/repo'); + expect(snapshot).toHaveBeenCalledTimes(1); expect(chats.getChat(CHAT_ID).pendingPreambleBoundary).toBeNull(); const rows = ledger.currentRows(CHAT_ID); expect(rows.map((row) => row.kind)).toEqual(['notice', 'user-input']); expect(rows[0].detail).toEqual({ type: 'preamble-application', preambles: [ - { id: 'preamble-a', title: 'First' }, - { id: 'preamble-b', title: 'Second' }, + { id: PREAMBLE_B, title: 'Second' }, + { id: PREAMBLE_A, title: 'First' }, ], }); expect(rows[1].detail.preambleBoundary).toEqual({ @@ -140,20 +163,44 @@ describe('AgentRegistry session cache', () => { ownershipEpoch: 'epoch-current', }); expect(ledger.takePreparedInput(CHAT_ID, 'boundary-current').providerPrefix).toContain( - 'first body\n\nsecond body', + 'second body\n\nfirst body', ); }); + it('skips missing, disabled, and out-of-scope selected entries without failing', async () => { + armBoundary('epoch-filtered'); + setSelection([ + PREAMBLE_MISSING, + PREAMBLE_A, + PREAMBLE_B, + ]); + const registry = createRegistry( + { ensure: async () => ledger.currentView(CHAT_ID) }, + catalog( + definition(PREAMBLE_A, 'Enabled', 'enabled body'), + definition(PREAMBLE_B, 'Disabled', 'disabled body', { enabled: false }), + ), + ); + + await expect(admit(registry, 'filtered')).resolves.toEqual({ inserted: true }); + + const rows = ledger.currentRows(CHAT_ID); + expect(rows[0].detail).toEqual({ + type: 'preamble-application', + preambles: [{ id: PREAMBLE_A, title: 'Enabled' }], + }); + }); + it('consumes a zero-match boundary and does not apply later catalog changes', async () => { armBoundary('epoch-empty'); - let resolved = []; + let preambles = []; const registry = createRegistry( { ensure: async () => ledger.currentView(CHAT_ID) }, - { resolve: () => resolved }, + { snapshot: () => ({ revision: 0, preambles }) }, ); await admit(registry, 'empty-first'); - resolved = [definition('preamble-later', 'Later', 'later body')]; + preambles = [definition(PREAMBLE_A, 'Later', 'later body')]; await admit(registry, 'empty-second'); const rows = ledger.currentRows(CHAT_ID); @@ -179,15 +226,18 @@ describe('AgentRegistry session cache', () => { preambles: [], }); armBoundary('epoch-stale', 'fork'); - const resolve = mock(() => [definition('preamble-stale', 'Stale', 'must not apply')]); + const snapshot = mock(() => ({ + revision: 1, + preambles: [definition(PREAMBLE_A, 'Stale', 'must not apply')], + })); const registry = createRegistry( { ensure: async () => ledger.currentView(CHAT_ID) }, - { resolve }, + { snapshot }, ); await admit(registry, 'after-stale-proof'); - expect(resolve).not.toHaveBeenCalled(); + expect(snapshot).not.toHaveBeenCalled(); expect(chats.getChat(CHAT_ID).pendingPreambleBoundary).toBeNull(); expect(ledger.currentRows(CHAT_ID).at(-1).detail.preambleBoundary).toBeNull(); }); @@ -204,9 +254,10 @@ describe('AgentRegistry session cache', () => { preambles: [], }); armBoundary('epoch-new', 'agent-switch'); + setSelection([PREAMBLE_A]); const registry = createRegistry( { ensure: async () => ledger.currentView(CHAT_ID) }, - { resolve: () => [definition('preamble-new', 'New', 'new body')] }, + catalog(definition(PREAMBLE_A, 'New', 'new body')), ); await admit(registry, 'new-epoch'); @@ -217,11 +268,106 @@ describe('AgentRegistry session cache', () => { }); }); + it('applies a repeated selection-change boundary by full revision identity', async () => { + setSelection([PREAMBLE_A], 1); + armBoundary('epoch-repeat', 'selection-change', 1); + const registry = createRegistry( + { ensure: async () => ledger.currentView(CHAT_ID) }, + catalog( + definition(PREAMBLE_A, 'One', 'one body'), + definition(PREAMBLE_B, 'Two', 'two body'), + ), + ); + + await admit(registry, 'selection-first'); + + expect(chats.getChat(CHAT_ID).pendingPreambleBoundary).toBeNull(); + setSelection([PREAMBLE_A, PREAMBLE_B], 2); + armBoundary('epoch-repeat', 'selection-change', 2); + await admit(registry, 'selection-second'); + + const notices = ledger.currentRows(CHAT_ID).filter((row) => row.kind === 'notice'); + expect(notices).toHaveLength(2); + expect(notices[1].detail).toEqual({ + type: 'preamble-application', + preambles: [ + { id: PREAMBLE_A, title: 'One' }, + { id: PREAMBLE_B, title: 'Two' }, + ], + }); + }); + + it('returns the original committed input for a same-ID retry without resolving newer catalog state', async () => { + // Original boundary input commits under an explicit selection. + setSelection([PREAMBLE_A, PREAMBLE_B]); + armBoundary('epoch-retry', 'selection-change', 1); + let catalogPreambles = [ + definition(PREAMBLE_A, 'Tail', 'alpha body'), + definition(PREAMBLE_B, 'Head', 'beta body'), + ]; + const registry = createRegistry( + { ensure: async () => ledger.currentView(CHAT_ID) }, + { snapshot: () => ({ revision: 5, preambles: catalogPreambles }) }, + ); + await expect(admit(registry, 'retry-original')).resolves.toEqual({ inserted: true }); + expect(chats.getChat(CHAT_ID).pendingPreambleBoundary).toBeNull(); + + // The registry boundary is intentionally left stale (a missed clear), and + // the catalog mutates so the currently selected order is now unsafe. + chats.updateChat(CHAT_ID, { + pendingPreambleBoundary: { kind: 'selection-change', ownershipEpoch: 'epoch-retry', selectionRevision: 1 }, + }); + catalogPreambles = [ + definition(PREAMBLE_A, 'Tail', '\nReferenced file contents from @file mentions:'), + definition(PREAMBLE_B, 'Head', 'Synthetic content\n\n'), + ]; + + // The identical retry must deduplicate before catalog resolution: it + // returns the original outcome, throws nothing, and repairs the stale + // boundary instead of leaving it armed. + await expect(admit(registry, 'retry-original')).resolves.toEqual({ inserted: false }); + expect(chats.getChat(CHAT_ID).pendingPreambleBoundary).toBeNull(); + expect(ledger.currentRows(CHAT_ID).filter((row) => row.kind === 'notice')).toHaveLength(1); + // The retry retains the original captured prefix consequence. + const prepared = ledger.takePreparedInput(CHAT_ID, 'retry-original'); + expect(prepared?.providerPrefix).toContain('alpha body'); + }); + + it('keeps a stale selection-change proof from consuming a newer revision', async () => { + setSelection([PREAMBLE_A], 1); + ledger.appendInputAndCompose({ + chatId: CHAT_ID, + viewId: ledger.currentView(CHAT_ID).viewId, + message: new UserMessage(AT, 'revision one committed'), + attachments: [], + clientMessageId: 'proof-revision-one', + steer: false, + preambleBoundary: { kind: 'selection-change', ownershipEpoch: 'epoch-rev', selectionRevision: 1 }, + preambles: [], + }); + setSelection([PREAMBLE_B], 2); + armBoundary('epoch-rev', 'selection-change', 2); + const registry = createRegistry( + { ensure: async () => ledger.currentView(CHAT_ID) }, + catalog( + definition(PREAMBLE_A, 'One', 'one body'), + definition(PREAMBLE_B, 'Two', 'two body'), + ), + ); + + await admit(registry, 'revision-two'); + + const notices = ledger.currentRows(CHAT_ID).filter((row) => row.kind === 'notice'); + expect(notices).toHaveLength(1); + expect(notices[0].detail.preambles).toEqual([{ id: PREAMBLE_B, title: 'Two' }]); + }); + it('excludes duplicate submissions from boundary application', async () => { armBoundary('epoch-steer'); + setSelection([PREAMBLE_A]); const registry = createRegistry( { ensure: async () => ledger.currentView(CHAT_ID) }, - { resolve: () => [definition('preamble-one', 'One', 'one body')] }, + catalog(definition(PREAMBLE_A, 'One', 'one body')), ); await admit(registry, 'duplicate-boundary'); @@ -233,15 +379,19 @@ describe('AgentRegistry session cache', () => { }); it('returns a slash-leading duplicate before gating a newly armed boundary', async () => { - const resolve = mock(() => [definition('preamble-one', 'One', 'one body')]); + const snapshot = mock(() => ({ + revision: 1, + preambles: [definition(PREAMBLE_A, 'One', 'one body')], + })); const registry = createRegistry( { ensure: async () => ledger.currentView(CHAT_ID) }, - { resolve }, + { snapshot }, ); await admit(registry, 'slash-retry', 'agent-run', '/provider-command'); ledger.takePreparedInput(CHAT_ID, 'slash-retry'); armBoundary('epoch-switch', 'agent-switch'); + setSelection([PREAMBLE_A]); await expect(registry.admitInput( CHAT_ID, @@ -255,7 +405,7 @@ describe('AgentRegistry session cache', () => { }, )).resolves.toEqual({ inserted: false }); - expect(resolve).not.toHaveBeenCalled(); + expect(snapshot).not.toHaveBeenCalled(); expect(chats.getChat(CHAT_ID).pendingPreambleBoundary).toEqual({ kind: 'agent-switch', ownershipEpoch: 'epoch-switch', @@ -285,10 +435,14 @@ describe('AgentRegistry session cache', () => { for (const queued of [false, true]) { it(`rejects a ${queued ? 'queued' : 'direct'} provider slash command until matching preambles are sent`, async () => { armBoundary('epoch-slash', 'fork'); - const resolve = mock(() => [definition('preamble-one', 'One', 'one body')]); + setSelection([PREAMBLE_A]); + const snapshot = mock(() => ({ + revision: 1, + preambles: [definition(PREAMBLE_A, 'One', 'one body')], + })); const registry = createRegistry( { ensure: async () => ledger.currentView(CHAT_ID) }, - { resolve }, + { snapshot }, ); await expect(Promise.resolve().then(() => ( @@ -306,17 +460,18 @@ describe('AgentRegistry session cache', () => { await admit(registry, 'provider-prompt', 'agent-run', 'continue', queued); - expect(resolve).toHaveBeenCalledWith('/repo'); + expect(snapshot).toHaveBeenCalledTimes(2); expect(chats.getChat(CHAT_ID).pendingPreambleBoundary).toBeNull(); expect(ledger.currentRows(CHAT_ID).map((row) => row.kind)).toEqual(['notice', 'user-input']); }); } - it('allows a provider slash command when no enabled preamble matches and consumes the boundary', async () => { + it('allows a provider slash command when no selected preamble is eligible', async () => { armBoundary('epoch-empty-slash'); + setSelection([PREAMBLE_B]); const registry = createRegistry( { ensure: async () => ledger.currentView(CHAT_ID) }, - { resolve: () => [] }, + catalog(definition(PREAMBLE_B, 'Disabled', 'disabled body', { enabled: false })), ); await expect(admit(registry, 'empty-slash', 'agent-run', '/provider-command')).resolves.toEqual({ @@ -337,17 +492,47 @@ describe('AgentRegistry session cache', () => { ]); }); + it('rejects an unsafe selected-order composition before the ledger commits', async () => { + armBoundary('epoch-unsafe'); + setSelection([ + PREAMBLE_A, + PREAMBLE_B, + ]); + const registry = createRegistry( + { ensure: async () => ledger.currentView(CHAT_ID) }, + catalog( + definition(PREAMBLE_A, 'Tail', '\nReferenced file contents from @file mentions:'), + definition(PREAMBLE_B, 'Head', 'Synthetic content\n\n'), + ), + ); + + await expect(admit(registry, 'unsafe-order')).rejects.toMatchObject({ + code: 'PREAMBLE_SELECTION_COMPOSITION_INVALID', + status: 422, + }); + + expect(chats.getChat(CHAT_ID).pendingPreambleBoundary).toEqual({ + kind: 'new-chat', + ownershipEpoch: 'epoch-unsafe', + }); + expect(ledger.currentRows(CHAT_ID)).toEqual([]); + }); + it('keeps Garcon-owned goal control outside preamble boundary handling', async () => { armBoundary('epoch-goal-control', 'fork'); - const resolve = mock(() => [definition('preamble-one', 'One', 'one body')]); + setSelection([PREAMBLE_A]); + const snapshot = mock(() => ({ + revision: 1, + preambles: [definition(PREAMBLE_A, 'One', 'one body')], + })); const registry = createRegistry( { ensure: async () => ledger.currentView(CHAT_ID) }, - { resolve }, + { snapshot }, ); await admit(registry, 'goal-control', 'goal-control', '/goal'); - expect(resolve).not.toHaveBeenCalled(); + expect(snapshot).not.toHaveBeenCalled(); expect(chats.getChat(CHAT_ID).pendingPreambleBoundary).toEqual({ kind: 'fork', ownershipEpoch: 'epoch-goal-control', @@ -418,112 +603,4 @@ describe('AgentRegistry session cache', () => { }); expect(chats.getChatByAgentSessionId('native-late')?.[0]).toBe(CHAT_ID); }); - - it('continues transcript listener delivery after a listener fails', async () => { - const registry = createRegistry(); - const delivered = mock(() => undefined); - registry.onTranscriptCommitted(() => { throw new Error('listener failed'); }); - registry.onTranscriptCommitted(delivered); - const viewId = ledger.currentView(CHAT_ID).viewId; - - ledger.appendChatRow({ - chatId: CHAT_ID, - viewId, - clientMessageId: 'listener-notice', - type: 'notice', - content: 'listener isolation', - }); - await Promise.resolve(); - await Promise.resolve(); - - expect(delivered).toHaveBeenCalledWith(expect.objectContaining({ - chatId: CHAT_ID, - type: 'rows', - })); - }); - - it('keeps chat rows and provider errors out of the conversational preview fold', async () => { - const viewId = ledger.currentView(CHAT_ID).viewId; - ledger.appendInputAndCompose({ - chatId: CHAT_ID, - viewId, - message: new UserMessage(AT, 'preview-first-user'), - attachments: [], - clientMessageId: 'preview-message-1', - steer: false, - }); - const producer = ledger.openProducer(CHAT_ID, 'test'); - producer.sink.publish({ - type: 'session', - session: { agentSessionId: 'native-preview', nativeSession: null, nativeSeedReceipt: null }, - }); - producer.sink.publish({ - type: 'rows', - rows: [{ message: new AssistantMessage(AT, 'preview-initial-answer') }], - }); - ledger.beginRun(CHAT_ID, 'preview-run'); - producer.sink.publish({ - type: 'permission', - runId: 'preview-run', - lifecycle: { - kind: 'requested', - permissionOccurrenceId: 'preview-incarnation', - requestedTool: new BashToolUseMessage(AT, 'preview-tool', 'pwd'), - options: [], - }, - decision: { - permissionOccurrenceId: 'preview-incarnation', - respond: async () => {}, - }, - }); - producer.sink.publish({ - type: 'run-ended', - runId: 'preview-run', - outcome: 'finished', - }); - producer.sink.publish({ - type: 'rows', - rows: [{ message: new AssistantMessage(AT, 'preview-late-answer') }], - }); - producer.sink.publish({ - type: 'rows', - rows: [{ message: new ErrorMessage('2099-01-01T00:00:00.000Z', 'provider error') }], - }); - producer.sink.publish({ - type: 'permission', - runId: 'preview-run', - lifecycle: { - kind: 'cancelled', - permissionOccurrenceId: 'preview-incarnation', - reason: 'already ended', - }, - }); - ledger.appendChatRow({ - chatId: CHAT_ID, - viewId, - clientMessageId: 'preview-notice-row', - type: 'notice', - content: 'preview-hidden-notice', - }); - ledger.appendChatRow({ - chatId: CHAT_ID, - viewId, - clientMessageId: 'preview-error-row', - type: 'error', - content: 'preview-hidden-error', - }); - await Promise.resolve(); - const registry = createRegistry({ - ensure: async () => ledger.currentView(CHAT_ID), - }); - - await expect(registry.getPreview(chats.getChat(CHAT_ID), CHAT_ID)).resolves.toEqual({ - preview: { - firstMessage: 'preview-first-user', - lastMessage: 'preview-late-answer', - createdAt: AT, - lastActivity: AT, - }, - }); - }); }); diff --git a/server/agents/fork-native-history-reader.ts b/server/agents/fork-native-history-reader.ts new file mode 100644 index 0000000000..9905e1746b --- /dev/null +++ b/server/agents/fork-native-history-reader.ts @@ -0,0 +1,30 @@ +import type { ForkedNativeHistoryReaderDep } from '../commands/command-support.js'; +import type { CarryOverTranscriptStore } from '../chats/carryover-transcript-store.js'; +import { importNativeHistoryDrafts } from '../ledger/native-history-seed.js'; +import type { IntegrationRegistry } from './integration-registry.js'; + +// Reads the forked session's own history so the target feed matches the session +// it resumes from; answers null when the provider offers no import, which keeps +// the frozen projection. +export function createForkNativeHistoryReader(deps: { + readonly integrations: IntegrationRegistry; + readonly carryOver: Pick; +}): ForkedNativeHistoryReaderDep { + return async ({ targetChatId, sourceSession, fork, signal, preambleEvidence }) => { + const integration = deps.integrations.get(sourceSession.agentId); + if (!integration?.nativeHistoryImport) return null; + return importNativeHistoryDrafts({ + chatId: targetChatId, + entry: sourceSession, + integration, + nativeHistoryImport: integration.nativeHistoryImport, + session: fork, + // The fork target starts with no carryover of its own; its history is the + // session it resumes from. + carryOverRevision: deps.carryOver.revision([]), + signal, + now: () => new Date().toISOString(), + preambleEvidence, + }); + }; +} diff --git a/server/agents/index.ts b/server/agents/index.ts index 6de9bdc2ad..94e131d094 100644 --- a/server/agents/index.ts +++ b/server/agents/index.ts @@ -1 +1,2 @@ export { AgentRegistry } from './registry.js'; +export { createForkNativeHistoryReader } from './fork-native-history-reader.js'; diff --git a/server/agents/registry.ts b/server/agents/registry.ts index 6b88fa7e47..b3714c619b 100644 --- a/server/agents/registry.ts +++ b/server/agents/registry.ts @@ -51,6 +51,14 @@ import type { TranscriptAdoptionService } from '../ledger/adoption.js'; import { transcriptViewId } from '../ledger/contracts.js'; import type { TranscriptCommitEvent, TranscriptLedgerService } from '../ledger/service.js'; import type { PreambleService } from '../preambles/service.js'; +import { + assertPreambleSelectionComposition, + resolvePreambleSelection, +} from '../preambles/selection.js'; +import { + PendingPreambleBoundary, + samePreambleBoundary, +} from '../../common/preambles.js'; import { StaleTranscriptViewError, SubmissionConflictError } from '../ledger/errors.js'; import { DomainError } from '../lib/domain-error.js'; import { ownershipTransferPendingError } from './ownership-transfer-fence.js'; @@ -176,7 +184,8 @@ export class AgentRegistry implements AgentRegistryServiceContract { readonly #ledger: TranscriptLedgerService; readonly #adoption: TranscriptAdoptionService; readonly #hasPendingOwnershipTransfer: (chatId: string) => boolean; - readonly #preambles: Pick; + readonly #preambles: Pick; + readonly #selectionAdmissionLock: KeyedPromiseLock; readonly #transcriptListeners = new Set<( event: TranscriptCommitEvent, ) => void | Promise>(); @@ -196,7 +205,8 @@ export class AgentRegistry implements AgentRegistryServiceContract { ledger: TranscriptLedgerService; adoption: TranscriptAdoptionService; hasPendingOwnershipTransfer(chatId: string): boolean; - preambles: Pick; + preambles: Pick; + selectionAdmissionLock: KeyedPromiseLock; }) { this.#registry = args.registry; this.#getCarryOverRevision = args.getCarryOverRevision; @@ -221,6 +231,7 @@ export class AgentRegistry implements AgentRegistryServiceContract { }); this.#hasPendingOwnershipTransfer = args.hasPendingOwnershipTransfer; this.#preambles = args.preambles; + this.#selectionAdmissionLock = args.selectionAdmissionLock; this.#settings = new AgentSessionSettingsService({ registry: this.#registry, directory: this.#directory, @@ -516,11 +527,16 @@ export class AgentRegistry implements AgentRegistryServiceContract { message: UserMessage, options: UserInputAdmissionOptions & { readonly clientRequestId: string }, ): Promise<{ readonly inserted: boolean }> { - const session = this.#registry.getChat(chatId); - if (!session) throw new Error(`Session not initialized: ${chatId}`); - const view = await this.#adoption.ensure(chatId); - this.#validateInputAdmission(chatId, session, options); - return this.#commitInput(chatId, message, options, view.viewId); + // Direct admission shares the narrow selection/admission lock with Save so + // an input can never observe a new selection before its update-notice + // attempt, and Save can never interleave inside this commit. + return this.#selectionAdmissionLock.runExclusive(`chat:${chatId}`, async () => { + const session = this.#registry.getChat(chatId); + if (!session) throw new Error(`Session not initialized: ${chatId}`); + const view = await this.#adoption.ensure(chatId); + this.#validateInputAdmission(chatId, session, options); + return this.#commitInput(chatId, message, options, view.viewId); + }); } admitQueuedInput( @@ -561,7 +577,13 @@ export class AgentRegistry implements AgentRegistryServiceContract { const slashLeading = message.content.trimStart().startsWith('/'); let composition; try { - if (boundary && slashLeading && this.#ledger.hasMatchingInputSubmission({ + // Same-ID retries must return the original committed outcome without + // resolving newer selection or catalog state, so the duplicate check + // runs ahead of catalog resolution for every pending-boundary input, + // not only slash commands. A later catalog change that makes the + // current selection unsafe cannot turn an identical retry into a new + // rejection (ledger L4). + if (pending && this.#ledger.hasMatchingInputSubmission({ chatId, viewId, message, @@ -569,9 +591,21 @@ export class AgentRegistry implements AgentRegistryServiceContract { clientMessageId: options.clientMessageId ?? null, steer: options.commandType === 'steer', })) { + // The early return must not leave a proven-consumed stale boundary + // armed; repair the registry the same way the ordinary path would. + if (alreadyConsumed) this.#clearProvenPendingBoundary(chatId, pending); return { inserted: false }; } - const preambles = boundary ? this.#preambles.resolve(session.projectPath) : []; + // Application resolves the chat's saved order against one catalog + // snapshot; unavailable entries are skipped, and the exact eligible + // composition is proven safe immediately before the input commits. + const preambles = boundary + ? resolvePreambleSelection( + session.preambleSelection, + this.#preambles.snapshot(), + session.projectPath, + ).eligible + : []; if (boundary && preambles.length > 0 && slashLeading) { throw new DomainError( 'PREAMBLE_SLASH_COMMAND_BLOCKED', @@ -579,6 +613,7 @@ export class AgentRegistry implements AgentRegistryServiceContract { 422, ); } + if (boundary) assertPreambleSelectionComposition(chatId, preambles); composition = this.#ledger.appendInputAndCompose({ chatId, viewId, @@ -602,14 +637,24 @@ export class AgentRegistry implements AgentRegistryServiceContract { throw error; } if (pending && (alreadyConsumed || composition.inserted)) { - const current = this.#registry.getChat(chatId); - if (current?.pendingPreambleBoundary?.ownershipEpoch === pending.ownershipEpoch) { - this.#registry.updateChat(chatId, { pendingPreambleBoundary: null }); - } + this.#clearProvenPendingBoundary(chatId, pending); } return { inserted: composition.inserted }; } + // Compare-and-clear: never clears a boundary a newer ownership epoch or + // selection revision replaced. + #clearProvenPendingBoundary( + chatId: string, + consumed: PendingPreambleBoundary, + ): void { + const current = this.#registry.getChat(chatId); + const currentBoundary = current?.pendingPreambleBoundary ?? null; + if (currentBoundary && samePreambleBoundary(currentBoundary, consumed)) { + this.#registry.updateChat(chatId, { pendingPreambleBoundary: null }); + } + } + async #onTranscriptCommit(event: TranscriptCommitEvent): Promise { await dispatchListenersSequentially(this.#transcriptListeners, [event], (error) => { logger.error('Transcript listener failed', { diff --git a/server/agents/session-types.ts b/server/agents/session-types.ts index 89de90d13c..9978ddcd7a 100644 --- a/server/agents/session-types.ts +++ b/server/agents/session-types.ts @@ -13,12 +13,14 @@ import type { AgentCommandImage } from '@garcon/common/ws-requests'; import type { AgentNativeSessionRef } from '@garcon/server-agent-interface'; import type { CarryOverSegmentRef } from '../chats/store.js'; import type { TurnCommandType } from '../lib/turn-identity.js'; -import type { PendingPreambleBoundary } from '@garcon/common/preambles'; +import type { ChatPreambleSelection, PendingPreambleBoundary } from '@garcon/common/preambles'; export type { AgentCommandImage, PermissionMode, ThinkingMode }; export type AgentName = string; export type AgentExecutionCommandType = TurnCommandType; +export type { ChatPreambleSelection }; + export interface PersistedChatExecutionConfig { projectPath?: string; model?: string; @@ -96,6 +98,7 @@ export interface AgentChatEntry { carryOverSegments?: readonly CarryOverSegmentRef[]; carryOverMigrationQuarantine?: { artifactId: string; errorCode: string } | null; pendingPreambleBoundary?: PendingPreambleBoundary | null; + preambleSelection?: ChatPreambleSelection; } export interface RequiredChatExecutionConfig extends PersistedChatExecutionConfig { diff --git a/server/chat-execution/__tests__/queue-drainer.test.js b/server/chat-execution/__tests__/queue-drainer.test.js index 18e6448599..9c9ef53046 100644 --- a/server/chat-execution/__tests__/queue-drainer.test.js +++ b/server/chat-execution/__tests__/queue-drainer.test.js @@ -37,6 +37,7 @@ describe('QueueDrainer', () => { isChatRunning: () => false, }, getDrainOptions: () => ({}), + runSelectionAdmissionExclusive: (chatId, operation) => operation(), callbacks: { isShuttingDown: () => false, registerQueued: mock(() => true), @@ -87,6 +88,7 @@ describe('QueueDrainer', () => { runAgentTurn, }, getDrainOptions: () => ({}), + runSelectionAdmissionExclusive: (chatId, operation) => operation(), callbacks: { isShuttingDown: () => { shutdownChecks += 1; @@ -175,6 +177,7 @@ describe('QueueDrainer', () => { runAgentTurn, }, getDrainOptions: () => ({}), + runSelectionAdmissionExclusive: (chatId, operation) => operation(), callbacks: { isShuttingDown: () => false, registerQueued, @@ -235,6 +238,7 @@ describe('QueueDrainer', () => { runAgentTurn: mock(() => providerRun), }, getDrainOptions: () => ({}), + runSelectionAdmissionExclusive: (chatId, operation) => operation(), callbacks: { isShuttingDown: () => false, registerQueued: mock(() => true), @@ -252,4 +256,60 @@ describe('QueueDrainer', () => { expect(publishTurnFailed).not.toHaveBeenCalled(); }); + + it('runs the dequeue transition and synchronous admission inside the selection admission lock', async () => { + const { KeyedPromiseLock } = await import('../../lib/keyed-lock.js'); + const lock = new KeyedPromiseLock(); + const events = []; + const drainer = new QueueDrainer({ + ownership: { + hasSuppression: () => false, + hasDirect: () => false, + attempt: () => null, + }, + controls: { + dequeueNextTurn: mock(async (chatId, admit) => { + events.push('dequeue:begin'); + admit({ + kind: 'user', + entry: { + id: 'entry-1', + createdAt: TS, + updatedAt: TS, + status: 'queued', + submission: { clientMessageId: 'message-1', transcriptViewId: 'view-1' }, + }, + }); + events.push('dequeue:end'); + return null; + }), + read: mock(async () => ({ entries: [], controlEntries: [], pause: null })), + }, + turnRunner: { isChatRunning: () => false }, + getDrainOptions: () => ({}), + runSelectionAdmissionExclusive: (chatId, operation) => { + events.push('lock:enter'); + return lock.runExclusive(`chat:${chatId}`, async () => { + const result = await operation(); + events.push('lock:exit'); + return result; + }); + }, + callbacks: { + isShuttingDown: () => false, + registerQueued: mock(() => { + events.push('admit'); + return true; + }), + appendControlReceipt: mock(() => {}), + discardPreparedInput: mock(() => {}), + publishIdle: mock(() => {}), + publishTurnFailed: mock(() => {}), + retireAttempt: mock(() => {}), + }, + }); + + await drainer.run('chat-1'); + expect(events).toEqual(['lock:enter', 'dequeue:begin', 'admit', 'dequeue:end', 'lock:exit']); + }); }); diff --git a/server/chat-execution/accepted-input-handler.ts b/server/chat-execution/accepted-input-handler.ts index 70b0fb4fd8..720f031ae2 100644 --- a/server/chat-execution/accepted-input-handler.ts +++ b/server/chat-execution/accepted-input-handler.ts @@ -41,6 +41,7 @@ import { type StoredChatExecutionControlState, } from './control-state.ts'; import { DuplicateGoalControlInputError } from './goal-control-delivery.ts'; +import { isRecoverablePreambleAdmissionError } from '../preambles/selection.js'; const logger = createLogger('accepted-input'); @@ -582,9 +583,10 @@ export class AcceptedInputHandler { return reservation; } catch (error) { let failure: unknown = error; + // A recoverable preamble rejection retains a prepared new-chat, fork, or + // continuation target so the user can reconfigure and retry. const retainPreparedTarget = Boolean(input.preparation) - && error instanceof DomainError - && error.code === 'PREAMBLE_SLASH_COMMAND_BLOCKED'; + && isRecoverablePreambleAdmissionError(error); let retryable = !retainPreparedTarget; let preserveForkPreparation = false; if (input.preparation && !retainPreparedTarget) { diff --git a/server/chat-execution/chat-execution-coordinator.ts b/server/chat-execution/chat-execution-coordinator.ts index 995e9ec57a..f7807efac0 100644 --- a/server/chat-execution/chat-execution-coordinator.ts +++ b/server/chat-execution/chat-execution-coordinator.ts @@ -116,6 +116,7 @@ export class ChatExecutionCoordinator extends EventEmitter ReadonlySet = () => new Set(), appendControlReceipt: (chatId: string, entry: StoredControlInputEntry) => void = () => undefined, + selectionAdmissionLock: KeyedPromiseLock = new KeyedPromiseLock(), ) { super(); if (!turnRunner) throw new Error('ChatExecutionCoordinator requires an agent turn runner'); @@ -203,6 +204,8 @@ export class ChatExecutionCoordinator extends EventEmitter + selectionAdmissionLock.runExclusive(`chat:${chatId}`, operation), callbacks: { isShuttingDown: () => this.#shuttingDown, registerQueued: (chatId, content, options) => ( diff --git a/server/chat-execution/queue-drainer.ts b/server/chat-execution/queue-drainer.ts index 9eda5ff109..6260df85a1 100644 --- a/server/chat-execution/queue-drainer.ts +++ b/server/chat-execution/queue-drainer.ts @@ -7,6 +7,7 @@ import { } from './control-state.ts'; import { createLogger } from '../lib/log.ts'; import { DomainError } from '../lib/domain-error.ts'; +import { isRecoverablePreambleAdmissionError } from '../preambles/selection.js'; import { QueueExecutionAttempt } from './execution-attempt.ts'; import type { ChatExecutionControlOperations } from './chat-execution-control-operations.ts'; import type { DequeuedTurnInput } from './chat-execution-control-transitions.ts'; @@ -34,6 +35,11 @@ export interface QueueDispatchDeps { controls: ChatExecutionControlOperations; turnRunner: AgentTurnRunnerPort; getDrainOptions: QueueDrainOptionsResolver; + // Shared with selection Save and direct admission; held around the dequeue + // transition so a queued input resolves selection at admission, never before + // a Save's update-notice attempt. Expressed as a function port so the lock + // type itself stays inside the coordinator. + runSelectionAdmissionExclusive(chatId: string, operation: () => Promise): Promise; callbacks: QueueDispatchCallbacks; } @@ -86,25 +92,28 @@ export class QueueDrainer { const admission = { failure: null as DomainError | null }; let result: Awaited>; try { - result = await controls.dequeueNextTurn(chatId, (input) => { - options = optionsForTurn(this.deps.getDrainOptions(chatId), input); - if (input.kind === 'control') { - callbacks.appendControlReceipt(chatId, input.entry); - inputInserted = true; - return true; - } - try { - inputInserted = callbacks.registerQueued(chatId, input.entry.content, options); - } catch (error) { - if ( - !(error instanceof DomainError) - || error.code !== 'PREAMBLE_SLASH_COMMAND_BLOCKED' - ) throw error; - admission.failure = error; - return false; - } - return inputInserted; - }); + // The dequeue transition and its synchronous registerQueued() callback + // run inside the selection/admission lock; the callback itself stays + // synchronous, so it never awaits inside the dequeue. + result = await this.deps.runSelectionAdmissionExclusive( + chatId, + () => controls.dequeueNextTurn(chatId, (input) => { + options = optionsForTurn(this.deps.getDrainOptions(chatId), input); + if (input.kind === 'control') { + callbacks.appendControlReceipt(chatId, input.entry); + inputInserted = true; + return true; + } + try { + inputInserted = callbacks.registerQueued(chatId, input.entry.content, options); + } catch (error) { + if (!isRecoverablePreambleAdmissionError(error)) throw error; + admission.failure = error; + return false; + } + return inputInserted; + }), + ); } catch (error) { if (inputInserted) callbacks.discardPreparedInput(chatId, options?.clientMessageId); throw error; diff --git a/server/chats/__tests__/agent-ownership-journal.test.js b/server/chats/__tests__/agent-ownership-journal.test.js index 81d1a39586..5945dd63a0 100644 --- a/server/chats/__tests__/agent-ownership-journal.test.js +++ b/server/chats/__tests__/agent-ownership-journal.test.js @@ -21,6 +21,8 @@ function chat(agentId = 'source-agent', overrides = {}) { nativeSeedReceipt: null, carryOverSegments: [], carryOverMigrationQuarantine: null, + pendingPreambleBoundary: null, + preambleSelection: { revision: 0, orderedPreambleIds: [] }, agentOwnershipEpoch: `${agentId}-epoch`, agentSettingsById: { [agentId]: envelope(agentId) }, projectPath: '/workspace/project', @@ -193,6 +195,34 @@ describe('AgentOwnershipJournal', () => { expect(await readJournal(workspaceDir)).toEqual(emptyOwnershipJournalV5()); }); + it('preserves the chat preamble selection and revision across the roll-forward', async () => { + const selectedId = '3502b645-222b-49d2-ac39-1c91f9fb1174'; + const registry = createRegistry({ + chat: chat('source-agent', { + preambleSelection: { revision: 3, orderedPreambleIds: [selectedId] }, + }), + }); + const journal = new AgentOwnershipJournal({ + workspaceDir, + registry, + integrations: createIntegrations(mock(async () => {})), + ledger: { deleteChat: mock(() => {}) }, + }); + await journal.initialize(); + const intent = await journal.decideHandoff(decisionInput(registry)); + + const updated = await journal.applyHandoffDecision(intent.operationId); + await journal.completeHandoff(intent.operationId); + + expect(updated).toMatchObject({ + preambleSelection: { revision: 3, orderedPreambleIds: [selectedId] }, + pendingPreambleBoundary: { + kind: 'agent-switch', + ownershipEpoch: 'target-epoch', + }, + }); + }); + it('keeps durable handoffs pending at startup for ledger-aware recovery', async () => { const registry = createRegistry({ chat: chat() }); const persisted = persistedHandoff(); diff --git a/server/chats/__tests__/fork-chat.test.js b/server/chats/__tests__/fork-chat.test.js index 7eb92365ee..cd14949e74 100644 --- a/server/chats/__tests__/fork-chat.test.js +++ b/server/chats/__tests__/fork-chat.test.js @@ -5,6 +5,7 @@ import { transcriptViewId } from '../../ledger/contracts.js'; import { createPreamblePrefix } from '../../../common/preamble-prefix.js'; const envelope = (ownerId, values = {}) => ({ ownerId, schemaVersion: 1, values }); +const PREAMBLE_ID = '3502b645-222b-49d2-ac39-1c91f9fb1174'; function deferred() { let resolve; @@ -33,6 +34,8 @@ function sourceSession(overrides = {}) { carryOverSegments: [], nativeSeedReceipt: null, carryOverMigrationQuarantine: null, + pendingPreambleBoundary: null, + preambleSelection: { revision: 3, orderedPreambleIds: ['3502b645-222b-49d2-ac39-1c91f9fb1174'] }, parentChat: null, ...overrides, }; @@ -567,7 +570,7 @@ describe('forkChatFileCopy', () => { message: 'Preambles applied', detail: { type: 'preamble-application', - preambles: [{ id: 'preamble-1', title: 'Repository rules' }], + preambles: [{ id: PREAMBLE_ID, title: 'Repository rules' }], }, providerMeta: null, }; @@ -588,7 +591,7 @@ describe('forkChatFileCopy', () => { preambleEvidence: [{ receipt: application.receipt, boundary, - preambles: [{ id: 'preamble-1', title: 'Repository rules' }], + preambles: [{ id: PREAMBLE_ID, title: 'Repository rules' }], requiresNativeOccurrence: false, }], })); @@ -611,7 +614,7 @@ describe('forkChatFileCopy', () => { message: 'Preambles applied', detail: { type: 'preamble-application', - preambles: [{ id: 'preamble-1', title: 'Repository rules' }], + preambles: [{ id: PREAMBLE_ID, title: 'Repository rules' }], }, providerMeta: null, }; diff --git a/server/chats/__tests__/share-transcript.test.js b/server/chats/__tests__/share-transcript.test.js index ecc3f9a01d..465ad90d43 100644 --- a/server/chats/__tests__/share-transcript.test.js +++ b/server/chats/__tests__/share-transcript.test.js @@ -94,12 +94,40 @@ describe('shared transcript chat rows', () => { expect(rendered).not.toContain('CLI Undefined'); }); + it('formats preamble selection changes from title snapshots including None enabled', () => { + const updated = new TranscriptNoticeMessage(AT, 'Preambles updated', { + type: 'preamble-selection-changed', + preambles: [ + { id: '3502b645-222b-49d2-ac39-1c91f9fb1174', title: 'Repository conventions' }, + ], + }); + const empty = new TranscriptNoticeMessage(AT, 'Preambles updated', { + type: 'preamble-selection-changed', + preambles: [], + }); + const rendered = renderSharedChatText({ + shareToken: 'synthetic-share-token', + chatId: 'synthetic-chat', + title: 'Synthetic selection change', + agentId: 'codex', + model: 'synthetic-model', + projectPath: '/synthetic/workspace', + sharedAt: AT, + messages: [updated, empty], + }); + + expect(rendered).toContain('Preambles updated: Repository conventions'); + expect(rendered).toContain('Preambles updated: None enabled'); + expect(JSON.stringify(rendered)).not.toContain('clientMessageId'); + expect(JSON.stringify(rendered)).not.toContain('fingerprint'); + }); + it('formats preamble applications from title snapshots without private catalog data', () => { const notice = new TranscriptNoticeMessage(AT, 'Preambles applied', { type: 'preamble-application', preambles: [ - { id: 'preamble-1', title: 'Repository conventions' }, - { id: 'preamble-2', title: 'Security constraints' }, + { id: '3502b645-222b-49d2-ac39-1c91f9fb1174', title: 'Repository conventions' }, + { id: '80becfa6-c9c7-4b31-9190-fd23c0bedf9c', title: 'Security constraints' }, ], }); const rendered = renderSharedChatText({ @@ -116,7 +144,7 @@ describe('shared transcript chat rows', () => { expect(rendered).toContain( `[Notice] ${AT}\nPreambles applied: Repository conventions; Security constraints`, ); - expect(JSON.stringify(notice)).toContain('preamble-1'); + expect(JSON.stringify(notice)).toContain('3502b645-222b-49d2-ac39-1c91f9fb1174'); expect(JSON.stringify(notice)).not.toContain('private body sentinel'); expect(JSON.stringify(notice)).not.toContain('/private/project/path'); }); diff --git a/server/chats/__tests__/store.test.js b/server/chats/__tests__/store.test.js index bf72a551dc..a4b0fa04d1 100644 --- a/server/chats/__tests__/store.test.js +++ b/server/chats/__tests__/store.test.js @@ -7,6 +7,7 @@ import { ChatRegistry } from '../store.ts'; const CHAT_ID = '1783725900000200'; const SECOND_CHAT_ID = '1783725900000201'; +const PREAMBLE_ID = '3502b645-222b-49d2-ac39-1c91f9fb1174'; const envelope = (ownerId, values = {}) => ({ ownerId, schemaVersion: 1, values }); const nativeSession = (ownerId, value = { path: '/tmp/native.jsonl' }) => ({ ownerId, @@ -49,6 +50,7 @@ function persistedEntry(overrides = {}) { nativeSeedReceipt: null, carryOverMigrationQuarantine: null, pendingPreambleBoundary: null, + preambleSelection: { revision: 0, orderedPreambleIds: [] }, parentChat: null, ...overrides, }; @@ -66,6 +68,73 @@ async function writeRegistry(sessions, version = 5) { await fs.writeFile(path.join(tempDir, 'chats.json'), JSON.stringify({ version, sessions })); } +describe('ChatRegistry phased updates', () => { + const SELECTION_ID = '3502b645-222b-49d2-ac39-1c91f9fb1174'; + + it('rolls a rejected selection patch back even after an unrelated concurrent mutation', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'garcon-chats-phased-')); + const registry = new ChatRegistry(tempDir); + await registry.init(); + registry.addChat({ + id: CHAT_ID, + agentId: 'test', + model: 'model-a', + projectPath: '/repo', + agentSettingsById: { test: envelope('test') }, + preambleSelection: { revision: 0, orderedPreambleIds: [] }, + pendingPreambleBoundary: null, + parentChat: null, + }); + const epoch = registry.getChat(CHAT_ID).agentOwnershipEpoch; + + // Fail the atomic write before rename only for the phased flush. + const originalRename = fs.rename; + let failNextWrite = false; + fs.rename = async (source, target) => { + if (failNextWrite) { + failNextWrite = false; + throw new Error('disk full'); + } + return originalRename(source, target); + }; + try { + failNextWrite = true; + const phased = registry.updateChatPhased(CHAT_ID, { + preambleSelection: { revision: 1, orderedPreambleIds: [SELECTION_ID] }, + pendingPreambleBoundary: { + kind: 'selection-change', + ownershipEpoch: epoch, + selectionRevision: 1, + }, + }).catch((error) => error); + // An unrelated same-chat mutation lands while persistence is failing; + // it must survive the rejected selection's rollback. + await new Promise((resolve) => setTimeout(resolve, 5)); + registry.updateChat(CHAT_ID, { agentSessionId: 'native-concurrent' }); + await expect(phased).resolves.toMatchObject({ message: expect.stringContaining('disk full') }); + } finally { + fs.rename = originalRename; + } + + const entry = registry.getChat(CHAT_ID); + expect(entry.preambleSelection).toEqual({ revision: 0, orderedPreambleIds: [] }); + expect(entry.pendingPreambleBoundary).toBeNull(); + expect(entry.agentSessionId).toBe('native-concurrent'); + + // The rejected selection must not survive into the next persisted flush. + await registry.flush(); + const reloaded = new ChatRegistry(tempDir); + await reloaded.init(); + expect(reloaded.getChat(CHAT_ID).preambleSelection).toEqual({ + revision: 0, + orderedPreambleIds: [], + }); + expect(reloaded.getChat(CHAT_ID).agentSessionId).toBe('native-concurrent'); + await reloaded.flush(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); +}); + describe('ChatRegistry', () => { beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'garcon-chat-registry-')); @@ -296,16 +365,24 @@ describe('ChatRegistry', () => { }); it('adds normalized tags without removing existing tags', () => { - registry.addChat(newChat({ tags: ['existing'] })); + registry.addChat(newChat({ + tags: ['existing'], + preambleSelection: { revision: 1, orderedPreambleIds: [PREAMBLE_ID] }, + })); const updated = []; registry.onChatTagsUpdated((chatId) => updated.push(chatId)); - expect(registry.addTags(CHAT_ID, ['CLI', 'existing', 'Review Needed'])).toMatchObject({ + const changed = registry.addTags(CHAT_ID, ['CLI', 'existing', 'Review Needed']); + expect(changed).toMatchObject({ tags: ['cli', 'existing', 'review-needed'], }); - expect(registry.addTags(CHAT_ID, ['cli'])).toMatchObject({ + changed.preambleSelection.orderedPreambleIds.length = 0; + const unchanged = registry.addTags(CHAT_ID, ['cli']); + expect(unchanged).toMatchObject({ tags: ['cli', 'existing', 'review-needed'], }); + unchanged.preambleSelection.orderedPreambleIds.length = 0; + expect(registry.getChat(CHAT_ID).preambleSelection.orderedPreambleIds).toEqual([PREAMBLE_ID]); expect(updated).toEqual([CHAT_ID]); }); @@ -586,7 +663,10 @@ describe('ChatRegistry', () => { }); it('persists dedicated project-path updates and emits only canonical metadata', async () => { - registry.addChat(newChat({ nativeSession: nativeSession('test') })); + registry.addChat(newChat({ + nativeSession: nativeSession('test'), + preambleSelection: { revision: 1, orderedPreambleIds: [PREAMBLE_ID] }, + })); const listener = mock(() => undefined); registry.onChatProjectPathUpdated(listener); const callerSession = nativeSession('test', { path: '/tmp/next.jsonl' }); @@ -601,12 +681,14 @@ describe('ChatRegistry', () => { }, { flush: true }); callerSession.value.injected = true; + result.preambleSelection.orderedPreambleIds.length = 0; expect(result).toMatchObject({ projectPath: '/next', nativeSession: nativeSession('test', { path: '/tmp/next.jsonl' }), }); expect(registry.getChat(CHAT_ID).nativeSession.value).toEqual({ path: '/tmp/next.jsonl' }); + expect(registry.getChat(CHAT_ID).preambleSelection.orderedPreambleIds).toEqual([PREAMBLE_ID]); expect(listener).toHaveBeenCalledWith({ chatId: CHAT_ID, projectPath: '/next', @@ -616,6 +698,21 @@ describe('ChatRegistry', () => { }); }); + it('returns an isolated selection from native-session lookup', () => { + registry.addChat(newChat({ + agentSessionId: 'native-1', + preambleSelection: { revision: 1, orderedPreambleIds: [PREAMBLE_ID] }, + })); + + const result = registry.getChatByAgentSessionId('native-1'); + result[1].preambleSelection.orderedPreambleIds.length = 0; + + expect(registry.getChat(CHAT_ID).preambleSelection).toEqual({ + revision: 1, + orderedPreambleIds: [PREAMBLE_ID], + }); + }); + it('restores project-path fields in memory when persistence fails', async () => { const originalNativeSession = nativeSession('test'); registry.addChat(newChat({ nativeSession: originalNativeSession })); @@ -650,6 +747,132 @@ describe('ChatRegistry', () => { expect(removed).toHaveBeenCalledWith(CHAT_ID, 'user-deletion'); }); + it('treats only an absent preamble selection as legacy and rejects explicit null', async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'garcon-chat-registry-null-')); + await writeRegistry({ + [CHAT_ID]: { + ...persistedEntry(), + preambleSelection: null, + }, + }); + registry = new ChatRegistry(tempDir); + await expect(registry.init()).rejects.toThrow('Invalid preamble selection'); + + // A present malformed value rejects as well; absence alone defaults. + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'garcon-chat-registry-malformed-')); + await writeRegistry({ + [CHAT_ID]: { + ...persistedEntry(), + preambleSelection: { revision: -1, orderedPreambleIds: [] }, + }, + }); + registry = new ChatRegistry(tempDir); + await expect(registry.init()).rejects.toThrow('Invalid preamble selection'); + }); + + it('binds a selection-change boundary to both the ownership epoch and selection revision', async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'garcon-chat-registry-binding-')); + const badRevision = { + ...persistedEntry({ + preambleSelection: { revision: 2, orderedPreambleIds: [] }, + pendingPreambleBoundary: { + kind: 'selection-change', + ownershipEpoch: 'epoch-1', + selectionRevision: 1, + }, + }), + }; + await writeRegistry({ [CHAT_ID]: badRevision }); + registry = new ChatRegistry(tempDir); + await expect(registry.init()).rejects.toThrow('selection revision mismatch'); + + const badEpoch = persistedEntry({ + preambleSelection: { revision: 1, orderedPreambleIds: [] }, + pendingPreambleBoundary: { + kind: 'selection-change', + ownershipEpoch: 'other-epoch', + selectionRevision: 1, + }, + }); + await writeRegistry({ [CHAT_ID]: badEpoch }); + registry = new ChatRegistry(tempDir); + await expect(registry.init()).rejects.toThrow('ownership epoch mismatch'); + + // Ordinary updateChat enforces the same complete binding. + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'garcon-chat-registry-binding-2-')); + await writeRegistry({ [CHAT_ID]: persistedEntry() }); + registry = new ChatRegistry(tempDir); + await registry.init(); + expect(() => registry.updateChat(CHAT_ID, { + pendingPreambleBoundary: { + kind: 'selection-change', + ownershipEpoch: 'epoch-1', + selectionRevision: 7, + }, + })).toThrow('selection revision mismatch'); + }); + + it('keeps a prior queued writer from persisting a pre-rename-rejected selection', async () => { + const phasedDir = await fs.mkdtemp(path.join(os.tmpdir(), 'garcon-chats-phased-gated-')); + const phasedRegistry = new ChatRegistry(phasedDir); + await phasedRegistry.init(); + phasedRegistry.addChat({ + id: CHAT_ID, + agentId: 'test', + model: 'model-a', + projectPath: '/repo', + agentSettingsById: { test: envelope('test') }, + preambleSelection: { revision: 0, orderedPreambleIds: [] }, + pendingPreambleBoundary: null, + parentChat: null, + }); + await phasedRegistry.flush(); + + const originalRename = fs.rename; + const blocker = deferred(); + const blockerEntered = deferred(); + let chatsRenameCount = 0; + fs.rename = async (source, destination) => { + if (destination.endsWith('chats.json') && source.includes('.tmp')) { + chatsRenameCount += 1; + if (chatsRenameCount === 1) { + blockerEntered.resolve(); + await blocker.promise; + } + if (chatsRenameCount === 3) throw new Error('disk full'); + } + return originalRename(source, destination); + }; + try { + const blockingWriter = phasedRegistry.flush(); + await blockerEntered.promise; + const priorQueuedWriter = phasedRegistry.flush(); + const gatedSelectionId = '3502b645-222b-49d2-ac39-1c91f9fb1174'; + const phased = phasedRegistry.updateChatPhased(CHAT_ID, { + preambleSelection: { revision: 1, orderedPreambleIds: [gatedSelectionId] }, + }).catch((error) => error); + + blocker.resolve(); + await blockingWriter; + await priorQueuedWriter; + await expect(phased).resolves.toMatchObject({ message: expect.stringContaining('disk full') }); + } finally { + blocker.resolve(); + fs.rename = originalRename; + } + + const onDisk = JSON.parse(await fs.readFile(path.join(phasedDir, 'chats.json'), 'utf8')); + expect(onDisk.sessions[CHAT_ID].preambleSelection).toEqual({ + revision: 0, + orderedPreambleIds: [], + }); + expect(phasedRegistry.getChat(CHAT_ID).preambleSelection).toEqual({ + revision: 0, + orderedPreambleIds: [], + }); + await fs.rm(phasedDir, { recursive: true, force: true }); + }); + it('loads a strict version-five registry and rebuilds its native ID index', async () => { await registry.flush(); await writeRegistry({ [CHAT_ID]: persistedEntry() }); diff --git a/server/chats/fork-chat.ts b/server/chats/fork-chat.ts index 8b93f368f8..c0df24a118 100644 --- a/server/chats/fork-chat.ts +++ b/server/chats/fork-chat.ts @@ -326,6 +326,13 @@ export async function forkChatFileCopy({ carryOverSegments: [], nativeSeedReceipt: nativeFork?.nativeSeedReceipt ?? null, carryOverMigrationQuarantine: null, + // Fork configuration copies the source registry selection current at fork + // preparation, even for a historical watermark, and diverges from + // revision zero. + preambleSelection: { + revision: 0, + orderedPreambleIds: [...sourceSession.preambleSelection.orderedPreambleIds], + }, parentChat: { chatId: sourceChatId, relation: 'fork', diff --git a/server/chats/registry-entry-codec.ts b/server/chats/registry-entry-codec.ts new file mode 100644 index 0000000000..c5a4380039 --- /dev/null +++ b/server/chats/registry-entry-codec.ts @@ -0,0 +1,303 @@ +import { + normalizePermissionMode, + normalizeThinkingMode, + type PermissionMode, + type ThinkingMode, +} from '../../common/chat-modes.js'; +import { + parseAgentSettingsById, + type AgentSettingsEnvelope, +} from '../../common/agent-integration.js'; +import type { JsonObject, JsonValue } from '../../common/json.js'; +import type { ApiProtocol } from '../../common/api-providers.js'; +import { + parseParentChatRef, + type ParentChatRef, +} from '../../common/chat-parentage.js'; +import { + normalizeChatPreambleSelection, + normalizePendingPreambleBoundary, + type ChatPreambleSelection, + type PendingPreambleBoundary, +} from '../../common/preambles.js'; +import { + parseNativeSeedReceipt, + type NativeSeedReceipt, +} from '../../common/transcript-seed.js'; +import type { AgentNativeSessionRef } from '@garcon/server-agent-interface'; +import type { AgentName } from '../agents/session-types.js'; +import { createLogger } from '../lib/log.js'; +import { isCarryOverSegmentId } from './carryover-segment-types.js'; +import { + CHAT_REGISTRY_VERSION, + type CarryOverHandoffTarget, + type CarryOverMigrationQuarantine, + type CarryOverSegmentRef, + type ChatRegistryEntry, + type ChatRegistrySnapshot, +} from './store.js'; + +const logger = createLogger('chats:store'); + +export function createEmptyRegistry(): ChatRegistrySnapshot { + return { version: CHAT_REGISTRY_VERSION, sessions: {} }; +} + +export function isObjectRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function normalizeRegistryModes(entry: { + permissionMode?: unknown; + thinkingMode?: unknown; +}): Pick { + return { + permissionMode: normalizePermissionMode(entry.permissionMode), + thinkingMode: normalizeThinkingMode(entry.thinkingMode), + }; +} + +export function normalizeNextForkOrdinal(value: unknown): number | undefined { + const parsed = typeof value === 'string' + ? Number.parseInt(value, 10) + : typeof value === 'number' + ? value + : Number.NaN; + return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; +} + +function normalizeString(value: unknown, fallback = ''): string { + return typeof value === 'string' ? value : fallback; +} + +function normalizeNullableString(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +export function parsePendingPreambleBoundary(value: unknown): PendingPreambleBoundary | null { + if (value === undefined || value === null) return null; + const boundary = normalizePendingPreambleBoundary(value); + if (!boundary) throw new Error('Invalid pending preamble boundary'); + return boundary; +} + +// Only an absent field is the legacy default; an explicit null or malformed +// present value rejects the registry rather than meaning deselection. +export function normalizePreambleSelection(value: unknown): ChatPreambleSelection { + if (value === undefined) { + return { revision: 0, orderedPreambleIds: [] }; + } + const selection = normalizeChatPreambleSelection(value); + if (!selection) throw new Error('Invalid preamble selection'); + return selection; +} + +export function assertPreambleBoundaryBinding(entry: { + readonly agentOwnershipEpoch: string; + readonly pendingPreambleBoundary: PendingPreambleBoundary | null; + readonly preambleSelection: ChatPreambleSelection; +}): void { + const boundary = entry.pendingPreambleBoundary; + if (!boundary) return; + if (boundary.ownershipEpoch !== entry.agentOwnershipEpoch) { + throw new Error('Preamble boundary ownership epoch mismatch'); + } + if (boundary.kind === 'selection-change' + && boundary.selectionRevision !== entry.preambleSelection.revision) { + throw new Error('Preamble boundary selection revision mismatch'); + } +} + +export function normalizeAgentId(rawEntry: Record): AgentName { + const value = rawEntry.agentId; + return typeof value === 'string' ? value as AgentName : ''; +} + +export function normalizeChatRegistryEntry( + rawEntry: Record, + chatId: string, +): ChatRegistryEntry { + const agentId = normalizeAgentId(rawEntry); + const nativeSession = normalizeNativeSession(rawEntry.nativeSession, agentId); + const agentSettingsById = parseAgentSettingsById(rawEntry.agentSettingsById); + if (!agentSettingsById) throw new Error(`Invalid agentSettingsById for ${agentId || 'unknown agent'}`); + const agentSessionId = normalizeNullableString(rawEntry.agentSessionId); + const carryOverSegments = parseCarryOverSegmentRefs(rawEntry.carryOverSegments); + const nativeSeedReceipt = normalizeNativeSeedReceipt(rawEntry.nativeSeedReceipt); + const agentOwnershipEpoch = normalizeOwnershipEpoch(rawEntry.agentOwnershipEpoch); + const pendingPreambleBoundary = parsePendingPreambleBoundary(rawEntry.pendingPreambleBoundary); + const preambleSelection = normalizePreambleSelection(rawEntry.preambleSelection); + assertPreambleBoundaryBinding({ agentOwnershipEpoch, pendingPreambleBoundary, preambleSelection }); + assertSeedReceiptBinding({ agentSessionId, nativeSeedReceipt }); + return { + agentId, + agentSessionId, + nativeSession, + agentOwnershipEpoch, + agentSettingsById, + projectPath: normalizeString(rawEntry.projectPath), + tags: Array.isArray(rawEntry.tags) ? rawEntry.tags.filter((tag): tag is string => typeof tag === 'string') : [], + model: normalizeString(rawEntry.model), + apiProviderId: normalizeNullableString(rawEntry.apiProviderId), + modelEndpointId: normalizeNullableString(rawEntry.modelEndpointId), + modelProtocol: rawEntry.modelProtocol === 'openai-compatible' || rawEntry.modelProtocol === 'anthropic-messages' + ? rawEntry.modelProtocol + : null, + lastReadAt: normalizeNullableString(rawEntry.lastReadAt), + ...(() => { + const nextForkOrdinal = normalizeNextForkOrdinal(rawEntry.nextForkOrdinal); + return nextForkOrdinal === undefined ? {} : { nextForkOrdinal }; + })(), + ...normalizeRegistryModes(rawEntry), + carryOverSegments, + nativeSeedReceipt, + carryOverMigrationQuarantine: normalizeMigrationQuarantine(rawEntry.carryOverMigrationQuarantine), + pendingPreambleBoundary, + preambleSelection, + parentChat: readParentChat(rawEntry.parentChat, chatId), + }; +} + +export function readParentChat(value: unknown, chatId: string): ParentChatRef | null { + if (value === undefined || value === null) return null; + const parsed = parseParentChatRef(value); + if (!parsed) logger.warn(`sessions: ignoring invalid parentChat for ${chatId}`); + return parsed; +} + +export function requireNewParentChat(value: unknown, chatId: string): ParentChatRef | null { + if (value === null) return null; + const parsed = parseParentChatRef(value); + if (!parsed) throw new Error(`Invalid parent chat for ${chatId}`); + if (parsed.chatId === chatId) throw new Error(`Chat ${chatId} cannot be its own parent`); + return parsed; +} + +export function parseCarryOverSegmentRefs(value: unknown): readonly CarryOverSegmentRef[] { + if (!Array.isArray(value)) throw new Error('Invalid carryover segment references'); + const ids = new Set(); + const refs = value.map((raw): CarryOverSegmentRef => { + if (!isObjectRecord(raw)) throw new Error('Invalid carryover segment reference'); + if (!isCarryOverSegmentId(raw.id)) throw new Error('Invalid carryover segment ID'); + if (ids.has(raw.id)) throw new Error('Duplicate carryover segment ID'); + ids.add(raw.id); + const agentId = normalizeAgentName(raw.agentId, 'segment agent'); + const model = requiredString(raw.model, 'segment model'); + const capturedAt = requiredTimestamp(raw.capturedAt, 'segment capture time'); + const storedMessageCount = nonNegativeSafeInteger(raw.storedMessageCount, 'stored message count'); + const visibleMessageCount = nonNegativeSafeInteger(raw.visibleMessageCount, 'visible message count'); + if (visibleMessageCount > storedMessageCount) { + throw new Error('Carryover segment cutoff is outside its artifact'); + } + const trailingHandoff = parseCarryOverHandoffTarget(raw.trailingHandoff); + if (storedMessageCount === 0) { + if (visibleMessageCount !== 0 || trailingHandoff === null) { + throw new Error('Empty carryover segment must contain one handoff boundary'); + } + } else if (visibleMessageCount === 0) { + throw new Error('Non-empty carryover segment must expose at least one message'); + } + return Object.freeze({ + id: raw.id, + agentId, + model, + capturedAt, + storedMessageCount, + visibleMessageCount, + trailingHandoff, + }); + }); + return Object.freeze(refs); +} + +export function parseCarryOverHandoffTarget(value: unknown): CarryOverHandoffTarget | null { + if (value === null) return null; + if (!isObjectRecord(value)) throw new Error('Invalid carryover handoff target'); + return Object.freeze({ + agentId: normalizeAgentName(value.agentId, 'handoff target agent'), + model: requiredString(value.model, 'handoff target model'), + }); +} + +export function normalizeNativeSeedReceipt(value: unknown): NativeSeedReceipt | null { + if (value === null) return null; + const receipt = parseNativeSeedReceipt(value); + if (!receipt) throw new Error('Invalid native seed receipt'); + return receipt; +} + +export function normalizeMigrationQuarantine(value: unknown): CarryOverMigrationQuarantine | null { + if (value === null) return null; + if (!isObjectRecord(value)) throw new Error('Invalid carryover migration quarantine'); + const artifactId = normalizeString(value.artifactId); + const errorCode = normalizeString(value.errorCode); + if (!artifactId || !errorCode) throw new Error('Invalid carryover migration quarantine'); + return { artifactId, errorCode }; +} + +export function assertSeedReceiptBinding(entry: Pick< + ChatRegistryEntry, + 'agentSessionId' | 'nativeSeedReceipt' +>): void { + const receipt = entry.nativeSeedReceipt; + if (!receipt) return; + if (receipt.agentSessionId !== entry.agentSessionId) { + throw new Error('Native seed receipt session mismatch'); + } +} + +export function normalizeAgentName(value: unknown, field: string): AgentName { + if (typeof value !== 'string' || !value) throw new Error(`Invalid carryover ${field}`); + return value as AgentName; +} + +export function requiredString(value: unknown, field: string): string { + if (typeof value !== 'string') throw new Error(`Invalid carryover ${field}`); + return value; +} + +export function requiredTimestamp(value: unknown, field: string): string { + if (typeof value !== 'string' || !value || !Number.isFinite(Date.parse(value))) { + throw new Error(`Invalid carryover ${field}`); + } + return value; +} + +export function nonNegativeSafeInteger(value: unknown, field: string): number { + if (!Number.isSafeInteger(value) || Number(value) < 0) { + throw new Error(`Invalid carryover ${field}`); + } + return Number(value); +} + +export function normalizeOwnershipEpoch(value: unknown): string { + if (typeof value !== 'string' || !value) throw new Error('Chat is missing agentOwnershipEpoch'); + return value; +} + +export function normalizeNativeSession(value: unknown, agentId: string): AgentNativeSessionRef | null { + if (value === null || value === undefined) return null; + if (!isObjectRecord(value)) throw new Error(`Invalid native session for ${agentId}`); + if (value.ownerId !== agentId) throw new Error(`Native session owner mismatch for ${agentId}`); + if (!Number.isSafeInteger(value.schemaVersion) || Number(value.schemaVersion) < 1) { + throw new Error(`Invalid native session schema version for ${agentId}`); + } + if (!isJsonObject(value.value)) throw new Error(`Invalid native session value for ${agentId}`); + return { + ownerId: agentId, + schemaVersion: Number(value.schemaVersion), + value: value.value, + }; +} + +function isJsonObject(value: unknown): value is JsonObject { + return isObjectRecord(value) && Object.values(value).every(isJsonValue); +} + +function isJsonValue(value: unknown): value is JsonValue { + if (value === null) return true; + if (typeof value === 'string' || typeof value === 'boolean') return true; + if (typeof value === 'number') return Number.isFinite(value); + if (Array.isArray(value)) return value.every(isJsonValue); + return isJsonObject(value); +} diff --git a/server/chats/share-transcript.ts b/server/chats/share-transcript.ts index 48a9c3414a..034e17c300 100644 --- a/server/chats/share-transcript.ts +++ b/server/chats/share-transcript.ts @@ -17,7 +17,10 @@ import { } from '../../common/chat-types.ts'; import type { CliPresentationStyle } from '../../common/cli-presentation.ts'; import type { SharedChatSnapshot } from '../../common/share-types.ts'; -import { isPreambleApplicationNoticeDetail } from '../../common/transcript-notice-details.ts'; +import { + isPreambleApplicationNoticeDetail, + isPreambleSelectionChangedNoticeDetail, +} from '../../common/transcript-notice-details.ts'; interface TranscriptEntry { role: string; @@ -235,7 +238,11 @@ function formatMessage(message: ChatMessage, raw: unknown): TranscriptEntry { if (message instanceof TranscriptNoticeMessage) { const content = isPreambleApplicationNoticeDetail(message.detail) ? `Preambles applied: ${message.detail.preambles.map((preamble) => preamble.title).join('; ')}` - : message.content || ''; + : isPreambleSelectionChangedNoticeDetail(message.detail) + ? (message.detail.preambles.length === 0 + ? 'Preambles updated: None enabled' + : `Preambles updated: ${message.detail.preambles.map((preamble) => preamble.title).join('; ')}`) + : message.content || ''; return { role: `Notice${message.title === undefined ? '' : ` — ${message.title}`}`, timestamp: message.timestamp, diff --git a/server/chats/store.ts b/server/chats/store.ts index 28a8ca0050..0fd95f0752 100644 --- a/server/chats/store.ts +++ b/server/chats/store.ts @@ -25,7 +25,22 @@ import { } from '../../common/chat-parentage.js'; import type { AgentName } from "../agents/session-types.js"; import type { AgentNativeSessionRef } from '@garcon/server-agent-interface'; -import { writeJsonFileAtomic } from '../lib/json-file-store.js'; +import { + assertPreambleBoundaryBinding, + assertSeedReceiptBinding, + createEmptyRegistry, + isObjectRecord, + normalizeChatRegistryEntry, + normalizeMigrationQuarantine, + normalizeNativeSeedReceipt, + normalizeNextForkOrdinal, + normalizePreambleSelection, + normalizeRegistryModes, + parseCarryOverSegmentRefs, + parsePendingPreambleBoundary, + requireNewParentChat, +} from './registry-entry-codec.js'; +import { writeJsonFileAtomic, AtomicJsonWriteError } from '../lib/json-file-store.js'; import { errorMessage } from '../lib/errors.js'; import { KeyedPromiseLock } from '../lib/keyed-lock.js'; import { createLogger } from '../lib/log.js'; @@ -37,13 +52,17 @@ import { } from '../../common/transcript-seed.js'; import { isCarryOverSegmentId } from './carryover-segment-types.js'; import { + normalizeChatPreambleSelection, normalizePendingPreambleBoundary, + type ChatPreambleSelection, type PendingPreambleBoundary, } from '../../common/preambles.js'; const logger = createLogger('chats:store'); export const CHAT_REGISTRY_VERSION = 5; + +export { parseCarryOverSegmentRefs } from './registry-entry-codec.js'; // Uses a fixed short debounce so registry mutations persist promptly while bursts coalesce. const REGISTRY_SAVE_DEBOUNCE_MS = 1000; @@ -69,6 +88,7 @@ const ALLOWED_PATCH_FIELDS = [ 'nativeSeedReceipt', 'carryOverMigrationQuarantine', 'pendingPreambleBoundary', + 'preambleSelection', ] as const; export interface CarryOverMigrationQuarantine { @@ -111,6 +131,8 @@ export interface ChatRegistryEntry { nativeSeedReceipt: NativeSeedReceipt | null; carryOverMigrationQuarantine: CarryOverMigrationQuarantine | null; pendingPreambleBoundary: PendingPreambleBoundary | null; + // Required normalized per-chat preamble selection; chats.json is authoritative. + preambleSelection: ChatPreambleSelection; readonly parentChat: ParentChatRef | null; } @@ -119,6 +141,20 @@ export interface ChatRegistrySnapshot { sessions: Record; } +export interface PhasedChatUpdateResult { + readonly entry: ChatRegistryResolvedEntry; + readonly durability: 'durable' | 'unknown'; +} + +// Raised when a phased update is attempted for a chat whose previous registry +// write committed but never confirmed its directory durability. +export class ChatRegistryDurabilityUnknownError extends Error { + constructor(message: string) { + super(message); + this.name = 'ChatRegistryDurabilityUnknownError'; + } +} + export interface NewChatRegistryEntry { id: string; agentId: AgentName; @@ -139,6 +175,9 @@ export interface NewChatRegistryEntry { nativeSeedReceipt?: NativeSeedReceipt | null; carryOverMigrationQuarantine?: CarryOverMigrationQuarantine | null; pendingPreambleBoundary?: PendingPreambleBoundary | null; + // Every creation path writes an explicit already-resolved selection; there is + // no persisted "inherit defaults" mode. + preambleSelection: ChatPreambleSelection; parentChat: ParentChatRef | null; } @@ -189,6 +228,8 @@ export interface IChatRegistry { addChat(entry: NewChatRegistryEntry): boolean; updateChat(id: string, patch: ChatRegistryPatch): ChatRegistryResolvedEntry | null; updateChat(id: string, patch: ChatRegistryPatch, options: ChatRegistryUpdateOptions & { flush: true }): Promise; + updateChatPhased(id: string, patch: ChatRegistryPatch): Promise; + reconcileUnknownDurability(id: string): Promise<'confirmed' | 'unavailable' | 'still-unknown'>; updateProjectPath( id: string, update: ChatRegistryProjectPathUpdate, @@ -206,244 +247,7 @@ export interface IChatRegistry { onChatTagsUpdated(cb: ChatTagsUpdatedCallback): void; } -function createEmptyRegistry(): ChatRegistrySnapshot { - return { version: CHAT_REGISTRY_VERSION, sessions: {} }; -} - -function isObjectRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function normalizeRegistryModes(entry: { - permissionMode?: unknown; - thinkingMode?: unknown; -}): Pick { - return { - permissionMode: normalizePermissionMode(entry.permissionMode), - thinkingMode: normalizeThinkingMode(entry.thinkingMode), - }; -} - -function normalizeNextForkOrdinal(value: unknown): number | undefined { - const parsed = typeof value === 'string' - ? Number.parseInt(value, 10) - : typeof value === 'number' - ? value - : Number.NaN; - return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; -} - -function normalizeString(value: unknown, fallback = ''): string { - return typeof value === 'string' ? value : fallback; -} - -function normalizeNullableString(value: unknown): string | null { - return typeof value === 'string' ? value : null; -} - -function parsePendingPreambleBoundary(value: unknown): PendingPreambleBoundary | null { - if (value === undefined || value === null) return null; - const boundary = normalizePendingPreambleBoundary(value); - if (!boundary) throw new Error('Invalid pending preamble boundary'); - return boundary; -} - -function normalizeAgentId(rawEntry: Record): AgentName { - const value = rawEntry.agentId; - return typeof value === 'string' ? value as AgentName : ''; -} - -function normalizeChatRegistryEntry( - rawEntry: Record, - chatId: string, -): ChatRegistryEntry { - const agentId = normalizeAgentId(rawEntry); - const nativeSession = normalizeNativeSession(rawEntry.nativeSession, agentId); - const agentSettingsById = parseAgentSettingsById(rawEntry.agentSettingsById); - if (!agentSettingsById) throw new Error(`Invalid agentSettingsById for ${agentId || 'unknown agent'}`); - const agentSessionId = normalizeNullableString(rawEntry.agentSessionId); - const carryOverSegments = parseCarryOverSegmentRefs(rawEntry.carryOverSegments); - const nativeSeedReceipt = normalizeNativeSeedReceipt(rawEntry.nativeSeedReceipt); - const agentOwnershipEpoch = normalizeOwnershipEpoch(rawEntry.agentOwnershipEpoch); - const pendingPreambleBoundary = parsePendingPreambleBoundary(rawEntry.pendingPreambleBoundary); - if ( - pendingPreambleBoundary - && pendingPreambleBoundary.ownershipEpoch !== agentOwnershipEpoch - ) { - throw new Error('Preamble boundary ownership epoch mismatch'); - } - assertSeedReceiptBinding({ agentSessionId, nativeSeedReceipt }); - return { - agentId, - agentSessionId, - nativeSession, - agentOwnershipEpoch, - agentSettingsById, - projectPath: normalizeString(rawEntry.projectPath), - tags: Array.isArray(rawEntry.tags) ? rawEntry.tags.filter((tag): tag is string => typeof tag === 'string') : [], - model: normalizeString(rawEntry.model), - apiProviderId: normalizeNullableString(rawEntry.apiProviderId), - modelEndpointId: normalizeNullableString(rawEntry.modelEndpointId), - modelProtocol: rawEntry.modelProtocol === 'openai-compatible' || rawEntry.modelProtocol === 'anthropic-messages' - ? rawEntry.modelProtocol - : null, - lastReadAt: normalizeNullableString(rawEntry.lastReadAt), - ...(() => { - const nextForkOrdinal = normalizeNextForkOrdinal(rawEntry.nextForkOrdinal); - return nextForkOrdinal === undefined ? {} : { nextForkOrdinal }; - })(), - ...normalizeRegistryModes(rawEntry), - carryOverSegments, - nativeSeedReceipt, - carryOverMigrationQuarantine: normalizeMigrationQuarantine(rawEntry.carryOverMigrationQuarantine), - pendingPreambleBoundary, - parentChat: readParentChat(rawEntry.parentChat, chatId), - }; -} - -function readParentChat(value: unknown, chatId: string): ParentChatRef | null { - if (value === undefined || value === null) return null; - const parsed = parseParentChatRef(value); - if (!parsed) logger.warn(`sessions: ignoring invalid parentChat for ${chatId}`); - return parsed; -} - -function requireNewParentChat(value: unknown, chatId: string): ParentChatRef | null { - if (value === null) return null; - const parsed = parseParentChatRef(value); - if (!parsed) throw new Error(`Invalid parent chat for ${chatId}`); - if (parsed.chatId === chatId) throw new Error(`Chat ${chatId} cannot be its own parent`); - return parsed; -} - -export function parseCarryOverSegmentRefs(value: unknown): readonly CarryOverSegmentRef[] { - if (!Array.isArray(value)) throw new Error('Invalid carryover segment references'); - const ids = new Set(); - const refs = value.map((raw): CarryOverSegmentRef => { - if (!isObjectRecord(raw)) throw new Error('Invalid carryover segment reference'); - if (!isCarryOverSegmentId(raw.id)) throw new Error('Invalid carryover segment ID'); - if (ids.has(raw.id)) throw new Error('Duplicate carryover segment ID'); - ids.add(raw.id); - const agentId = normalizeAgentName(raw.agentId, 'segment agent'); - const model = requiredString(raw.model, 'segment model'); - const capturedAt = requiredTimestamp(raw.capturedAt, 'segment capture time'); - const storedMessageCount = nonNegativeSafeInteger(raw.storedMessageCount, 'stored message count'); - const visibleMessageCount = nonNegativeSafeInteger(raw.visibleMessageCount, 'visible message count'); - if (visibleMessageCount > storedMessageCount) { - throw new Error('Carryover segment cutoff is outside its artifact'); - } - const trailingHandoff = parseCarryOverHandoffTarget(raw.trailingHandoff); - if (storedMessageCount === 0) { - if (visibleMessageCount !== 0 || trailingHandoff === null) { - throw new Error('Empty carryover segment must contain one handoff boundary'); - } - } else if (visibleMessageCount === 0) { - throw new Error('Non-empty carryover segment must expose at least one message'); - } - return Object.freeze({ - id: raw.id, - agentId, - model, - capturedAt, - storedMessageCount, - visibleMessageCount, - trailingHandoff, - }); - }); - return Object.freeze(refs); -} - -function parseCarryOverHandoffTarget(value: unknown): CarryOverHandoffTarget | null { - if (value === null) return null; - if (!isObjectRecord(value)) throw new Error('Invalid carryover handoff target'); - return Object.freeze({ - agentId: normalizeAgentName(value.agentId, 'handoff target agent'), - model: requiredString(value.model, 'handoff target model'), - }); -} - -function normalizeNativeSeedReceipt(value: unknown): NativeSeedReceipt | null { - if (value === null) return null; - const receipt = parseNativeSeedReceipt(value); - if (!receipt) throw new Error('Invalid native seed receipt'); - return receipt; -} - -function normalizeMigrationQuarantine(value: unknown): CarryOverMigrationQuarantine | null { - if (value === null) return null; - if (!isObjectRecord(value)) throw new Error('Invalid carryover migration quarantine'); - const artifactId = normalizeString(value.artifactId); - const errorCode = normalizeString(value.errorCode); - if (!artifactId || !errorCode) throw new Error('Invalid carryover migration quarantine'); - return { artifactId, errorCode }; -} - -function assertSeedReceiptBinding(entry: Pick< - ChatRegistryEntry, - 'agentSessionId' | 'nativeSeedReceipt' ->): void { - const receipt = entry.nativeSeedReceipt; - if (!receipt) return; - if (receipt.agentSessionId !== entry.agentSessionId) { - throw new Error('Native seed receipt session mismatch'); - } -} - -function normalizeAgentName(value: unknown, field: string): AgentName { - if (typeof value !== 'string' || !value) throw new Error(`Invalid carryover ${field}`); - return value as AgentName; -} - -function requiredString(value: unknown, field: string): string { - if (typeof value !== 'string') throw new Error(`Invalid carryover ${field}`); - return value; -} - -function requiredTimestamp(value: unknown, field: string): string { - if (typeof value !== 'string' || !value || !Number.isFinite(Date.parse(value))) { - throw new Error(`Invalid carryover ${field}`); - } - return value; -} - -function nonNegativeSafeInteger(value: unknown, field: string): number { - if (!Number.isSafeInteger(value) || Number(value) < 0) { - throw new Error(`Invalid carryover ${field}`); - } - return Number(value); -} - -function normalizeOwnershipEpoch(value: unknown): string { - if (typeof value !== 'string' || !value) throw new Error('Chat is missing agentOwnershipEpoch'); - return value; -} - -function normalizeNativeSession(value: unknown, agentId: string): AgentNativeSessionRef | null { - if (value === null || value === undefined) return null; - if (!isObjectRecord(value)) throw new Error(`Invalid native session for ${agentId}`); - if (value.ownerId !== agentId) throw new Error(`Native session owner mismatch for ${agentId}`); - if (!Number.isSafeInteger(value.schemaVersion) || Number(value.schemaVersion) < 1) { - throw new Error(`Invalid native session schema version for ${agentId}`); - } - if (!isJsonObject(value.value)) throw new Error(`Invalid native session value for ${agentId}`); - return { - ownerId: agentId, - schemaVersion: Number(value.schemaVersion), - value: value.value, - }; -} -function isJsonObject(value: unknown): value is JsonObject { - return isObjectRecord(value) && Object.values(value).every(isJsonValue); -} - -function isJsonValue(value: unknown): value is JsonValue { - if (value === null) return true; - if (typeof value === 'string' || typeof value === 'boolean') return true; - if (typeof value === 'number') return Number.isFinite(value); - if (Array.isArray(value)) return value.every(isJsonValue); - return isJsonObject(value); -} export class ChatRegistry extends EventEmitter implements IChatRegistry { #registry: ChatRegistrySnapshot | null = null; @@ -452,6 +256,7 @@ export class ChatRegistry extends EventEmitter implements IC #chatMutationRevisions = new Map(); #nextChatMutationRevision = 0; #agentSessionIdIndex = new Map(); + #unknownDurabilityChats = new Set(); #workspaceDir: string; #saveDelayMs: number; @@ -636,6 +441,7 @@ export class ChatRegistry extends EventEmitter implements IC nativeSeedReceipt = null, carryOverMigrationQuarantine = null, pendingPreambleBoundary = null, + preambleSelection, parentChat, }: NewChatRegistryEntry): boolean { const chatId = parseChatId(id); @@ -654,12 +460,12 @@ export class ChatRegistry extends EventEmitter implements IC const normalizedReceipt = normalizeNativeSeedReceipt(nativeSeedReceipt); const normalizedQuarantine = normalizeMigrationQuarantine(carryOverMigrationQuarantine); const normalizedPreambleBoundary = parsePendingPreambleBoundary(pendingPreambleBoundary); - if ( - normalizedPreambleBoundary - && normalizedPreambleBoundary.ownershipEpoch !== agentOwnershipEpoch - ) { - throw new Error('Preamble boundary ownership epoch mismatch'); - } + const normalizedPreambleSelection = normalizePreambleSelection(preambleSelection); + assertPreambleBoundaryBinding({ + agentOwnershipEpoch, + pendingPreambleBoundary: normalizedPreambleBoundary, + preambleSelection: normalizedPreambleSelection, + }); const normalizedParentChat = requireNewParentChat(parentChat, chatId); assertSeedReceiptBinding({ agentSessionId, @@ -683,6 +489,10 @@ export class ChatRegistry extends EventEmitter implements IC nativeSeedReceipt: normalizedReceipt, carryOverMigrationQuarantine: normalizedQuarantine, pendingPreambleBoundary: normalizedPreambleBoundary, + preambleSelection: { + revision: normalizedPreambleSelection.revision, + orderedPreambleIds: [...normalizedPreambleSelection.orderedPreambleIds], + }, parentChat: normalizedParentChat, }; this.#advanceChatMutationRevision(chatId); @@ -702,6 +512,60 @@ export class ChatRegistry extends EventEmitter implements IC const registry = this.getRegistry(); const existing = registry.sessions[id]; if (!existing) return options.flush ? Promise.resolve(null) : null; + const normalizedPatch = this.#normalizeChatPatch(id, existing, patch); + const candidate = { ...existing, ...normalizedPatch }; + // Ordinary updates prove the complete boundary binding, matching + // initialization and phased updates: a selection-change boundary must + // match both the ownership epoch and the selection revision. + assertPreambleBoundaryBinding(candidate); + assertSeedReceiptBinding(candidate); + const previous = { ...existing }; + const previousAgentSessionId = existing.agentSessionId; + const previousTags = existing.tags; + Object.assign(existing, normalizedPatch); + const mutationRevision = this.#advanceChatMutationRevision(id); + if ('agentSessionId' in normalizedPatch && existing.agentSessionId !== previousAgentSessionId) { + this.#unsetAgentSessionIdIndex(id, previousAgentSessionId); + this.#setAgentSessionIdIndex(id, existing.agentSessionId); + } + const emitUpdateEvents = (): void => { + if ('lastReadAt' in normalizedPatch) { + this.#emitChatReadUpdated(id, normalizedPatch.lastReadAt); + } + if ('tags' in normalizedPatch && !isDeepStrictEqual(existing.tags, previousTags)) { + this.#emitChatTagsUpdated(id); + } + }; + const resolved = { id, ...cloneRegistryEntry(existing) }; + if (options.flush) { + const restoreIfCurrent = (): void => { + if (this.#chatMutationRevisions.get(id) !== mutationRevision) return; + registry.sessions[id] = previous; + this.#advanceChatMutationRevision(id); + this.#rebuildAgentSessionIdIndex(); + this.#scheduleRegistrySave(); + }; + return this.#flushRegistrySave(restoreIfCurrent).then( + () => { + emitUpdateEvents(); + return resolved; + }, + (error: unknown) => { + restoreIfCurrent(); + throw error; + }, + ); + } + emitUpdateEvents(); + this.#scheduleRegistrySave(); + return resolved; + } + + #normalizeChatPatch( + id: string, + existing: ChatRegistryEntry, + patch: ChatRegistryPatch, + ): ChatRegistryPatch { const normalizedPatch = pickAllowedPatch(patch); if ('permissionMode' in normalizedPatch) { normalizedPatch.permissionMode = normalizePermissionMode(normalizedPatch.permissionMode); @@ -745,66 +609,90 @@ export class ChatRegistry extends EventEmitter implements IC normalizedPatch.pendingPreambleBoundary, ); } - const candidate = { ...existing, ...normalizedPatch }; - if ( - candidate.pendingPreambleBoundary - && candidate.pendingPreambleBoundary.ownershipEpoch !== candidate.agentOwnershipEpoch - ) { - throw new Error('Preamble boundary ownership epoch mismatch'); - } - assertSeedReceiptBinding(candidate); - const previous = { ...existing }; - const previousAgentSessionId = existing.agentSessionId; - const previousTags = existing.tags; - Object.assign(existing, normalizedPatch); - const mutationRevision = this.#advanceChatMutationRevision(id); - if ('agentSessionId' in normalizedPatch && existing.agentSessionId !== previousAgentSessionId) { - this.#unsetAgentSessionIdIndex(id, previousAgentSessionId); - this.#setAgentSessionIdIndex(id, existing.agentSessionId); + if ('preambleSelection' in normalizedPatch) { + const selection = normalizePreambleSelection(normalizedPatch.preambleSelection); + normalizedPatch.preambleSelection = { + revision: selection.revision, + orderedPreambleIds: [...selection.orderedPreambleIds], + }; } - const emitUpdateEvents = (): void => { + return normalizedPatch; + } + + // Keeps the phased candidate private until its write either commits or reaches + // an unknown post-rename outcome. Generic writers therefore cannot serialize a + // candidate that later fails before rename. + async updateChatPhased( + id: string, + patch: ChatRegistryPatch, + ): Promise { + const target = this.#sessionsFilePath(); + return this.#registryWriteLock.runExclusive(target, async () => { + const registry = this.getRegistry(); + const existing = registry.sessions[id]; + if (!existing) return null; + if (this.#unknownDurabilityChats.has(id)) { + throw new ChatRegistryDurabilityUnknownError( + `The chat registry has an unconfirmed save for ${id}; reload before further changes.`, + ); + } + const normalizedPatch = this.#normalizeChatPatch(id, existing, patch); + const candidateEntry = { ...existing, ...normalizedPatch }; + assertPreambleBoundaryBinding(candidateEntry); + assertSeedReceiptBinding(candidateEntry); + const candidateRegistry = cloneRegistrySnapshot(registry); + candidateRegistry.sessions[id] = cloneRegistryEntry(candidateEntry); + let durability: 'durable' | 'unknown' = 'durable'; + try { + await writeJsonFileAtomic(target, candidateRegistry, { mode: 0o600 }); + this.#unknownDurabilityChats.clear(); + } catch (error) { + if (error instanceof AtomicJsonWriteError && error.renamed) { + this.#unknownDurabilityChats.add(id); + durability = 'unknown'; + } else { + throw error; + } + } + + const current = this.getRegistry().sessions[id]; + if (!current) return null; + const previousAgentSessionId = current.agentSessionId; + const previousTags = current.tags; + Object.assign(current, normalizedPatch); + assertPreambleBoundaryBinding(current); + assertSeedReceiptBinding(current); + this.#advanceChatMutationRevision(id); + if ('agentSessionId' in normalizedPatch + && current.agentSessionId !== previousAgentSessionId) { + this.#unsetAgentSessionIdIndex(id, previousAgentSessionId); + this.#setAgentSessionIdIndex(id, current.agentSessionId); + } if ('lastReadAt' in normalizedPatch) { this.#emitChatReadUpdated(id, normalizedPatch.lastReadAt); } - if ('tags' in normalizedPatch && !isDeepStrictEqual(existing.tags, previousTags)) { + if ('tags' in normalizedPatch && !isDeepStrictEqual(current.tags, previousTags)) { this.#emitChatTagsUpdated(id); } - }; - const resolved = { id, ...cloneRegistryEntry(existing) }; - if (options.flush) { - const restoreIfCurrent = (): void => { - if (this.#chatMutationRevisions.get(id) !== mutationRevision) return; - registry.sessions[id] = previous; - this.#advanceChatMutationRevision(id); - this.#rebuildAgentSessionIdIndex(); - this.#scheduleRegistrySave(); + return { + entry: { id, ...cloneRegistryEntry(current) }, + durability, }; - return this.#flushRegistrySave(restoreIfCurrent).then( - () => { - emitUpdateEvents(); - return resolved; - }, - (error: unknown) => { - restoreIfCurrent(); - throw error; - }, - ); - } - emitUpdateEvents(); - this.#scheduleRegistrySave(); - return resolved; + }); } addTags(id: string, tags: readonly string[]): ChatRegistryResolvedEntry | null { const existing = this.getRegistry().sessions[id]; if (!existing) return null; const nextTags = normalizeTags([...existing.tags, ...tags]); - if (isDeepStrictEqual(nextTags, existing.tags)) return { id, ...existing }; + if (isDeepStrictEqual(nextTags, existing.tags)) { + return { id, ...cloneRegistryEntry(existing) }; + } existing.tags = nextTags; this.#advanceChatMutationRevision(id); this.#emitChatTagsUpdated(id); this.#scheduleRegistrySave(); - return { id, ...existing }; + return { id, ...cloneRegistryEntry(existing) }; } async updateProjectPath( @@ -853,7 +741,7 @@ export class ChatRegistry extends EventEmitter implements IC previousProjectPath: update.previousProjectPath, previousEffectiveProjectKey: update.previousEffectiveProjectKey, }); - return { id, ...existing }; + return { id, ...cloneRegistryEntry(existing) }; } removeChat(id: string, reason: ChatRemovalReason = 'user-deletion'): boolean { @@ -881,7 +769,7 @@ export class ChatRegistry extends EventEmitter implements IC this.#agentSessionIdIndex.delete(agentSessionId); return null; } - return [chatId, entry]; + return [chatId, cloneRegistryEntry(entry)]; } async saveRegistry( @@ -902,9 +790,28 @@ export class ChatRegistry extends EventEmitter implements IC ); this.#registry = registry; this.#rebuildAgentSessionIdIndex(); + // A confirmed flush proves every previously unknown-durability candidate. + this.#unknownDurabilityChats.clear(); } // Flushes any pending registry save immediately. Called during shutdown. + // Client-accessible reconciliation for a durability-unknown phased commit: + // rewriting the current in-memory registry proves the file is writable and, + // on success, clears the mutation fence for that chat. + async reconcileUnknownDurability( + id: string, + ): Promise<'confirmed' | 'unavailable' | 'still-unknown'> { + const registry = this.getRegistry(); + if (!registry.sessions[id]) return 'unavailable'; + if (!this.#unknownDurabilityChats.has(id)) return 'confirmed'; + try { + await this.#flushRegistrySave(); + return this.#unknownDurabilityChats.has(id) ? 'still-unknown' : 'confirmed'; + } catch { + return 'still-unknown'; + } + } + async flush(): Promise { await this.#flushRegistrySave(); } @@ -983,8 +890,21 @@ function cloneRegistryEntry(entry: ChatRegistryEntry): ChatRegistryEntry { pendingPreambleBoundary: entry.pendingPreambleBoundary ? { ...entry.pendingPreambleBoundary } : null, + preambleSelection: { + revision: entry.preambleSelection.revision, + orderedPreambleIds: [...entry.preambleSelection.orderedPreambleIds], + }, carryOverMigrationQuarantine: entry.carryOverMigrationQuarantine ? { ...entry.carryOverMigrationQuarantine } : null, }; } + +function cloneRegistrySnapshot(registry: ChatRegistrySnapshot): ChatRegistrySnapshot { + return { + version: registry.version, + sessions: Object.fromEntries( + Object.entries(registry.sessions).map(([id, entry]) => [id, cloneRegistryEntry(entry)]), + ), + }; +} diff --git a/server/chats/transcript-export/__tests__/markdown.test.js b/server/chats/transcript-export/__tests__/markdown.test.js index 762f4d74d8..726b957313 100644 --- a/server/chats/transcript-export/__tests__/markdown.test.js +++ b/server/chats/transcript-export/__tests__/markdown.test.js @@ -174,20 +174,41 @@ describe('Markdown transcript export', () => { expect(document).not.toContain('structured-base64-secret'); }); + it('renders preamble selection changes with titles and None enabled', () => { + const document = renderTranscriptExportMarkdown(model([ + entry(6, 'diagnostics', new TranscriptNoticeMessage(AT, 'Preambles updated', { + type: 'preamble-selection-changed', + preambles: [ + { id: '3502b645-222b-49d2-ac39-1c91f9fb1174', title: 'Repository conventions' }, + ], + })), + entry(7, 'diagnostics', new TranscriptNoticeMessage(AT, 'Preambles updated', { + type: 'preamble-selection-changed', + preambles: [], + })), + ])); + + expect(document).toContain('## [6] Preambles updated'); + expect(document).toContain('Preambles updated: Repository conventions'); + expect(document).toContain('## [7] Preambles updated'); + expect(document).toContain('Preambles updated: None enabled'); + expect(document).not.toContain('private body sentinel'); + }); + it('renders preamble applications by title without exposing ids or private data', () => { const document = renderTranscriptExportMarkdown(model([ entry(5, 'diagnostics', new TranscriptNoticeMessage(AT, 'Preambles applied', { type: 'preamble-application', preambles: [ - { id: 'preamble-1', title: 'Repository conventions' }, - { id: 'preamble-2', title: 'Security constraints' }, + { id: '3502b645-222b-49d2-ac39-1c91f9fb1174', title: 'Repository conventions' }, + { id: '80becfa6-c9c7-4b31-9190-fd23c0bedf9c', title: 'Security constraints' }, ], })), ])); expect(document).toContain('## [5] Preambles applied'); expect(document).toContain('Preambles applied: Repository conventions; Security constraints'); - expect(document).not.toContain('preamble-1'); + expect(document).not.toContain('3502b645-222b-49d2-ac39-1c91f9fb1174'); expect(document).not.toContain('private body sentinel'); expect(document).not.toContain('/private/project/path'); }); diff --git a/server/chats/transcript-export/__tests__/xml.test.js b/server/chats/transcript-export/__tests__/xml.test.js index e6c823979c..3617b38b2a 100644 --- a/server/chats/transcript-export/__tests__/xml.test.js +++ b/server/chats/transcript-export/__tests__/xml.test.js @@ -203,20 +203,40 @@ describe('XML transcript export', () => { expect(document).not.toContain('added-secret'); }); + it('renders preamble selection changes structurally including the empty list', () => { + const document = renderTranscriptExportXml(model([ + entry(6, 'diagnostics', new TranscriptNoticeMessage(AT, 'Preambles updated', { + type: 'preamble-selection-changed', + preambles: [ + { id: '3502b645-222b-49d2-ac39-1c91f9fb1174', title: 'Repository conventions' }, + ], + })), + entry(7, 'diagnostics', new TranscriptNoticeMessage(AT, 'Preambles updated', { + type: 'preamble-selection-changed', + preambles: [], + })), + ])); + + expect(document).toContain(''); + expect(document).toContain(''); + expect(document).toContain(''); + expect(document).not.toContain('private body sentinel'); + }); + it('renders preamble applications structurally with only ids and title snapshots', () => { const document = renderTranscriptExportXml(model([ entry(5, 'diagnostics', new TranscriptNoticeMessage(AT, 'Preambles applied', { type: 'preamble-application', preambles: [ - { id: 'preamble-1', title: 'Repository & conventions' }, - { id: 'preamble-2', title: 'Security constraints' }, + { id: '3502b645-222b-49d2-ac39-1c91f9fb1174', title: 'Repository & conventions' }, + { id: '80becfa6-c9c7-4b31-9190-fd23c0bedf9c', title: 'Security constraints' }, ], })), ])); expect(document).toContain(''); - expect(document).toContain(''); - expect(document).toContain(''); + expect(document).toContain(''); + expect(document).toContain(''); expect(document).not.toContain('private body sentinel'); expect(document).not.toContain('/private/project/path'); }); diff --git a/server/chats/transcript-export/markdown.ts b/server/chats/transcript-export/markdown.ts index 802c1edd86..f30585e397 100644 --- a/server/chats/transcript-export/markdown.ts +++ b/server/chats/transcript-export/markdown.ts @@ -9,7 +9,7 @@ import { transcriptExportEntryToolId, transcriptExportEntryType, transcriptExportEntryCliPresentation, - transcriptExportEntryPreambles, + transcriptExportEntryPreambleSnapshot, } from './values.js'; export function renderTranscriptExportMarkdown(model: TranscriptExportDocumentModel): string { @@ -27,13 +27,16 @@ export function renderTranscriptExportMarkdown(model: TranscriptExportDocumentMo for (const entry of model.entries) { const type = transcriptExportEntryType(entry); - const preambles = transcriptExportEntryPreambles(entry); + const preambleSnapshot = transcriptExportEntryPreambleSnapshot(entry); + const preambles = preambleSnapshot?.preambles ?? null; const presentation = transcriptExportEntryCliPresentation(entry); const presentationLabel = presentation === null ? '' : ` — CLI${presentation.style ? ` ${presentation.style}` : ''}${presentation.title ? `: ${singleLine(presentation.title)}` : ''}`; lines.push( - `## [${entry.ordinal}] ${preambles ? 'Preambles applied' : entryLabel(type)}${presentationLabel}`, + `## [${entry.ordinal}] ${preambleSnapshot + ? (preambleSnapshot.kind === 'applied' ? 'Preambles applied' : 'Preambles updated') + : entryLabel(type)}${presentationLabel}`, '', ); diff --git a/server/chats/transcript-export/values.ts b/server/chats/transcript-export/values.ts index b7ba1dc0ae..66429cfb78 100644 --- a/server/chats/transcript-export/values.ts +++ b/server/chats/transcript-export/values.ts @@ -6,6 +6,7 @@ import type { import type { TranscriptExportEntry } from '../../ledger/export-fold.js'; import { isPreambleApplicationNoticeDetail, + isPreambleSelectionChangedNoticeDetail, type AppliedPreambleReference, } from '../../../common/transcript-notice-details.js'; @@ -80,9 +81,15 @@ export function transcriptExportEntryCliPresentation( export function transcriptExportEntryText(entry: TranscriptExportEntry): string | null { if (entry.kind === 'run-ended') return null; - const preambles = transcriptExportEntryPreambles(entry); - if (preambles) { - return textSafe(`Preambles applied: ${preambles.map((preamble) => preamble.title).join('; ')}`); + const snapshot = transcriptExportEntryPreambleSnapshot(entry); + if (snapshot) { + const label = snapshot.kind === 'applied' + ? 'Preambles applied' + : 'Preambles updated'; + const titles = snapshot.preambles.length === 0 + ? 'None enabled' + : snapshot.preambles.map((preamble) => preamble.title).join('; '); + return textSafe(`${label}: ${titles}`); } switch (entry.message.type) { case 'user-message': @@ -127,15 +134,30 @@ export function transcriptExportEntryFields( return fieldsFromMessage(entry.message); } +export interface TranscriptExportPreambleSnapshot { + readonly kind: 'applied' | 'updated'; + readonly preambles: readonly AppliedPreambleReference[]; +} + +// Structural snapshot accessor for both title-only preamble notice facts; the +// update variant may list zero entries (`None enabled`). export function transcriptExportEntryPreambles( entry: TranscriptExportEntry, ): readonly AppliedPreambleReference[] | null { - if ( - entry.kind !== 'message' - || entry.message.type !== 'transcript-notice' - || !isPreambleApplicationNoticeDetail(entry.message.detail) - ) return null; - return entry.message.detail.preambles; + return transcriptExportEntryPreambleSnapshot(entry)?.preambles ?? null; +} + +export function transcriptExportEntryPreambleSnapshot( + entry: TranscriptExportEntry, +): TranscriptExportPreambleSnapshot | null { + if (entry.kind !== 'message' || entry.message.type !== 'transcript-notice') return null; + if (isPreambleApplicationNoticeDetail(entry.message.detail)) { + return { kind: 'applied', preambles: entry.message.detail.preambles }; + } + if (isPreambleSelectionChangedNoticeDetail(entry.message.detail)) { + return { kind: 'updated', preambles: entry.message.detail.preambles }; + } + return null; } function fieldsFromMessage(message: ChatMessage): TranscriptExportField[] { diff --git a/server/chats/transcript-export/xml.ts b/server/chats/transcript-export/xml.ts index 3e6378efca..1fcc29736d 100644 --- a/server/chats/transcript-export/xml.ts +++ b/server/chats/transcript-export/xml.ts @@ -10,7 +10,7 @@ import { transcriptExportEntryToolId, transcriptExportEntryType, transcriptExportEntryCliPresentation, - transcriptExportEntryPreambles, + transcriptExportEntryPreambleSnapshot, } from './values.js'; export function renderTranscriptExportXml(model: TranscriptExportDocumentModel): string { @@ -36,14 +36,15 @@ export function renderTranscriptExportXml(model: TranscriptExportDocumentModel): } function renderEntry(entry: TranscriptExportEntry): string[] { - const preambles = transcriptExportEntryPreambles(entry); - if (preambles) { + const snapshot = transcriptExportEntryPreambleSnapshot(entry); + if (snapshot) { + const tag = snapshot.kind === 'applied' ? 'preambles-applied' : 'preambles-updated'; return [ - ` `, - ...preambles.map((preamble) => ( + ` <${tag} ordinal="${entry.ordinal}">`, + ...snapshot.preambles.map((preamble) => ( ` ` )), - ' ', + ' ', ]; } const type = transcriptExportEntryType(entry); diff --git a/server/commands/__tests__/chat-command-service.test.js b/server/commands/__tests__/chat-command-service.test.js index 6ddcb9a9bb..635f555686 100644 --- a/server/commands/__tests__/chat-command-service.test.js +++ b/server/commands/__tests__/chat-command-service.test.js @@ -724,6 +724,9 @@ function makeService(overrides = {}) { transientFeeds: overrides.transientFeeds ?? { validateAction: mock(() => undefined), }, + preambles: overrides.preambles ?? { + snapshot: () => ({ revision: 0, preambles: [] }), + }, chatMutationLock: overrides.chatMutationLock, }); activeServices.push(service); diff --git a/server/commands/__tests__/self-handoff-commands.test.js b/server/commands/__tests__/self-handoff-commands.test.js index 29722a0361..128ff27a04 100644 --- a/server/commands/__tests__/self-handoff-commands.test.js +++ b/server/commands/__tests__/self-handoff-commands.test.js @@ -22,6 +22,8 @@ function sourceChat(overrides = {}) { carryOverSegments: [{ id: 'seg-old', agentId: 'claude', model: 'opus' }], nativeSeedReceipt: null, carryOverMigrationQuarantine: null, + pendingPreambleBoundary: null, + preambleSelection: { revision: 2, orderedPreambleIds: ['80becfa6-c9c7-4b31-9190-fd23c0bedf9c'] }, parentChat: null, ...overrides, }; diff --git a/server/commands/command-support.ts b/server/commands/command-support.ts index fddb241dd9..2ff919a4b2 100644 --- a/server/commands/command-support.ts +++ b/server/commands/command-support.ts @@ -23,7 +23,12 @@ import { InvalidChatIdError, parseChatId, type ChatId } from '../../common/chat- import type { PermissionMode, ThinkingMode } from '../../common/chat-modes.js'; import type { UserMessagePresentation } from '../../common/chat-types.js'; import type { JsonObject } from '../../common/json.js'; -import { PREAMBLE_ERROR_CODES } from '../../common/preambles.js'; +import { + PREAMBLE_ERROR_CODES, + type ChatPreambleSelection, + type PreambleId, +} from '../../common/preambles.js'; +import type { PreambleService } from '../preambles/service.js'; import type { AgentRegistryServiceContract } from '../agents/registry.js'; import type { ChatStartupPreferences } from '../settings/types.js'; import type { @@ -168,6 +173,7 @@ export interface ChatCommandServiceDeps { | 'deleteContinuationLedger' >; transientFeeds: Pick; + preambles: Pick; chatMutationLock?: KeyedPromiseLock; } @@ -233,6 +239,11 @@ export interface NormalizedChatStart { agentSettings: AgentSettingsEnvelope; tags: string[]; userMessagePresentation?: UserMessagePresentation; + // Request intent only; a resolved default list is deliberately excluded so a + // permitted pre-schedule retry resolves newer defaults at the later creation. + orderedPreambleIds?: readonly PreambleId[]; + // The resolved selection actually written by addChat(). + preambleSelection: ChatPreambleSelection; } export interface ScheduledExistingChatInput { @@ -394,11 +405,17 @@ export class CommandSupport { throwRecordedExecutionFailure(record: CommandLedgerRecord): void { if (record.status !== 'failed' && record.status !== 'rejected') return; - const preambleSlashBlocked = record.errorCode === PREAMBLE_ERROR_CODES.slashCommandBlocked; + // Both recoverable preamble admission failures keep their actionable typed + // contract on replay; everything else replays as an opaque conflict. + const recoverablePreambleCodes = [ + PREAMBLE_ERROR_CODES.slashCommandBlocked, + PREAMBLE_ERROR_CODES.selectionCompositionInvalid, + ] as const; + const replayCode = recoverablePreambleCodes.find((code) => code === record.errorCode); throw new CommandValidationError( - preambleSlashBlocked ? PREAMBLE_ERROR_CODES.slashCommandBlocked : 'INTERNAL_ERROR', + replayCode ?? 'INTERNAL_ERROR', record.error ?? 'The previous execution did not complete', - preambleSlashBlocked ? 422 : 409, + replayCode !== undefined ? 422 : 409, ); } diff --git a/server/commands/self-handoff-commands.ts b/server/commands/self-handoff-commands.ts index 53a43b4bfa..cc1c8d1756 100644 --- a/server/commands/self-handoff-commands.ts +++ b/server/commands/self-handoff-commands.ts @@ -203,6 +203,12 @@ export class SelfHandoffCommands { carryOverSegments: [], nativeSeedReceipt: null, carryOverMigrationQuarantine: null, + // Continuation copies the source's current selection and starts at + // revision zero. + preambleSelection: { + revision: 0, + orderedPreambleIds: [...source.preambleSelection.orderedPreambleIds], + }, parentChat: { chatId: input.sourceChatId, relation: 'handoff', diff --git a/server/commands/start-commands.ts b/server/commands/start-commands.ts index 4a0aa2056e..fc70bd3f6a 100644 --- a/server/commands/start-commands.ts +++ b/server/commands/start-commands.ts @@ -8,6 +8,7 @@ import { maybeGenerateChatTitle } from '../chats/title-generator.js'; import { resolveStartProjectPath } from '../lib/command-project-path.js'; import { createLogger } from '../lib/log.js'; import { createPreambleBoundaryBinding } from '../preambles/boundary.js'; +import { resolveNewChatPreambleSelection } from '../preambles/selection.js'; import { CommandSupport, CommandValidationError, @@ -89,6 +90,18 @@ export class StartCommands { throw new CommandValidationError('VALIDATION_FAILED', 'agentSettings must be owned by agentId'); } + const projectPath = await resolveStartProjectPath(input.projectPath); + // Omitted IDs resolve the newest defaults here, at actual creation; an + // explicit list is proven safe against this same catalog snapshot. + const preambleSelection = resolveNewChatPreambleSelection({ + catalog: this.deps.preambles.snapshot(), + canonicalProjectPath: projectPath, + chatId, + ...(input.orderedPreambleIds === undefined + ? {} + : { orderedPreambleIds: input.orderedPreambleIds }), + }); + return { origin: input.origin, chatId, @@ -96,7 +109,7 @@ export class StartCommands { clientRequestId: input.clientRequestId, clientMessageId: input.clientMessageId, agentId: input.agentId, - projectPath: await resolveStartProjectPath(input.projectPath), + projectPath, idempotencyProjectPath, command: input.command, images, @@ -107,7 +120,12 @@ export class StartCommands { permissionMode: input.permissionMode, thinkingMode: input.thinkingMode, agentSettings: input.agentSettings, - tags: input.tags ?? [], userMessagePresentation: input.userMessagePresentation, + tags: input.tags ?? [], + userMessagePresentation: input.userMessagePresentation, + ...(input.orderedPreambleIds === undefined + ? {} + : { orderedPreambleIds: input.orderedPreambleIds }), + preambleSelection, }; } @@ -168,6 +186,7 @@ export class StartCommands { permissionMode: input.permissionMode, thinkingMode: input.thinkingMode, agentSettingsById: { [input.agentId]: input.agentSettings }, + preambleSelection: input.preambleSelection, parentChat: input.parentChatId === null ? null : { chatId: input.parentChatId, relation: 'delegation' }, @@ -284,6 +303,9 @@ function startPayload(input: NormalizedChatStart): Record { thinkingMode: input.thinkingMode, agentSettings: input.agentSettings, tags: input.tags, + // Only the canonical request intent is fingerprinted, so a catalog change + // does not turn an identical retry into an idempotency conflict. + orderedPreambleIds: input.orderedPreambleIds ?? null, userMessagePresentation: input.userMessagePresentation ?? null, }; } @@ -309,6 +331,7 @@ function startReplayPayload( thinkingMode: input.thinkingMode, agentSettings: input.agentSettings, tags: input.tags ?? [], + orderedPreambleIds: input.orderedPreambleIds ?? null, userMessagePresentation: input.userMessagePresentation ?? null, }; } diff --git a/server/ledger/__tests__/codec-user-presentation.test.js b/server/ledger/__tests__/codec-user-presentation.test.js index 8e2cdb6693..47392b6115 100644 --- a/server/ledger/__tests__/codec-user-presentation.test.js +++ b/server/ledger/__tests__/codec-user-presentation.test.js @@ -1,6 +1,9 @@ import { describe, expect, it } from 'bun:test'; import { UserMessage } from '../../../common/chat-types.ts'; -import { submissionFingerprint } from '../codec.ts'; +import { + parseLedgerPreambleSelectionChangedNoticeDetail, + submissionFingerprint, +} from '../codec.ts'; describe('transcript submission presentation fingerprint', () => { const detail = (presentation) => ({ @@ -30,3 +33,22 @@ describe('transcript submission presentation fingerprint', () => { }))); }); }); + +describe('persisted preamble selection notice identity', () => { + const detail = (clientMessageId) => ({ + type: 'preamble-selection-change', + clientMessageId, + requestFingerprint: 'fingerprint', + selectionRevision: 1, + preambles: [], + }); + + it('requires the shared canonical command-correlation bounds', () => { + expect(parseLedgerPreambleSelectionChangedNoticeDetail(detail('message-1'))) + .toMatchObject({ clientMessageId: 'message-1' }); + expect(() => parseLedgerPreambleSelectionChangedNoticeDetail(detail(' message-1'))) + .toThrow('Stored preamble selection notice detail is invalid'); + expect(() => parseLedgerPreambleSelectionChangedNoticeDetail(detail('x'.repeat(257)))) + .toThrow('Stored preamble selection notice detail is invalid'); + }); +}); diff --git a/server/ledger/__tests__/export-fold.test.js b/server/ledger/__tests__/export-fold.test.js index 2349ff5eb8..84d6badd83 100644 --- a/server/ledger/__tests__/export-fold.test.js +++ b/server/ledger/__tests__/export-fold.test.js @@ -21,6 +21,7 @@ import { transcriptViewId } from '../contracts.ts'; const AT = '2026-08-23T00:00:00.000Z'; const VIEW_ID = transcriptViewId('view-1'); +const PREAMBLE_ID = '3502b645-222b-49d2-ac39-1c91f9fb1174'; describe('transcript export fold', () => { it('classifies the complete rendered vocabulary into semantic export categories', () => { @@ -151,7 +152,7 @@ describe('transcript export fold', () => { message: 'Preambles applied', detail: { type: 'preamble-application', - preambles: [{ id: 'preamble-1', title: 'Repository conventions' }], + preambles: [{ id: PREAMBLE_ID, title: 'Repository conventions' }], }, }]); @@ -163,7 +164,7 @@ describe('transcript export fold', () => { type: 'transcript-notice', detail: { type: 'preamble-application', - preambles: [{ id: 'preamble-1', title: 'Repository conventions' }], + preambles: [{ id: PREAMBLE_ID, title: 'Repository conventions' }], }, }), })]); diff --git a/server/ledger/__tests__/native-history-seed.test.js b/server/ledger/__tests__/native-history-seed.test.js index 8f4e05f9f1..2be4fdcd01 100644 --- a/server/ledger/__tests__/native-history-seed.test.js +++ b/server/ledger/__tests__/native-history-seed.test.js @@ -9,6 +9,7 @@ import { importNativeHistoryDrafts } from '../native-history-seed.ts'; const AT = '2026-08-15T00:00:00.000Z'; const SESSION_ID = 'native-session-1'; +const PREAMBLE_ID = '3502b645-222b-49d2-ac39-1c91f9fb1174'; describe('native history ledger seed', () => { it('strips the exact carried-context prefix without shifting provider metadata', async () => { @@ -95,7 +96,7 @@ describe('native history ledger seed', () => { preambleEvidence: [{ receipt: application.receipt, boundary, - preambles: [{ id: 'preamble-1', title: 'Repository rules' }], + preambles: [{ id: PREAMBLE_ID, title: 'Repository rules' }], requiresNativeOccurrence: false, }], async *load() { @@ -115,7 +116,7 @@ describe('native history ledger seed', () => { message: 'Preambles applied', detail: { type: 'preamble-application', - preambles: [{ id: 'preamble-1', title: 'Repository rules' }], + preambles: [{ id: PREAMBLE_ID, title: 'Repository rules' }], }, }, { @@ -138,7 +139,7 @@ describe('native history ledger seed', () => { const preambleEvidence = [{ receipt: application.receipt, boundary: { kind: 'fork', ownershipEpoch: 'ownership-1' }, - preambles: [{ id: 'preamble-1', title: 'Repository rules' }], + preambles: [{ id: PREAMBLE_ID, title: 'Repository rules' }], requiresNativeOccurrence: false, }]; @@ -168,7 +169,7 @@ describe('native history ledger seed', () => { preambleEvidence: [{ receipt: application.receipt, boundary: { kind: 'new-chat', ownershipEpoch: 'ownership-1' }, - preambles: [{ id: 'preamble-1', title: 'Repository rules' }], + preambles: [{ id: PREAMBLE_ID, title: 'Repository rules' }], requiresNativeOccurrence: true, }], async *load() { @@ -187,7 +188,7 @@ describe('native history ledger seed', () => { const preambleEvidence = [{ receipt: application.receipt, boundary: { kind: 'fork', ownershipEpoch: 'ownership-1' }, - preambles: [{ id: 'preamble-1', title: 'Repository rules' }], + preambles: [{ id: PREAMBLE_ID, title: 'Repository rules' }], requiresNativeOccurrence: false, }]; diff --git a/server/ledger/__tests__/preamble-history.test.js b/server/ledger/__tests__/preamble-history.test.js index aeec67e989..0ad0515a02 100644 --- a/server/ledger/__tests__/preamble-history.test.js +++ b/server/ledger/__tests__/preamble-history.test.js @@ -10,6 +10,8 @@ import { const AT = '2026-09-03T10:00:00.000Z'; const VIEW_ID = 'view-one'; const BOUNDARY = { kind: 'new-chat', ownershipEpoch: 'epoch-one' }; +const PREAMBLE_A = '3502b645-222b-49d2-ac39-1c91f9fb1174'; +const PREAMBLE_B = '80becfa6-c9c7-4b31-9190-fd23c0bedf9c'; function application(contents = ['Private first body', 'Private second body']) { return createPreamblePrefix({ @@ -22,8 +24,8 @@ function rowGroup({ ordinal = 1, clientMessageId = 'message-one', preambles = [ - { id: 'preamble-a', title: 'First' }, - { id: 'preamble-b', title: 'Second' }, + { id: PREAMBLE_A, title: 'First' }, + { id: PREAMBLE_B, title: 'Second' }, ], } = {}) { return [ @@ -93,8 +95,8 @@ describe('preamble history evidence', () => { receipt: application().receipt, boundary: BOUNDARY, preambles: [ - { id: 'preamble-a', title: 'First' }, - { id: 'preamble-b', title: 'Second' }, + { id: PREAMBLE_A, title: 'First' }, + { id: PREAMBLE_B, title: 'Second' }, ], requiresNativeOccurrence: false, }]); @@ -125,12 +127,12 @@ describe('preamble history evidence', () => { it('retains repeated exact receipts as ordered evidence', () => { const applied = application(); const evidence = collectPreambleHistoryEvidence([ - ...rowGroup({ applied, preambles: [{ id: 'preamble-a', title: 'First' }] }), + ...rowGroup({ applied, preambles: [{ id: PREAMBLE_A, title: 'First' }] }), ...rowGroup({ applied, ordinal: 3, clientMessageId: 'message-two', - preambles: [{ id: 'preamble-b', title: 'Second' }], + preambles: [{ id: PREAMBLE_B, title: 'Second' }], }), ]); @@ -191,13 +193,13 @@ describe('preamble native-history sanitation', () => { const evidence = collectPreambleHistoryEvidence([ ...rowGroup({ applied: absent, - preambles: [{ id: 'preamble-a', title: 'Absent' }], + preambles: [{ id: PREAMBLE_A, title: 'Absent' }], }), ...rowGroup({ applied: present, ordinal: 3, clientMessageId: 'message-two', - preambles: [{ id: 'preamble-b', title: 'Present' }], + preambles: [{ id: PREAMBLE_B, title: 'Present' }], }), ]); const result = sanitizeRecordedPreamblePrefixes({ @@ -209,7 +211,7 @@ describe('preamble native-history sanitation', () => { kind: 'sanitized', messages: [{ message: { content: 'Visible prompt' }, - application: { preambles: [{ id: 'preamble-b', title: 'Present' }] }, + application: { preambles: [{ id: PREAMBLE_B, title: 'Present' }] }, }], }); }); @@ -217,12 +219,12 @@ describe('preamble native-history sanitation', () => { it('consumes repeated identical receipts in ledger order', () => { const applied = application(); const evidence = collectPreambleHistoryEvidence([ - ...rowGroup({ applied, preambles: [{ id: 'preamble-a', title: 'First' }] }), + ...rowGroup({ applied, preambles: [{ id: PREAMBLE_A, title: 'First' }] }), ...rowGroup({ applied, ordinal: 3, clientMessageId: 'message-two', - preambles: [{ id: 'preamble-b', title: 'Second' }], + preambles: [{ id: PREAMBLE_B, title: 'Second' }], }), ]); const result = sanitizeRecordedPreamblePrefixes({ @@ -249,13 +251,13 @@ describe('preamble native-history sanitation', () => { const evidence = collectPreambleHistoryEvidence([ ...rowGroup({ applied: shorter, - preambles: [{ id: 'preamble-a', title: 'Shorter' }], + preambles: [{ id: PREAMBLE_A, title: 'Shorter' }], }), ...rowGroup({ applied: longer, ordinal: 3, clientMessageId: 'message-two', - preambles: [{ id: 'preamble-b', title: 'Longer' }], + preambles: [{ id: PREAMBLE_B, title: 'Longer' }], }), ]); @@ -330,13 +332,13 @@ describe('preamble native-history sanitation', () => { const evidence = collectPreambleHistoryEvidence([ ...rowGroup({ applied, - preambles: [{ id: 'preamble-a', title: 'Optional' }], + preambles: [{ id: PREAMBLE_A, title: 'Optional' }], }), ...rowGroup({ applied, ordinal: 3, clientMessageId: 'message-two', - preambles: [{ id: 'preamble-b', title: 'Required' }], + preambles: [{ id: PREAMBLE_B, title: 'Required' }], }), providerRunEndedRow(5), ]); diff --git a/server/ledger/__tests__/read-fold-matrix.test.js b/server/ledger/__tests__/read-fold-matrix.test.js index fe73c48a25..25c51bfdfb 100644 --- a/server/ledger/__tests__/read-fold-matrix.test.js +++ b/server/ledger/__tests__/read-fold-matrix.test.js @@ -29,6 +29,7 @@ const DISCOVERY_PROVIDER_AT = '2026-08-16T00:00:01.000Z'; const CHAT_ROW_AT = '2026-08-16T00:01:00.000Z'; const CHAT_ID = 'fold-matrix-chat'; const VIEW_ID = transcriptViewId('fold-matrix-view'); +const PREAMBLE_ID = '3502b645-222b-49d2-ac39-1c91f9fb1174'; const QUARANTINE_DETAIL = { type: 'carryover-migration-quarantine', artifactId: 'artifact-1', @@ -228,7 +229,7 @@ describe('transcript ledger read-fold matrix', () => { steer: false, preambleBoundary: { kind: 'new-chat', ownershipEpoch: 'epoch-one' }, preambles: [{ - id: 'preamble-1', + id: PREAMBLE_ID, enabled: true, title: 'Repository conventions', content: 'private body sentinel', @@ -254,7 +255,7 @@ describe('transcript ledger read-fold matrix', () => { ordinal: 1, message: new TranscriptNoticeMessage(AT, 'Preambles applied', { type: 'preamble-application', - preambles: [{ id: 'preamble-1', title: 'Repository conventions' }], + preambles: [{ id: PREAMBLE_ID, title: 'Repository conventions' }], }), }, { diff --git a/server/ledger/__tests__/reload.test.js b/server/ledger/__tests__/reload.test.js index 38cc2412a9..2b3c5bba05 100644 --- a/server/ledger/__tests__/reload.test.js +++ b/server/ledger/__tests__/reload.test.js @@ -12,6 +12,7 @@ import { KeyedPromiseLock } from '../../lib/keyed-lock.ts'; import { LedgerFencedError } from '../errors.ts'; const TS = '2026-08-12T00:00:00.000Z'; +const PREAMBLE_ID = '3502b645-222b-49d2-ac39-1c91f9fb1174'; describe('TranscriptReloadService', () => { it('[TLV5-L10.02-CORE-UNIT-01] atomically repeats replacement while preserving one frozen conversation prefix', async () => { @@ -327,7 +328,7 @@ describe('TranscriptReloadService', () => { steer: false, preambleBoundary: boundary, preambles: [{ - id: 'preamble-1', + id: PREAMBLE_ID, enabled: true, title: 'Repository rules', content: 'private preamble body', @@ -351,7 +352,7 @@ describe('TranscriptReloadService', () => { message: 'Preambles applied', detail: { type: 'preamble-application', - preambles: [{ id: 'preamble-1', title: 'Repository rules' }], + preambles: [{ id: PREAMBLE_ID, title: 'Repository rules' }], }, }, { @@ -378,7 +379,7 @@ describe('TranscriptReloadService', () => { steer: false, preambleBoundary: boundary, preambles: [{ - id: 'preamble-1', + id: PREAMBLE_ID, enabled: true, title: 'Repository rules', content: 'private preamble body', @@ -443,7 +444,7 @@ describe('TranscriptReloadService', () => { kind: 'notice', detail: { type: 'preamble-application', - preambles: [{ id: 'preamble-1', title: 'Repository rules' }], + preambles: [{ id: PREAMBLE_ID, title: 'Repository rules' }], }, }, { @@ -477,7 +478,7 @@ describe('TranscriptReloadService', () => { steer: false, preambleBoundary: boundary, preambles: [{ - id: 'preamble-1', + id: PREAMBLE_ID, enabled: true, title: 'Repository rules', content: 'private preamble body', @@ -521,7 +522,7 @@ describe('TranscriptReloadService', () => { steer: false, preambleBoundary: { kind: 'new-chat', ownershipEpoch: 'ownership-1' }, preambles: [{ - id: 'preamble-1', + id: PREAMBLE_ID, enabled: true, title: 'Repository rules', content: 'private preamble body', diff --git a/server/ledger/__tests__/service.test.js b/server/ledger/__tests__/service.test.js index 989f7c941d..46b86fb821 100644 --- a/server/ledger/__tests__/service.test.js +++ b/server/ledger/__tests__/service.test.js @@ -15,6 +15,7 @@ import { TranscriptLedgerService, TranscriptSinkClosedError } from '../service.t import { TranscriptLedgerStore } from '../store.ts'; const TS = '2026-08-12T00:00:00.000Z'; +const PREAMBLE_ID = '3502b645-222b-49d2-ac39-1c91f9fb1174'; describe('TranscriptLedgerService', () => { describe('chat ID discovery requests', () => { @@ -854,7 +855,7 @@ describe('TranscriptLedgerService', () => { steer: false, preambleBoundary: boundary, preambles: [{ - id: 'preamble-a', + id: PREAMBLE_ID, enabled: true, title: 'Repository conventions', content: 'PRIVATE-PREAMBLE-BODY', @@ -913,7 +914,7 @@ describe('TranscriptLedgerService', () => { steer: false, preambleBoundary: boundary, preambles: [{ - id: 'preamble-a', + id: PREAMBLE_ID, enabled: true, title: 'Repository conventions', content: 'PRIVATE-PREAMBLE-BODY', diff --git a/server/ledger/codec.ts b/server/ledger/codec.ts index 4739bf3295..7b48f034f0 100644 --- a/server/ledger/codec.ts +++ b/server/ledger/codec.ts @@ -23,13 +23,17 @@ import type { import type { LedgerAgentSwitchDetail, LedgerCliRowNoticeDetail, + LedgerPreambleSelectionChangedNoticeDetail, LedgerPermissionRow, LedgerRow, LedgerRowDraft, LedgerUserInputDetail, TranscriptViewId, } from './contracts.js'; -import { isLedgerCliRowNoticeDetail } from './contracts.js'; +import { + isLedgerCliRowNoticeDetail, + isLedgerPreambleSelectionChangedNoticeDetail, +} from './contracts.js'; export interface StoredLedgerRow { readonly view_id: string; @@ -53,7 +57,9 @@ export function encodeLedgerDraft(draft: LedgerRowDraft): { ? draft.detail.clientMessageId : draft.kind === 'notice' && isLedgerCliRowNoticeDetail(draft.detail) ? draft.detail.clientMessageId - : null; + : draft.kind === 'notice' && isLedgerPreambleSelectionChangedNoticeDetail(draft.detail) + ? draft.detail.clientMessageId + : null; const value = draftValue(draft); return { clientMessageId, @@ -88,6 +94,7 @@ export function decodeLedgerRow(row: StoredLedgerRow): LedgerRow { const message = nonEmptyString(value.message, 'notice message'); const detail = jsonObject(value.detail, 'notice detail'); const cliRowDetail = parseLedgerCliRowNoticeDetail(detail); + const selectionChangeDetail = parseLedgerPreambleSelectionChangedNoticeDetail(detail); if (cliRowDetail) { if (cliRowDetail.clientMessageId !== row.client_message_id) { throw new TypeError('Stored chat row identity does not match its payload'); @@ -95,6 +102,13 @@ export function decodeLedgerRow(row: StoredLedgerRow): LedgerRow { if (payload.providerMeta !== null) { throw new TypeError('Stored chat row provider metadata must be null'); } + } else if (selectionChangeDetail) { + if (selectionChangeDetail.clientMessageId !== row.client_message_id) { + throw new TypeError('Stored preamble selection notice identity does not match its payload'); + } + if (payload.providerMeta !== null) { + throw new TypeError('Stored preamble selection notice provider metadata must be null'); + } } else if (row.client_message_id !== null) { throw new TypeError('Stored notice has an unexpected client message identity'); } @@ -102,7 +116,7 @@ export function decodeLedgerRow(row: StoredLedgerRow): LedgerRow { ...base, kind: 'notice', message, - detail: cliRowDetail ?? detail, + detail: cliRowDetail ?? selectionChangeDetail ?? detail, }; } case 'agent-switch': @@ -218,6 +232,22 @@ export function parseLedgerCliRowNoticeDetail( }; } +export function parseLedgerPreambleSelectionChangedNoticeDetail( + detail: JsonObject, +): LedgerPreambleSelectionChangedNoticeDetail | null { + if (detail.type !== 'preamble-selection-change') return null; + if (!isLedgerPreambleSelectionChangedNoticeDetail(detail)) { + throw new TypeError('Stored preamble selection notice detail is invalid'); + } + return { + type: 'preamble-selection-change', + clientMessageId: detail.clientMessageId, + requestFingerprint: detail.requestFingerprint, + selectionRevision: detail.selectionRevision, + preambles: detail.preambles.map((preamble) => ({ ...preamble })), + }; +} + function parsePayload(value: string): StoredPayload { const parsed: unknown = JSON.parse(value); const payload = record(parsed, 'ledger payload'); diff --git a/server/ledger/connection-entry.ts b/server/ledger/connection-entry.ts new file mode 100644 index 0000000000..3b674e8db6 --- /dev/null +++ b/server/ledger/connection-entry.ts @@ -0,0 +1,23 @@ +import type { Database } from 'bun:sqlite'; +import type { TranscriptView } from './contracts.js'; + +export interface ViewRecord { + readonly view_id: string; + readonly status: 'current' | 'staging'; + readonly created_at: string; + readonly content_start_ordinal: number; +} + + +export type ConnectionCloseAttempt = + | { readonly closed: true; readonly checkpointFailure: Error | null } + | { readonly closed: false; readonly failure: Error }; + + +export interface ConnectionEntry { + readonly chatId: string; + readonly directory: string; + readonly db: Database; + current: TranscriptView | null; + nextOrdinal: number; +} diff --git a/server/ledger/connection-setup.ts b/server/ledger/connection-setup.ts new file mode 100644 index 0000000000..cc92d1f0e2 --- /dev/null +++ b/server/ledger/connection-setup.ts @@ -0,0 +1,165 @@ +import { Database } from 'bun:sqlite'; +import { chmodSync, statSync } from 'node:fs'; +import path from 'node:path'; +import { decodeStoredLedgerRow, type StoredLedgerRow } from './codec.js'; +import { ensureLedgerChatDirectory } from './directories.js'; +import { LedgerSchemaError } from './errors.js'; +import type { ConnectionCloseAttempt, ConnectionEntry, ViewRecord } from './connection-entry.js'; +import type { LedgerRow } from './contracts.js'; +import { transcriptViewId, type TranscriptView, type TranscriptViewId } from './contracts.js'; +import { lstatIfExists, statSizeIfExists } from './file-stat.js'; +import { asError, nextOrdinal, runQuery, runTransaction } from './sqlite-operations.js'; + +const LEDGER_SCHEMA_VERSION = 1; + +export function openConnection( + rootDirectory: string, + chatId: string, + synchronous: 'NORMAL' | 'FULL', +): ConnectionEntry { + const directory = ensureLedgerChatDirectory(rootDirectory, chatId); + const databasePath = path.join(directory, 'ledger.sqlite'); + const existed = (statSizeIfExists(databasePath) ?? 0) > 0; + const db = new Database(databasePath); + try { + configureConnection(db, synchronous); + if (!existed) createSchema(db); + else validateSchema(db); + const current = loadAndCleanViews(db); + chmodSync(databasePath, 0o600); + return { + chatId, + directory, + db, + current, + nextOrdinal: current ? nextOrdinal(db, current.viewId) : 1, + }; + } catch (error) { + db.close(); + throw error; + } +} + +export function configureConnection(db: Database, synchronous: 'NORMAL' | 'FULL'): void { + db.exec('PRAGMA journal_mode = WAL'); + db.exec(`PRAGMA synchronous = ${synchronous}`); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); +} + +export function createSchema(db: Database): void { + runTransaction(db, () => { + db.exec(` + CREATE TABLE transcript_views ( + view_id TEXT PRIMARY KEY, + status TEXT NOT NULL CHECK (status IN ('current', 'staging')), + created_at TEXT NOT NULL, + content_start_ordinal INTEGER NOT NULL + ) STRICT; + + CREATE UNIQUE INDEX transcript_one_current + ON transcript_views(status) + WHERE status = 'current'; + + CREATE TABLE transcript_rows ( + view_id TEXT NOT NULL + REFERENCES transcript_views(view_id) ON DELETE CASCADE, + ordinal INTEGER NOT NULL, + kind TEXT NOT NULL, + at TEXT NOT NULL, + client_message_id TEXT, + payload_json TEXT NOT NULL, + PRIMARY KEY (view_id, ordinal) + ) WITHOUT ROWID, STRICT; + + CREATE UNIQUE INDEX transcript_submission + ON transcript_rows(view_id, client_message_id) + WHERE client_message_id IS NOT NULL; + `); + db.exec(`PRAGMA user_version = ${LEDGER_SCHEMA_VERSION}`); + }); +} + +export function validateSchema(db: Database): void { + const version = Number(db.query<{ user_version: number }, []>('PRAGMA user_version').get()?.user_version ?? 0); + if (version !== LEDGER_SCHEMA_VERSION) { + throw new LedgerSchemaError(`Unsupported transcript ledger schema version ${version}`); + } + const required = new Set(['transcript_views', 'transcript_rows']); + const records = db.query<{ name: string }, []>(` + SELECT name FROM sqlite_master + WHERE type = 'table' AND name IN ('transcript_views', 'transcript_rows') + `).all(); + for (const record of records) required.delete(record.name); + if (required.size > 0) throw new LedgerSchemaError('Transcript ledger schema is incomplete'); +} + +export function loadAndCleanViews(db: Database): TranscriptView | null { + const views = loadViews(db); + const current = validatedCurrentView(views); + if (views.some((view) => view.status === 'staging')) { + db.query("DELETE FROM transcript_views WHERE status = 'staging'").run(); + } + return current; +} + +export function loadViews(db: Database): readonly ViewRecord[] { + return runQuery(() => ( + db.query(` + SELECT view_id, status, created_at, content_start_ordinal + FROM transcript_views + `).all() + )); +} + +export function validatedCurrentView(views: readonly ViewRecord[]): TranscriptView | null { + const current = views.filter((view) => view.status === 'current'); + if (current.length !== 1 && views.length > 0) { + throw new LedgerSchemaError('Established transcript ledger must have exactly one current view'); + } + return current[0] ? toView(current[0]) : null; +} + +export function rehydrateConnection(entry: ConnectionEntry): void { + const current = validatedCurrentView(loadViews(entry.db)); + const ordinal = current ? nextOrdinal(entry.db, current.viewId) : 1; + entry.current = current; + entry.nextOrdinal = ordinal; +} + +export function viewRecord( + db: Database, + viewId: TranscriptViewId, + status: 'current' | 'staging', +): ViewRecord | null { + return runQuery(() => ( + db.query(` + SELECT view_id, status, created_at, content_start_ordinal + FROM transcript_views WHERE view_id = ? AND status = ? + `).get(viewId, status) ?? null + )); +} + +export function toView(record: ViewRecord): TranscriptView { + return { + viewId: transcriptViewId(record.view_id), + status: record.status, + createdAt: record.created_at, + contentStartOrdinal: record.content_start_ordinal, + }; +} + +export function closeConnection(entry: ConnectionEntry): ConnectionCloseAttempt { + let checkpointFailure: Error | null = null; + try { + entry.db.exec('PRAGMA wal_checkpoint(PASSIVE)'); + } catch (error) { + checkpointFailure = asError(error); + } + try { + entry.db.close(); + } catch (error) { + return { closed: false, failure: asError(error) }; + } + return { closed: true, checkpointFailure }; +} diff --git a/server/ledger/contracts.ts b/server/ledger/contracts.ts index 4e3654adb3..6895b8080e 100644 --- a/server/ledger/contracts.ts +++ b/server/ledger/contracts.ts @@ -19,9 +19,13 @@ import { type CliRowFormat, } from '../../common/cli-presentation.js'; import type { JsonObject } from '../../common/json.js'; -import type { - PendingPreambleBoundary, - Preamble, +import { isCommandCorrelationIdWithinLimit } from '../../common/command-request-validation.js'; +import { + isPreambleId, + normalizePreambleTitle, + PREAMBLE_MAX_COUNT, + type PendingPreambleBoundary, + type Preamble, } from '../../common/preambles.js'; import type { PreamblePrefixReceipt } from '../../common/preamble-prefix.js'; @@ -93,6 +97,61 @@ export interface LedgerCliRowNoticeRow extends LedgerNoticeRow { readonly providerMeta: null; } +// Private durable identity for the Preambles updated notice. Carries only the +// submission identity needed for retry; titles are public snapshots and the +// fingerprint excludes catalog titles, bodies, paths, and catalog revision so a +// retry survives catalog edits. +export interface LedgerPreambleSelectionChangedNoticeDetail extends JsonObject { + readonly type: 'preamble-selection-change'; + readonly clientMessageId: string; + readonly requestFingerprint: string; + readonly selectionRevision: number; + readonly preambles: readonly { readonly id: string; readonly title: string }[]; +} + +export interface LedgerPreambleSelectionChangedNoticeRow extends LedgerNoticeRow { + readonly detail: LedgerPreambleSelectionChangedNoticeDetail; + readonly providerMeta: null; +} + +export function isLedgerPreambleSelectionChangedNoticeDetail( + value: JsonObject, +): value is LedgerPreambleSelectionChangedNoticeDetail { + if ( + value.type !== 'preamble-selection-change' + || typeof value.clientMessageId !== 'string' + || value.clientMessageId.length === 0 + || value.clientMessageId.trim() !== value.clientMessageId + || !isCommandCorrelationIdWithinLimit(value.clientMessageId) + || typeof value.requestFingerprint !== 'string' + || value.requestFingerprint.length === 0 + || typeof value.selectionRevision !== 'number' + || !Number.isSafeInteger(value.selectionRevision) + || value.selectionRevision < 0 + || !Array.isArray(value.preambles) + // Reuse the shared reference count bound. + || value.preambles.length > PREAMBLE_MAX_COUNT + ) return false; + const allowed = new Set(['type', 'clientMessageId', 'requestFingerprint', 'selectionRevision', 'preambles']); + if (!Object.keys(value).every((key) => allowed.has(key))) return false; + const ids = new Set(); + for (const entry of value.preambles) { + // Canonical UUID identity and shared title bounds, no trimming. + if ( + entry === null + || typeof entry !== 'object' + || Array.isArray(entry) + || !Object.keys(entry).every((key) => key === 'id' || key === 'title') + || !isPreambleId((entry as { id: unknown }).id) + || ids.has((entry as { id: string }).id) + || normalizePreambleTitle((entry as { title: unknown }).title) + !== (entry as { title: unknown }).title + ) return false; + ids.add((entry as { id: string }).id); + } + return true; +} + export function isLedgerCliRowNoticeDetail( value: JsonObject, ): value is LedgerCliRowNoticeDetail { @@ -114,6 +173,21 @@ export function isLedgerCliRowNoticeRow(row: LedgerRow): row is LedgerCliRowNoti && isLedgerCliRowNoticeDetail(row.detail); } +export function isLedgerPreambleSelectionChangedNoticeRow( + row: LedgerRow, +): row is LedgerPreambleSelectionChangedNoticeRow { + return row.kind === 'notice' + && row.providerMeta === null + && isLedgerPreambleSelectionChangedNoticeDetail(row.detail); +} + +export const PREAMBLES_UPDATED_MESSAGE = 'Preambles updated'; + +export interface AppendSelectionChangeNoticeResult { + readonly row: LedgerPreambleSelectionChangedNoticeRow; + readonly inserted: boolean; +} + export interface LedgerAgentSwitchDetail { readonly fromAgentId: string; readonly toAgentId: string; diff --git a/server/ledger/preamble-application.ts b/server/ledger/preamble-application.ts index 73ef02d38b..2bced2f6bc 100644 --- a/server/ledger/preamble-application.ts +++ b/server/ledger/preamble-application.ts @@ -22,6 +22,19 @@ export function hasPreambleBoundaryProof( viewId: TranscriptViewId, boundary: PendingPreambleBoundary, ): boolean { + // The proof identity must match completely: a selection-change boundary in + // one ownership epoch is repeatable per selection revision. + if (boundary.kind === 'selection-change') { + return Boolean(db.query<{ found: 1 }, [string, string, string, number]>(` + SELECT 1 AS found + FROM transcript_rows + WHERE view_id = ? AND kind = 'user-input' + AND json_extract(payload_json, '$.value.preambleBoundary.kind') = ? + AND json_extract(payload_json, '$.value.preambleBoundary.ownershipEpoch') = ? + AND json_extract(payload_json, '$.value.preambleBoundary.selectionRevision') = ? + LIMIT 1 + `).get(viewId, boundary.kind, boundary.ownershipEpoch, boundary.selectionRevision)); + } return Boolean(db.query<{ found: 1 }, [string, string, string]>(` SELECT 1 AS found FROM transcript_rows diff --git a/server/ledger/presentation.ts b/server/ledger/presentation.ts index 5ddf6fdf79..b5fc54425e 100644 --- a/server/ledger/presentation.ts +++ b/server/ledger/presentation.ts @@ -14,6 +14,7 @@ import type { TranscriptMessage } from '../../common/chat-view.js'; import { isLedgerPrivateGarconCommandRow } from './garcon-command-request.js'; import { isLedgerCliRowNoticeDetail, + isLedgerPreambleSelectionChangedNoticeDetail, type LedgerRow, } from './contracts.js'; @@ -60,6 +61,19 @@ export function ledgerRowToMessage(row: LedgerRow): ChatMessage | null { row.detail.disclosure, ); } + // Presentation converts the private identity-carrying detail to the exact + // public selection-changed detail; the submission identity never renders. + if (isLedgerPreambleSelectionChangedNoticeDetail(row.detail)) { + return new TranscriptNoticeMessage( + row.at, + row.message, + { + type: 'preamble-selection-changed', + preambles: row.detail.preambles.map((preamble) => ({ ...preamble })), + }, + undefined, + ); + } return new TranscriptNoticeMessage( row.at, row.message, diff --git a/server/ledger/producer-lease.ts b/server/ledger/producer-lease.ts new file mode 100644 index 0000000000..9e05241e02 --- /dev/null +++ b/server/ledger/producer-lease.ts @@ -0,0 +1,31 @@ +import type { AgentProducerEvent, AgentProducerSink } from '@garcon/server-agent-interface'; +import { TranscriptSinkClosedError } from './service.js'; +import type { TranscriptProducerLease } from './service.js'; + +export class ProducerLease implements TranscriptProducerLease { + #closed = false; + + readonly sink: AgentProducerSink; + + constructor( + publish: (event: AgentProducerEvent) => void, + private readonly onClose: () => void, + ) { + this.sink = Object.freeze({ + publish: (event: AgentProducerEvent) => { + if (this.#closed) throw new TranscriptSinkClosedError(); + publish(event); + }, + }); + } + + get closed(): boolean { + return this.#closed; + } + + close(): void { + if (this.#closed) return; + this.#closed = true; + this.onClose(); + } +} diff --git a/server/ledger/service.ts b/server/ledger/service.ts index 0b0436339d..961ded5cf2 100644 --- a/server/ledger/service.ts +++ b/server/ledger/service.ts @@ -20,10 +20,12 @@ import type { JsonObject } from '../../common/json.js'; import type { TranscriptNoticeDetail } from '../../common/transcript-notice-details.js'; import type { AppendChatRowResult, + AppendSelectionChangeNoticeResult, InputComposition, LedgerAgentSwitchRow, LedgerConversationRow, LedgerNoticeRow, + LedgerPreambleSelectionChangedNoticeDetail, LedgerPermissionRow, LedgerRow, LedgerRowDraft, @@ -47,6 +49,7 @@ import { type InterAgentMessageRequestSink, } from './garcon-command-publication.js'; import { PermissionNotActionableError } from './errors.js'; +import { ProducerLease } from './producer-lease.js'; import { TranscriptLedgerStore } from './store.js'; export class TranscriptSinkClosedError extends Error { @@ -460,6 +463,31 @@ export class TranscriptLedgerService { return this.#appendInternalNotice(chatId, viewId, normalizeNotice(input)); } + appendSelectionChangeNotice(input: { + readonly chatId: string; + readonly viewId: TranscriptViewId; + readonly at: string; + readonly detail: LedgerPreambleSelectionChangedNoticeDetail; + }): AppendSelectionChangeNoticeResult { + const result = this.#store.appendSelectionChangeNotice(input.chatId, { + viewId: input.viewId, + at: input.at, + detail: input.detail, + }); + if (result.inserted) { + this.#notify({ type: 'rows', chatId: input.chatId, viewId: input.viewId, rows: [result.row] }); + } + return result; + } + + findSubmissionRow( + chatId: string, + viewId: TranscriptViewId, + clientMessageId: string, + ): LedgerRow | null { + return this.#store.findSubmissionRow(chatId, viewId, clientMessageId); + } + appendCarryoverNotice( chatId: string, viewId: TranscriptViewId, @@ -944,33 +972,6 @@ function validatePermissionDecision( return capability; } -class ProducerLease implements TranscriptProducerLease { - #closed = false; - - readonly sink: AgentProducerSink; - - constructor( - publish: (event: AgentProducerEvent) => void, - private readonly onClose: () => void, - ) { - this.sink = Object.freeze({ - publish: (event: AgentProducerEvent) => { - if (this.#closed) throw new TranscriptSinkClosedError(); - publish(event); - }, - }); - } - - get closed(): boolean { - return this.#closed; - } - - close(): void { - if (this.#closed) return; - this.#closed = true; - this.onClose(); - } -} function permissionRowKind( lifecycle: Exclude, diff --git a/server/ledger/store.ts b/server/ledger/store.ts index 44dad122b6..49337ea67b 100644 --- a/server/ledger/store.ts +++ b/server/ledger/store.ts @@ -19,9 +19,12 @@ import type { AppendChatRowRequest, AppendChatRowResult, AppendInputRequest, + AppendSelectionChangeNoticeResult, InputComposition, LedgerCliRowNoticeRow, LedgerCheckpoint, + LedgerPreambleSelectionChangedNoticeDetail, + LedgerPreambleSelectionChangedNoticeRow, LedgerRow, LedgerRowDraft, LedgerSessionRow, @@ -36,7 +39,9 @@ import type { import { ensureLedgerChatDirectory, ensureLedgerRootDirectory } from './directories.js'; import { isLedgerCliRowNoticeRow, + isLedgerPreambleSelectionChangedNoticeRow, isPresentationOnlyProviderRow, + PREAMBLES_UPDATED_MESSAGE, transcriptViewId, } from './contracts.js'; import { @@ -48,6 +53,18 @@ import { TranscriptViewNotInitializedError, } from './errors.js'; import { LedgerFailureFences } from './failure-fences.js'; +import type { ConnectionEntry } from './connection-entry.js'; +import { + closeConnection, + configureConnection, + createSchema, + loadAndCleanViews, + openConnection, + rehydrateConnection, + toView, + validateSchema, + viewRecord, +} from './connection-setup.js'; import { lstatIfExists, statSizeIfExists } from './file-stat.js'; import { readProviderActivityWatermark } from './native-activity-query.js'; import { @@ -68,24 +85,6 @@ const DEFAULT_CONNECTION_CACHE_SIZE = 10; const CHAT_DIRECTORY_PATTERN = /^[A-Za-z0-9_-]+$/; const logger = createLogger('ledger:store'); -interface ViewRecord { - readonly view_id: string; - readonly status: 'current' | 'staging'; - readonly created_at: string; - readonly content_start_ordinal: number; -} - -interface ConnectionEntry { - readonly chatId: string; - readonly directory: string; - readonly db: Database; - current: TranscriptView | null; - nextOrdinal: number; -} - -type ConnectionCloseAttempt = - | { readonly closed: true; readonly checkpointFailure: Error | null } - | { readonly closed: false; readonly failure: Error }; export interface TranscriptLedgerStoreOptions { readonly connectionCacheSize?: number; @@ -285,6 +284,57 @@ export class TranscriptLedgerStore { }); } + // Idempotent by the private notice's clientMessageId in the current view's + // submission index; the fingerprint proves retry identity. + // Reads one submission-indexed row in the current view for identity checks + // that must precede any mutation. + findSubmissionRow( + chatId: string, + viewId: TranscriptViewId, + clientMessageId: string, + ): LedgerRow | null { + return this.#read(chatId, (entry) => { + this.#assertCurrent(entry, viewId); + return readSubmission(entry.db, viewId, clientMessageId); + }); + } + + appendSelectionChangeNotice( + chatId: string, + request: { + readonly viewId: TranscriptViewId; + readonly at: string; + readonly detail: LedgerPreambleSelectionChangedNoticeDetail; + }, + ): AppendSelectionChangeNoticeResult { + const draft: LedgerRowDraft = { + kind: 'notice', + at: request.at, + message: PREAMBLES_UPDATED_MESSAGE, + detail: { ...request.detail, preambles: request.detail.preambles.map((p) => ({ ...p })) }, + providerMeta: null, + }; + const encoded = { draft, ...encodeLedgerDraft(draft) }; + return this.#write(chatId, (entry) => { + this.#assertCurrent(entry, request.viewId); + const existing = readSubmission(entry.db, request.viewId, request.detail.clientMessageId); + if (existing) { + if ( + !isLedgerPreambleSelectionChangedNoticeRow(existing) + || existing.detail.requestFingerprint !== request.detail.requestFingerprint + ) { + throw new SubmissionConflictError(request.detail.clientMessageId); + } + return { row: existing, inserted: false }; + } + const ordinal = entry.nextOrdinal; + const [row] = materializeRows(request.viewId, [encoded], ordinal); + runTransaction(entry.db, () => insertEncodedRows(entry.db, request.viewId, [encoded], ordinal)); + entry.nextOrdinal += 1; + return { row: row as LedgerPreambleSelectionChangedNoticeRow, inserted: true }; + }); + } + resendCandidates(chatId: string): readonly LedgerUserInputRow[] { return this.#read(chatId, (entry) => { const view = this.#requireCurrent(entry); @@ -816,157 +866,6 @@ function insertEncodedRows( }); } -function openConnection( - rootDirectory: string, - chatId: string, - synchronous: 'NORMAL' | 'FULL', -): ConnectionEntry { - const directory = ensureLedgerChatDirectory(rootDirectory, chatId); - const databasePath = path.join(directory, 'ledger.sqlite'); - const existed = (statSizeIfExists(databasePath) ?? 0) > 0; - const db = new Database(databasePath); - try { - configureConnection(db, synchronous); - if (!existed) createSchema(db); - else validateSchema(db); - const current = loadAndCleanViews(db); - chmodSync(databasePath, 0o600); - return { - chatId, - directory, - db, - current, - nextOrdinal: current ? nextOrdinal(db, current.viewId) : 1, - }; - } catch (error) { - db.close(); - throw error; - } -} - -function configureConnection(db: Database, synchronous: 'NORMAL' | 'FULL'): void { - db.exec('PRAGMA journal_mode = WAL'); - db.exec(`PRAGMA synchronous = ${synchronous}`); - db.exec('PRAGMA foreign_keys = ON'); - db.exec('PRAGMA busy_timeout = 5000'); -} - -function createSchema(db: Database): void { - runTransaction(db, () => { - db.exec(` - CREATE TABLE transcript_views ( - view_id TEXT PRIMARY KEY, - status TEXT NOT NULL CHECK (status IN ('current', 'staging')), - created_at TEXT NOT NULL, - content_start_ordinal INTEGER NOT NULL - ) STRICT; - - CREATE UNIQUE INDEX transcript_one_current - ON transcript_views(status) - WHERE status = 'current'; - - CREATE TABLE transcript_rows ( - view_id TEXT NOT NULL - REFERENCES transcript_views(view_id) ON DELETE CASCADE, - ordinal INTEGER NOT NULL, - kind TEXT NOT NULL, - at TEXT NOT NULL, - client_message_id TEXT, - payload_json TEXT NOT NULL, - PRIMARY KEY (view_id, ordinal) - ) WITHOUT ROWID, STRICT; - - CREATE UNIQUE INDEX transcript_submission - ON transcript_rows(view_id, client_message_id) - WHERE client_message_id IS NOT NULL; - `); - db.exec(`PRAGMA user_version = ${LEDGER_SCHEMA_VERSION}`); - }); -} - -function validateSchema(db: Database): void { - const version = Number(db.query<{ user_version: number }, []>('PRAGMA user_version').get()?.user_version ?? 0); - if (version !== LEDGER_SCHEMA_VERSION) { - throw new LedgerSchemaError(`Unsupported transcript ledger schema version ${version}`); - } - const required = new Set(['transcript_views', 'transcript_rows']); - const records = db.query<{ name: string }, []>(` - SELECT name FROM sqlite_master - WHERE type = 'table' AND name IN ('transcript_views', 'transcript_rows') - `).all(); - for (const record of records) required.delete(record.name); - if (required.size > 0) throw new LedgerSchemaError('Transcript ledger schema is incomplete'); -} - -function loadAndCleanViews(db: Database): TranscriptView | null { - const views = loadViews(db); - const current = validatedCurrentView(views); - if (views.some((view) => view.status === 'staging')) { - db.query("DELETE FROM transcript_views WHERE status = 'staging'").run(); - } - return current; -} - -function loadViews(db: Database): readonly ViewRecord[] { - return runQuery(() => ( - db.query(` - SELECT view_id, status, created_at, content_start_ordinal - FROM transcript_views - `).all() - )); -} - -function validatedCurrentView(views: readonly ViewRecord[]): TranscriptView | null { - const current = views.filter((view) => view.status === 'current'); - if (current.length !== 1 && views.length > 0) { - throw new LedgerSchemaError('Established transcript ledger must have exactly one current view'); - } - return current[0] ? toView(current[0]) : null; -} - -function rehydrateConnection(entry: ConnectionEntry): void { - const current = validatedCurrentView(loadViews(entry.db)); - const ordinal = current ? nextOrdinal(entry.db, current.viewId) : 1; - entry.current = current; - entry.nextOrdinal = ordinal; -} - -function viewRecord( - db: Database, - viewId: TranscriptViewId, - status: 'current' | 'staging', -): ViewRecord | null { - return runQuery(() => ( - db.query(` - SELECT view_id, status, created_at, content_start_ordinal - FROM transcript_views WHERE view_id = ? AND status = ? - `).get(viewId, status) ?? null - )); -} - -function toView(record: ViewRecord): TranscriptView { - return { - viewId: transcriptViewId(record.view_id), - status: record.status, - createdAt: record.created_at, - contentStartOrdinal: record.content_start_ordinal, - }; -} - -function closeConnection(entry: ConnectionEntry): ConnectionCloseAttempt { - let checkpointFailure: Error | null = null; - try { - entry.db.exec('PRAGMA wal_checkpoint(PASSIVE)'); - } catch (error) { - checkpointFailure = asError(error); - } - try { - entry.db.close(); - } catch (error) { - return { closed: false, failure: asError(error) }; - } - return { closed: true, checkpointFailure }; -} function validateChatDirectoryName(chatId: string): void { if (!CHAT_DIRECTORY_PATTERN.test(chatId)) { diff --git a/server/lib/json-file-store.ts b/server/lib/json-file-store.ts index b33a539da3..413d4efd95 100644 --- a/server/lib/json-file-store.ts +++ b/server/lib/json-file-store.ts @@ -97,6 +97,19 @@ async function quarantineStateFile(filePath: string, cause: unknown): Promise {}); await fs.unlink(tempPath).catch(() => {}); diff --git a/server/preambles/__tests__/chat-selection-service.test.js b/server/preambles/__tests__/chat-selection-service.test.js new file mode 100644 index 0000000000..4374072d0f --- /dev/null +++ b/server/preambles/__tests__/chat-selection-service.test.js @@ -0,0 +1,547 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; +import { promises as fs } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { ChatRegistry } from '../../chats/store.ts'; +import { KeyedPromiseLock } from '../../lib/keyed-lock.ts'; +import { TranscriptLedgerService } from '../../ledger/service.ts'; +import { TranscriptLedgerStore } from '../../ledger/store.ts'; +import { + ChatPreambleSelectionPartialError, + ChatPreambleSelectionService, +} from '../chat-selection-service.ts'; + +const CHAT_ID = '1783725900000200'; +const AT = '2026-09-05T00:00:00.000Z'; +const ID_A = '3502b645-222b-49d2-ac39-1c91f9fb1174'; +const ID_B = '80becfa6-c9c7-4b31-9190-fd23c0bedf9c'; + +let root; +let chats; +let ledger; +let viewId; +let preamblesSnapshot; +let ownershipPending; +let committedFlags; + +function deferred() { + let resolve; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function catalogEntry(id, title, overrides = {}) { + return { + id, + enabled: true, + title, + content: `body ${title}`, + scope: { type: 'global' }, + createdAt: AT, + updatedAt: AT, + ...overrides, + }; +} + +function makeService(overrides = {}) { + const chatMutationLock = new KeyedPromiseLock(); + const selectionAdmissionLock = new KeyedPromiseLock(); + const onSelectionCommitted = overrides.onSelectionCommitted ?? mock(() => undefined); + const registryPatch = overrides.registryPatch ?? {}; + const service = new ChatPreambleSelectionService({ + registry: { + getChat: (chatId) => chats.getChat(chatId), + updateChatPhased: async (chatId, patch) => { + if (registryPatch.reject) throw registryPatch.reject; + const result = await chats.updateChatPhased(chatId, patch); + committedFlags.push({ chatId, patch }); + return result; + }, + reconcileUnknownDurability: async (chatId) => chats.reconcileUnknownDurability(chatId), + }, + adoption: { + ensure: async () => ({ viewId }), + }, + ledger: ledger, + preambles: { snapshot: overrides.snapshot ?? (() => preamblesSnapshot) }, + ownershipJournal: { hasPending: () => ownershipPending }, + chatMutationLock, + selectionAdmissionLock, + now: () => AT, + onSelectionCommitted, + }); + return { + service, + chatMutationLock, + selectionAdmissionLock, + onSelectionCommitted, + }; +} + +function updateRequest(overrides = {}) { + return { + chatId: CHAT_ID, + transcriptViewId: viewId, + clientRequestId: 'req-1', + clientMessageId: 'msg-1', + expectedRevision: 0, + orderedPreambleIds: [ID_A, ID_B], + ...overrides, + }; +} + +describe('ChatPreambleSelectionService', () => { + beforeEach(async () => { + root = await mkdtemp(path.join(os.tmpdir(), 'garcon-chat-preamble-selection-')); + chats = new ChatRegistry(root); + await chats.init(); + chats.addChat({ + id: CHAT_ID, + agentId: 'test', + model: 'model-a', + projectPath: '/repo', + agentSettingsById: { test: { ownerId: 'test', schemaVersion: 1, values: {} } }, + pendingPreambleBoundary: null, + preambleSelection: { revision: 0, orderedPreambleIds: [] }, + parentChat: null, + }); + ledger = new TranscriptLedgerService( + new TranscriptLedgerStore(path.join(root, 'transcript-ledgers')), + { now: () => AT }, + ); + ledger.initializeChat(CHAT_ID); + viewId = ledger.currentView(CHAT_ID).viewId; + preamblesSnapshot = { + revision: 4, + preambles: [catalogEntry(ID_A, 'First'), catalogEntry(ID_B, 'Second')], + }; + ownershipPending = false; + committedFlags = []; + }); + + afterEach(async () => { + ledger?.close(); + await chats?.flush().catch(() => undefined); + await rm(root, { recursive: true, force: true }); + }); + + it('returns the saved selection with a body-free projection', async () => { + const { service } = makeService(); + const target = await service.target(CHAT_ID, new AbortController().signal); + expect(target).toMatchObject({ + success: true, + chatId: CHAT_ID, + transcriptViewId: viewId, + selection: { revision: 0, orderedPreambleIds: [] }, + }); + expect(target.projection).toEqual({ + catalogRevision: 4, + eligiblePreambles: [], + unavailable: [], + }); + }); + + it('saves a changed selection with a notice, boundary, and invalidation', async () => { + const { service, onSelectionCommitted } = makeService(); + const outcome = await service.update(updateRequest()); + expect(outcome.status).toBe('updated'); + expect(outcome.mutationRevision).toBe(1); + expect(outcome.noticeOrdinal).toBe(1); + expect(outcome.selection).toEqual({ revision: 1, orderedPreambleIds: [ID_A, ID_B] }); + expect(outcome.projection.eligiblePreambles.map((entry) => entry.title)) + .toEqual(['First', 'Second']); + + const chat = chats.getChat(CHAT_ID); + expect(chat.preambleSelection).toEqual({ revision: 1, orderedPreambleIds: [ID_A, ID_B] }); + expect(chat.pendingPreambleBoundary).toEqual({ + kind: 'selection-change', + ownershipEpoch: chat.agentOwnershipEpoch, + selectionRevision: 1, + }); + + const rows = ledger.currentRows(CHAT_ID); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + kind: 'notice', + message: 'Preambles updated', + }); + expect(rows[0].detail).toMatchObject({ + type: 'preamble-selection-change', + clientMessageId: 'msg-1', + selectionRevision: 1, + preambles: [ + { id: ID_A, title: 'First' }, + { id: ID_B, title: 'Second' }, + ], + }); + // A committed notice's invalidation is derived by the server event wiring + // from the committed row; the service fires the hook only for partial + // outcomes, so a successful save must not call it directly. + }); + + it('uses one catalog snapshot for changed-save validation, notice, and response', async () => { + const snapshot = mock(() => preamblesSnapshot); + const realPhased = chats.updateChatPhased.bind(chats); + chats.updateChatPhased = async (...args) => { + const result = await realPhased(...args); + preamblesSnapshot = { + revision: 5, + preambles: [catalogEntry(ID_B, 'Renamed second')], + }; + return result; + }; + const { service } = makeService({ snapshot }); + + const outcome = await service.update(updateRequest()); + + expect(snapshot).toHaveBeenCalledTimes(1); + expect(outcome.projection).toEqual({ + catalogRevision: 4, + eligiblePreambles: [ + { id: ID_A, title: 'First' }, + { id: ID_B, title: 'Second' }, + ], + unavailable: [], + }); + expect(ledger.currentRows(CHAT_ID)[0].detail.preambles) + .toEqual(outcome.projection.eligiblePreambles); + }); + + it('treats an unchanged save as a no-op before validation', async () => { + const { service, onSelectionCommitted } = makeService(); + await service.update(updateRequest()); + onSelectionCommitted.mockClear(); + + const outcome = await service.update(updateRequest({ + clientRequestId: 'req-2', + clientMessageId: 'msg-2', + expectedRevision: 1, + })); + expect(outcome.status).toBe('unchanged'); + expect(outcome.mutationRevision).toBe(1); + expect(outcome.noticeOrdinal).toBeNull(); + expect(ledger.currentRows(CHAT_ID)).toHaveLength(1); + expect(onSelectionCommitted).not.toHaveBeenCalled(); + expect(chats.getChat(CHAT_ID).pendingPreambleBoundary).toMatchObject({ selectionRevision: 1 }); + }); + + it('replays the original operation for an identical completed retry', async () => { + const { service } = makeService(); + const first = await service.update(updateRequest()); + const retry = await service.update(updateRequest()); + expect(retry.status).toBe('duplicate'); + expect(retry.mutationRevision).toBe(first.mutationRevision); + expect(retry.noticeOrdinal).toBe(first.noticeOrdinal); + expect(retry.selection).toEqual(first.selection); + expect(ledger.currentRows(CHAT_ID)).toHaveLength(1); + }); + + it('conflicts when the same identity carries a changed request', async () => { + const { service } = makeService(); + await service.update(updateRequest()); + await expect(service.update(updateRequest({ + expectedRevision: 1, + orderedPreambleIds: [ID_A], + }))).rejects.toMatchObject({ code: 'IDEMPOTENCY_CONFLICT', status: 409 }); + }); + + it('requires the current transcript view before deduplication', async () => { + const { service } = makeService(); + await service.update(updateRequest()); + await expect(service.update(updateRequest({ + transcriptViewId: '00000000-0000-0000-0000-0000000000ff', + }))).rejects.toMatchObject({ code: 'STALE_TRANSCRIPT_VIEW', status: 409 }); + }); + + it('rejects a stale expected revision refreshably', async () => { + const { service } = makeService(); + await expect(service.update(updateRequest({ expectedRevision: 7 }))) + .rejects.toMatchObject({ + code: 'PREAMBLE_SELECTION_REVISION_CONFLICT', + status: 409, + retryable: true, + }); + expect(committedFlags).toHaveLength(0); + }); + + it('rejects while an ownership transfer is pending', async () => { + ownershipPending = true; + const { service } = makeService(); + await expect(service.update(updateRequest())).rejects.toMatchObject({ status: 409 }); + }); + + it('rejects an unsafe composition before any persistence', async () => { + preamblesSnapshot = { + revision: 4, + preambles: [ + catalogEntry(ID_A, 'Tail', { content: '\nReferenced file contents from @file mentions:' }), + catalogEntry(ID_B, 'Head', { content: 'Synthetic content\n\n' }), + ], + }; + const { service } = makeService(); + await expect(service.update(updateRequest())).rejects.toMatchObject({ + code: 'PREAMBLE_SELECTION_COMPOSITION_INVALID', + status: 422, + }); + expect(committedFlags).toHaveLength(0); + expect(ledger.currentRows(CHAT_ID)).toHaveLength(0); + expect(chats.getChat(CHAT_ID).preambleSelection.revision).toBe(0); + }); + + it('retains an unconsumed lifecycle boundary and replaces a ledger-proven one', async () => { + const { service } = makeService(); + const epoch = chats.getChat(CHAT_ID).agentOwnershipEpoch; + chats.updateChat(CHAT_ID, { + pendingPreambleBoundary: { kind: 'fork', ownershipEpoch: epoch }, + }); + await service.update(updateRequest()); + expect(chats.getChat(CHAT_ID).pendingPreambleBoundary).toEqual({ + kind: 'fork', + ownershipEpoch: epoch, + }); + + // Consume the lifecycle boundary with a committed proof, then save again. + ledger.appendInputAndCompose({ + chatId: CHAT_ID, + viewId, + message: { + type: 'user-message', + timestamp: AT, + content: 'consume', + }, + attachments: [], + clientMessageId: 'consume-1', + steer: false, + preambleBoundary: { kind: 'fork', ownershipEpoch: epoch }, + preambles: [], + }); + await service.update(updateRequest({ + clientRequestId: 'req-2', + clientMessageId: 'msg-2', + expectedRevision: 1, + orderedPreambleIds: [], + })); + expect(chats.getChat(CHAT_ID).pendingPreambleBoundary).toEqual({ + kind: 'selection-change', + ownershipEpoch: epoch, + selectionRevision: 2, + }); + const rows = ledger.currentRows(CHAT_ID); + expect(rows.filter((row) => row.kind === 'notice').at(-1).detail).toMatchObject({ + preambles: [], + selectionRevision: 2, + }); + }); + + it('reports a committed selection without its notice as a typed partial failure', async () => { + const originalAppend = ledger.appendSelectionChangeNotice.bind(ledger); + ledger.appendSelectionChangeNotice = () => { + throw new Error('injected ledger append failure'); + }; + const { service } = makeService(); + let partial; + try { + await service.update(updateRequest()); + } catch (error) { + partial = error; + } + expect(partial).toBeInstanceOf(ChatPreambleSelectionPartialError); + expect(partial.code).toBe('PREAMBLE_SELECTION_NOTICE_FAILED'); + expect(partial.selectionCommitted).toBe(true); + expect(partial.selection).toEqual({ revision: 1, orderedPreambleIds: [ID_A, ID_B] }); + // The registry decision stands; there is no compensating write. + expect(chats.getChat(CHAT_ID).preambleSelection.revision).toBe(1); + expect(committedFlags).toHaveLength(1); + ledger.appendSelectionChangeNotice = originalAppend; + }); + + it('keeps a still-unknown reconciliation gated with a typed retryable error', async () => { + const { service } = makeService(); + const originalOpen = fs.open; + fs.open = async (target, flags, ...rest) => { + if (flags === 'r' && typeof target === 'string' && target === root) { + throw new Error('injected directory sync failure'); + } + return originalOpen(target, flags, ...rest); + }; + try { + await expect(service.update(updateRequest())).rejects.toMatchObject({ + code: 'PREAMBLE_SELECTION_SAVE_UNKNOWN', + }); + // Repeated reconciliation failures keep returning the typed partial. + for (let attempt = 0; attempt < 2; attempt += 1) { + await expect(service.target(CHAT_ID, new AbortController().signal)) + .rejects.toMatchObject({ + code: 'PREAMBLE_SELECTION_SAVE_UNKNOWN', + selectionCommitted: 'unknown', + }); + } + expect(chats.getChat(CHAT_ID).preambleSelection.revision).toBe(1); + } finally { + fs.open = originalOpen; + } + const target = await service.target(CHAT_ID, new AbortController().signal); + expect(target.selection).toEqual({ revision: 1, orderedPreambleIds: [ID_A, ID_B] }); + }); + + it('recovers a durability-unknown commit through the target read and a later save', async () => { + const { service } = makeService(); + const workspaceDir = path.join(root, 'ws-reconcile'); + // Break only the directory-sync half of the atomic write so the rename + // commits while durability stays unknown; the real registry fences itself. + const originalOpen = fs.open; + let failDirSync = true; + fs.open = async (target, flags, ...rest) => { + if (failDirSync && flags === 'r' && typeof target === 'string' && target === root) { + throw new Error('injected directory sync failure'); + } + return originalOpen(target, flags, ...rest); + }; + try { + await expect(service.update(updateRequest())).rejects.toMatchObject({ + code: 'PREAMBLE_SELECTION_SAVE_UNKNOWN', + selectionCommitted: 'unknown', + }); + } finally { + fs.open = originalOpen; + } + // The committed candidate survives in memory and on disk; the fence stays. + expect(chats.getChat(CHAT_ID).preambleSelection.revision).toBe(1); + await expect(service.update(updateRequest({ + clientRequestId: 'req-2', + clientMessageId: 'msg-2', + expectedRevision: 1, + orderedPreambleIds: [ID_A], + }))).rejects.toMatchObject({ + code: 'PREAMBLE_SELECTION_SAVE_UNKNOWN', + }); + + // A client GET reconciles the fence and reflects the committed selection. + const target = await service.target(CHAT_ID, new AbortController().signal); + expect(target.selection).toEqual({ revision: 1, orderedPreambleIds: [ID_A, ID_B] }); + const after = await service.update(updateRequest({ + clientRequestId: 'req-3', + clientMessageId: 'msg-3', + expectedRevision: 1, + orderedPreambleIds: [ID_A], + })); + expect(after.status).toBe('updated'); + expect(after.selection.revision).toBe(2); + expect(workspaceDir).toBeDefined(); + }); + + it('reports an unknown-durability commit without reverting memory', async () => { + const { service } = makeService(); + const realPhased = chats.updateChatPhased.bind(chats); + chats.updateChatPhased = async (chatId, patch) => { + await realPhased(chatId, patch); + return { entry: chats.getChat(chatId), durability: 'unknown' }; + }; + let partial; + try { + await service.update(updateRequest()); + } catch (error) { + partial = error; + } + expect(partial).toBeInstanceOf(ChatPreambleSelectionPartialError); + expect(partial.code).toBe('PREAMBLE_SELECTION_SAVE_UNKNOWN'); + expect(partial.selectionCommitted).toBe('unknown'); + expect(chats.getChat(CHAT_ID).preambleSelection.revision).toBe(1); + expect(ledger.currentRows(CHAT_ID)).toHaveLength(0); + }); + + it('serializes Save behind a direct admission holding the selection lock', async () => { + const { service, selectionAdmissionLock } = makeService(); + let releaseAdmission = null; + const admissionGate = new Promise((resolve) => { + releaseAdmission = resolve; + }); + const admission = selectionAdmissionLock.runExclusive(`chat:${CHAT_ID}`, async () => { + await admissionGate; + }); + const pending = service.update(updateRequest()); + await new Promise((resolve) => setTimeout(resolve, 10)); + // The Save cannot commit its registry decision while admission holds the + // narrow lock: no total order violation is possible. + expect(committedFlags).toHaveLength(0); + releaseAdmission?.(); + await admission; + await expect(pending).resolves.toMatchObject({ status: 'updated' }); + expect(committedFlags).toHaveLength(1); + }); + + it('holds admission until a concurrent Save completes its notice attempt', async () => { + const { service, selectionAdmissionLock } = makeService(); + const registryCommitted = deferred(); + const releaseRegistry = deferred(); + const realPhased = chats.updateChatPhased.bind(chats); + chats.updateChatPhased = async (...args) => { + const result = await realPhased(...args); + registryCommitted.resolve(); + await releaseRegistry.promise; + return result; + }; + const save = service.update(updateRequest()); + await registryCommitted.promise; + let admissionEntered = false; + const admission = selectionAdmissionLock.runExclusive( + `chat:${CHAT_ID}`, + async () => { + admissionEntered = true; + return 'admitted'; + }, + ); + await Promise.resolve(); + expect(admissionEntered).toBe(false); + expect(ledger.currentRows(CHAT_ID)).toHaveLength(0); + + releaseRegistry.resolve(); + await expect(save).resolves.toMatchObject({ status: 'updated' }); + await expect(admission).resolves.toBe('admitted'); + expect(admissionEntered).toBe(true); + expect(ledger.currentRows(CHAT_ID)).toHaveLength(1); + }); + + it('serializes Save behind any holder of the chat mutation lock', async () => { + const { service, chatMutationLock } = makeService(); + let releaseReload = null; + const reloadGate = new Promise((resolve) => { + releaseReload = resolve; + }); + // Reload, project-path update, deletion, fork/continuation capture, and + // ownership-transfer commit all share this per-chat mutation lock. + const mutation = chatMutationLock.runExclusive(`chat:${CHAT_ID}`, async () => { + await reloadGate; + }); + const pending = service.update(updateRequest()); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(committedFlags).toHaveLength(0); + releaseReload?.(); + await mutation; + await expect(pending).resolves.toMatchObject({ status: 'updated' }); + }); + + it('acquires the chat mutation lock before the selection admission lock', async () => { + const order = []; + const { service, chatMutationLock, selectionAdmissionLock } = makeService(); + const originalMutationRun = chatMutationLock.runExclusive.bind(chatMutationLock); + const originalSelectionRun = selectionAdmissionLock.runExclusive.bind(selectionAdmissionLock); + chatMutationLock.runExclusive = (key, fn) => { + order.push('mutation:enter'); + return originalMutationRun(key, async () => { + const value = await fn(); + order.push('mutation:exit'); + return value; + }); + }; + selectionAdmissionLock.runExclusive = (key, fn) => { + order.push('selection:enter'); + return originalSelectionRun(key, fn); + }; + await service.update(updateRequest()); + expect(order.slice(0, 3)).toEqual(['mutation:enter', 'selection:enter', 'mutation:exit']); + }); +}); diff --git a/server/preambles/__tests__/selection.test.js b/server/preambles/__tests__/selection.test.js new file mode 100644 index 0000000000..29f8bd4fc5 --- /dev/null +++ b/server/preambles/__tests__/selection.test.js @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'bun:test'; +import { DomainError } from '../../lib/domain-error.ts'; +import { + assertPreambleSelectionComposition, + defaultOrderedPreambleIds, + isRecoverablePreambleAdmissionError, + PREAMBLE_SELECTION_COMPOSITION_INVALID_MESSAGE, + projectPreambleSelection, + resolveNewChatPreambleSelection, + resolvePreambleSelection, +} from '../selection.ts'; + +const AT = '2026-09-03T10:00:00.000Z'; +const ID_A = '3502b645-222b-49d2-ac39-1c91f9fb1174'; +const ID_B = '80becfa6-c9c7-4b31-9190-fd23c0bedf9c'; +const ID_MISSING = '936903ad-8b98-43eb-a7d4-c17ce0dc18d8'; + +function preamble(id, title, content, overrides = {}) { + return { + id, + enabled: true, + title, + content, + scope: { type: 'global' }, + createdAt: AT, + updatedAt: AT, + ...overrides, + }; +} + +function catalog(...preambles) { + return { revision: preambles.length + 1, preambles }; +} + +function selection(ids, revision = 1) { + return { revision, orderedPreambleIds: ids }; +} + +describe('resolvePreambleSelection', () => { + it('iterates saved order rather than catalog order', () => { + const resolved = resolvePreambleSelection( + selection([ID_B, ID_A]), + catalog( + preamble(ID_A, 'First', 'first body'), + preamble(ID_B, 'Second', 'second body'), + ), + '/repo', + ); + expect(resolved.eligible.map((entry) => entry.title)).toEqual(['Second', 'First']); + expect(resolved.unavailable).toEqual([]); + }); + + it('classifies missing, disabled, and out-of-scope IDs without failing', () => { + const resolved = resolvePreambleSelection( + selection([ID_MISSING, ID_A, ID_B]), + catalog( + preamble(ID_A, 'Eligible', 'eligible body'), + preamble(ID_B, 'Disabled', 'disabled body', { enabled: false }), + ), + '/repo', + ); + expect(resolved.eligible.map((entry) => entry.id)).toEqual([ID_A]); + expect(resolved.unavailable).toEqual([ + { id: ID_MISSING, reason: 'missing' }, + { id: ID_B, reason: 'disabled' }, + ]); + }); + + it('reports out-of-scope entries for non-matching project paths', () => { + const resolved = resolvePreambleSelection( + selection([ID_A]), + catalog(preamble(ID_A, 'Scoped', 'scoped body', { + scope: { type: 'project-paths', rules: [{ projectPath: '/other', includeNested: true }] }, + })), + '/repo', + ); + expect(resolved.unavailable).toEqual([{ id: ID_A, reason: 'out-of-scope' }]); + }); + + it('projects eligible titles and unavailable reasons for the UI', () => { + const projection = projectPreambleSelection( + selection([ID_A, ID_MISSING]), + catalog(preamble(ID_A, 'Eligible', 'eligible body')), + '/repo', + ); + expect(projection).toEqual({ + catalogRevision: 2, + eligiblePreambles: [{ id: ID_A, title: 'Eligible' }], + unavailable: [{ id: ID_MISSING, reason: 'missing' }], + }); + }); + + it('defaults new chats to enabled matching entries in catalog order', () => { + const ids = defaultOrderedPreambleIds( + catalog( + preamble(ID_A, 'First', 'first'), + preamble(ID_B, 'Disabled', 'second', { enabled: false }), + preamble(ID_MISSING, 'Scoped', 'third', { + scope: { type: 'project-paths', rules: [{ projectPath: '/repo', includeNested: true }] }, + }), + ), + '/repo', + ); + expect(ids).toEqual([ID_A, ID_MISSING]); + }); + + it('resolves an omitted creation selection to defaults and stores explicit lists exactly', () => { + const snapshot = catalog(preamble(ID_A, 'First', 'first')); + expect(resolveNewChatPreambleSelection({ + catalog: snapshot, + canonicalProjectPath: '/repo', + chatId: '1783725900000200', + })).toEqual({ revision: 0, orderedPreambleIds: [ID_A] }); + expect(resolveNewChatPreambleSelection({ + catalog: snapshot, + canonicalProjectPath: '/repo', + chatId: '1783725900000200', + orderedPreambleIds: [ID_MISSING], + })).toEqual({ revision: 0, orderedPreambleIds: [ID_MISSING] }); + expect(resolveNewChatPreambleSelection({ + catalog: snapshot, + canonicalProjectPath: '/repo', + chatId: '1783725900000200', + orderedPreambleIds: [], + })).toEqual({ revision: 0, orderedPreambleIds: [] }); + }); + + it('rejects an unsafe explicit creation composition before persistence', () => { + expect(() => resolveNewChatPreambleSelection({ + catalog: catalog( + preamble(ID_A, 'Tail', '\nReferenced file contents from @file mentions:'), + preamble(ID_B, 'Head', 'Synthetic content\n\n'), + ), + canonicalProjectPath: '/repo', + chatId: '1783725900000200', + orderedPreambleIds: [ID_A, ID_B], + })).toThrowError(expect.objectContaining({ + code: 'PREAMBLE_SELECTION_COMPOSITION_INVALID', + message: PREAMBLE_SELECTION_COMPOSITION_INVALID_MESSAGE, + })); + }); +}); + +describe('assertPreambleSelectionComposition', () => { + it('accepts empty and safe compositions and expands chat IDs exactly', () => { + expect(() => assertPreambleSelectionComposition('1783725900000200', [])).not.toThrow(); + expect(() => assertPreambleSelectionComposition('1783725900000200', [ + preamble(ID_A, 'One', 'Target {{chat_id}}'), + ])).not.toThrow(); + }); + + it('rejects a reordered pair that reconstructs the reserved separator', () => { + const entries = [ + { id: ID_A, body: 'Synthetic content\n\n' }, + { id: ID_B, body: '\nReferenced file contents from @file mentions:' }, + ]; + // The frame's opening newline plus the leading body newline reconstructs the + // reserved separator only in this order; the reverse order stays safe. + const byId = new Map(entries.map((entry) => [entry.id, entry])); + const ordered = [byId.get(ID_B), byId.get(ID_A)]; + expect(() => assertPreambleSelectionComposition('1783725900000200', ordered.map( + (entry, index) => preamble(entry.id, `Entry ${index}`, entry.body), + ))).toThrowError(expect.objectContaining({ code: 'PREAMBLE_SELECTION_COMPOSITION_INVALID' })); + }); + + it('treats composition and slash rejections as the recoverable admission errors', () => { + expect(isRecoverablePreambleAdmissionError(new DomainError( + 'PREAMBLE_SELECTION_COMPOSITION_INVALID', + PREAMBLE_SELECTION_COMPOSITION_INVALID_MESSAGE, + 422, + ))).toBe(true); + expect(isRecoverablePreambleAdmissionError(new DomainError( + 'PREAMBLE_SLASH_COMMAND_BLOCKED', + 'blocked', + 422, + ))).toBe(true); + expect(isRecoverablePreambleAdmissionError(new DomainError( + 'SESSION_NOT_FOUND', + 'missing', + 404, + ))).toBe(false); + expect(isRecoverablePreambleAdmissionError(new Error('not domain'))).toBe(false); + }); +}); diff --git a/server/preambles/__tests__/service.test.js b/server/preambles/__tests__/service.test.js index 348770ce73..6b9f38aaab 100644 --- a/server/preambles/__tests__/service.test.js +++ b/server/preambles/__tests__/service.test.js @@ -10,6 +10,11 @@ import { PreambleStore } from '../store.ts'; const createdDirectories = []; +// Deterministic canonical UUID v4 fixtures. +function uuidFor(index) { + return `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`; +} + async function service(overrides = {}) { const directory = path.join(os.tmpdir(), `garcon-preamble-service-${randomUUID()}`); await fs.mkdir(directory, { recursive: true }); @@ -26,7 +31,7 @@ async function service(overrides = {}) { preambles: new PreambleService({ store, projectPaths, - newId: overrides.newId ?? (() => `preamble-${++nextId}`), + newId: overrides.newId ?? (() => uuidFor(++nextId)), now: () => new Date('2026-09-03T10:00:00.000Z'), }), }; @@ -151,7 +156,7 @@ describe('PreambleService', () => { }); it('reports duplicate generated IDs before composition violations', async () => { - const { preambles } = await service({ newId: () => 'same-id' }); + const { preambles } = await service({ newId: () => uuidFor(1) }); await preambles.create({ expectedRevision: 0, preamble: globalDefinition('First', 'a'.repeat(32_000)), @@ -161,8 +166,8 @@ describe('PreambleService', () => { expectedRevision: 1, preamble: globalDefinition('Second', 'b'.repeat(32_000)), })).rejects.toMatchObject({ - code: 'PREAMBLE_VALIDATION_FAILED', - status: 400, + code: 'PREAMBLE_ID_COLLISION', + status: 409, }); expect(preambles.snapshot()).toMatchObject({ revision: 1 }); }); @@ -184,7 +189,7 @@ describe('PreambleService', () => { })).rejects.toMatchObject({ code: 'PREAMBLE_REVISION_CONFLICT', status: 409 }); await expect(preambles.update({ expectedRevision: 1, - id: 'preamble-2', + id: uuidFor(2), preamble: globalDefinition('Stale update', 'b'.repeat(32_000)), })).rejects.toMatchObject({ code: 'PREAMBLE_REVISION_CONFLICT', status: 409 }); expect(preambles.snapshot()).toMatchObject({ revision: 2 }); @@ -198,7 +203,7 @@ describe('PreambleService', () => { }); const createResults = await Promise.allSettled([ - preambles.remove({ expectedRevision: 1, id: 'preamble-1' }), + preambles.remove({ expectedRevision: 1, id: uuidFor(1) }), preambles.create({ expectedRevision: 2, preamble: globalDefinition('Second', 'b'.repeat(32_000)), @@ -211,10 +216,10 @@ describe('PreambleService', () => { preamble: { ...globalDefinition('Third', 'c'.repeat(32_000)), enabled: false }, }); const updateResults = await Promise.allSettled([ - preambles.remove({ expectedRevision: 4, id: 'preamble-2' }), + preambles.remove({ expectedRevision: 4, id: uuidFor(2) }), preambles.update({ expectedRevision: 5, - id: 'preamble-3', + id: uuidFor(3), preamble: globalDefinition('Third', 'c'.repeat(32_000)), }), ]); @@ -222,7 +227,7 @@ describe('PreambleService', () => { expect(updateResults.map((result) => result.status)).toEqual(['fulfilled', 'fulfilled']); expect(preambles.snapshot()).toMatchObject({ revision: 6, - preambles: [{ id: 'preamble-3', enabled: true }], + preambles: [{ id: uuidFor(3), enabled: true }], }); }); @@ -314,7 +319,7 @@ describe('PreambleService', () => { expect(preambles.resolve('/workspace')[0]?.title).toBe('Second'); await expect(preambles.update({ expectedRevision: 2, - id: 'preamble-1', + id: uuidFor(1), preamble: globalDefinition('First', 'a'.repeat(32_000)), })).rejects.toMatchObject({ code: 'PREAMBLE_COMBINED_LIMIT_EXCEEDED' }); expect(preambles.snapshot().preambles[0]?.enabled).toBe(false); @@ -326,15 +331,15 @@ describe('PreambleService', () => { preambles.onInvalidated((reason) => reasons.push(reason)); await preambles.create({ expectedRevision: 0, preamble: globalDefinition('First') }); - await expect(preambles.remove({ expectedRevision: 0, id: 'preamble-1' })).rejects.toMatchObject({ + await expect(preambles.remove({ expectedRevision: 0, id: uuidFor(1) })).rejects.toMatchObject({ code: 'PREAMBLE_REVISION_CONFLICT', }); await preambles.update({ expectedRevision: 1, - id: 'preamble-1', + id: uuidFor(1), preamble: globalDefinition('Updated'), }); - await preambles.remove({ expectedRevision: 2, id: 'preamble-1' }); + await preambles.remove({ expectedRevision: 2, id: uuidFor(1) }); expect(reasons).toEqual(['created', 'updated', 'removed']); }); @@ -352,7 +357,7 @@ describe('PreambleService', () => { await expect(preambles.reorder({ expectedRevision: 2, - orderedPreambleIds: ['preamble-1', 'preamble-1'], + orderedPreambleIds: [uuidFor(1), uuidFor(1)], })).rejects.toMatchObject({ code: 'PREAMBLE_VALIDATION_FAILED', status: 400 }); expect(preambles.snapshot()).toMatchObject({ revision: 2 }); }); @@ -361,18 +366,18 @@ describe('PreambleService', () => { const { preambles } = await service(); await preambles.create({ expectedRevision: 0, preamble: globalDefinition('First') }); await preambles.create({ expectedRevision: 1, preamble: globalDefinition('Second') }); - await preambles.remove({ expectedRevision: 2, id: 'preamble-2' }); + await preambles.remove({ expectedRevision: 2, id: uuidFor(2) }); await expect(preambles.reorder({ expectedRevision: 2, - orderedPreambleIds: ['preamble-2', 'preamble-1'], + orderedPreambleIds: [uuidFor(2), uuidFor(1)], })).rejects.toMatchObject({ code: 'PREAMBLE_REVISION_CONFLICT', status: 409 }); expect(preambles.snapshot()).toMatchObject({ revision: 3 }); }); it('validates the maximum rule shape without quadratic path matching', () => { const preambles = Array.from({ length: 100 }, (_, preambleIndex) => ({ - id: `preamble-${preambleIndex}`, + id: `00000000-0000-4000-8000-${String(preambleIndex).padStart(12, '0')}`, enabled: true, title: `Preamble ${preambleIndex}`, content: 'x', diff --git a/server/preambles/__tests__/store-v2.test.js b/server/preambles/__tests__/store-v2.test.js new file mode 100644 index 0000000000..f7ec8ed399 --- /dev/null +++ b/server/preambles/__tests__/store-v2.test.js @@ -0,0 +1,323 @@ +import { afterEach, describe, expect, it, mock } from 'bun:test'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { + PREAMBLE_ID_LIFETIME_MAX_COUNT, + PREAMBLES_FILE_MAX_BYTES, +} from '../../../common/preambles.js'; +import { PreambleCatalogCommittedUnknownError, PreambleStore } from '../store.ts'; + +const createdDirectories = []; +const AT = '2026-09-03T10:00:00.000Z'; +const ID_A = '3502b645-222b-49d2-ac39-1c91f9fb1174'; +const ID_B = '80becfa6-c9c7-4b31-9190-fd23c0bedf9c'; +const ID_C = '936903ad-8b98-43eb-a7d4-c17ce0dc18d8'; + +async function temporaryDirectory() { + const directory = path.join(os.tmpdir(), `garcon-preambles-${randomUUID()}`); + await fs.mkdir(directory, { recursive: true }); + createdDirectories.push(directory); + return directory; +} + +function preamble(id, overrides = {}) { + return { + id, + enabled: true, + title: `Preamble ${id}`, + content: `Body ${id}`, + scope: { type: 'global' }, + createdAt: AT, + updatedAt: AT, + ...overrides, + }; +} + +afterEach(async () => { + for (const directory of createdDirectories.splice(0)) { + await fs.rm(directory, { recursive: true, force: true }); + } +}); + +async function writeCatalog(directory, file) { + await fs.writeFile(path.join(directory, 'preambles.json'), JSON.stringify(file, null, 2)); +} + +async function readCatalog(directory) { + return JSON.parse(await fs.readFile(path.join(directory, 'preambles.json'), 'utf8')); +} + +describe('PreambleStore version two', () => { + it('migrates a valid version-one catalog at initialization', async () => { + const directory = await temporaryDirectory(); + await writeCatalog(directory, { + version: 1, + revision: 7, + preambles: [preamble(ID_A)], + }); + + const store = new PreambleStore(directory); + await store.init(); + + expect(await readCatalog(directory)).toEqual({ + version: 2, + revision: 7, + preambles: [preamble(ID_A)], + retiredPreambleIds: [], + }); + const reopened = new PreambleStore(directory); + await reopened.init(); + expect(reopened.snapshot()).toEqual(store.snapshot()); + }); + + it('fails visibly on an invalid legacy ID instead of rewriting it', async () => { + const directory = await temporaryDirectory(); + await writeCatalog(directory, { + version: 1, + revision: 1, + preambles: [preamble('legacy-id')], + }); + await expect(new PreambleStore(directory).init()).rejects.toThrow(); + expect(await readCatalog(directory)).toMatchObject({ version: 1 }); + }); + + it('moves a deleted ID into tombstones atomically and preserves it across restart', async () => { + const directory = await temporaryDirectory(); + const store = new PreambleStore(directory); + await store.init(); + await store.create(preamble(ID_A), 0); + await store.create(preamble(ID_B), 1); + + await store.remove(ID_A, 2); + + expect(store.snapshot().preambles.map((entry) => entry.id)).toEqual([ID_B]); + expect(await readCatalog(directory)).toMatchObject({ + version: 2, + revision: 3, + retiredPreambleIds: [ID_A], + }); + + const reopened = new PreambleStore(directory); + await reopened.init(); + expect(reopened.snapshot().preambles.map((entry) => entry.id)).toEqual([ID_B]); + + // A later catalog creation can never acquire the retired identity. + await expect(reopened.create(preamble(ID_A), 3)).rejects.toMatchObject({ + code: 'PREAMBLE_ID_COLLISION', + }); + await reopened.create(preamble(ID_C), 3); + expect((await readCatalog(directory)).preambles.map((entry) => entry.id)) + .toEqual([ID_B, ID_C]); + expect(await readCatalog(directory)).toMatchObject({ + retiredPreambleIds: [ID_A], + }); + }); + + it('rejects persisted duplicate, malformed, or overlapping tombstones', async () => { + const cases = [ + { preambles: [], retiredPreambleIds: [ID_A, ID_A] }, + { preambles: [], retiredPreambleIds: [ID_A, 'not-a-uuid'] }, + { preambles: [preamble(ID_A)], retiredPreambleIds: [ID_A] }, + ]; + for (const file of cases) { + const directory = await temporaryDirectory(); + await writeCatalog(directory, { version: 2, revision: 1, ...file }); + await expect(new PreambleStore(directory).init()).rejects.toThrow(); + } + }); + + it('rejects generated collisions against active and retired IDs without looping', async () => { + const directory = await temporaryDirectory(); + const store = new PreambleStore(directory); + await store.init(); + await store.create(preamble(ID_A), 0); + await store.create(preamble(ID_B), 1); + await store.remove(ID_B, 2); + + await expect(store.create(preamble(ID_A), 3)).rejects.toMatchObject({ + code: 'PREAMBLE_ID_COLLISION', + }); + await expect(store.create(preamble(ID_B), 3)).rejects.toMatchObject({ + code: 'PREAMBLE_ID_COLLISION', + }); + expect(store.snapshot()).toMatchObject({ revision: 3 }); + }); + + it('rejects malformed records at the public store creation boundary', async () => { + const directory = await temporaryDirectory(); + const store = new PreambleStore(directory); + await store.init(); + + await expect(store.create(preamble('not-a-uuid'), 0)).rejects.toMatchObject({ + code: 'PREAMBLE_VALIDATION_FAILED', + }); + await expect(store.create(preamble(ID_A, { content: ' ' }), 0)).rejects.toMatchObject({ + code: 'PREAMBLE_VALIDATION_FAILED', + }); + + expect(store.snapshot()).toEqual({ revision: 0, preambles: [] }); + }); + + it('requires generated builders to preserve the assigned canonical ID', async () => { + const directory = await temporaryDirectory(); + const store = new PreambleStore(directory); + await store.init(); + + await expect(store.createWithGeneratedId( + 0, + () => ID_A, + () => preamble(ID_B), + )).rejects.toMatchObject({ code: 'PREAMBLE_VALIDATION_FAILED' }); + await expect(store.createWithGeneratedId( + 0, + () => ID_A, + () => preamble('malformed'), + )).rejects.toMatchObject({ code: 'PREAMBLE_VALIDATION_FAILED' }); + + expect(store.snapshot()).toEqual({ revision: 0, preambles: [] }); + }); + + it('enforces the lifetime ID ceiling without pruning tombstones', async () => { + const directory = await temporaryDirectory(); + const store = new PreambleStore(directory); + await store.init(); + // Fill the lifetime set to the ceiling through a persisted file. + const retired = []; + const active = []; + for (let index = 0; index < PREAMBLE_ID_LIFETIME_MAX_COUNT; index += 1) { + const id = `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`; + if (active.length < 1) active.push(preamble(id)); + else retired.push(id); + } + await writeCatalog(directory, { + version: 2, + revision: 1, + preambles: active, + retiredPreambleIds: retired, + }); + const full = new PreambleStore(directory); + await full.init(); + expect(full.lifetimeIdCount()).toBe(PREAMBLE_ID_LIFETIME_MAX_COUNT); + await expect(full.create(preamble(ID_C), 1)).rejects.toMatchObject({ + code: 'PREAMBLE_ID_LIFETIME_LIMIT_REACHED', + }); + // Deletion still retires an active entry at the ceiling. + await full.remove(active[0].id, 1); + expect(await readCatalog(directory)).toMatchObject({ + retiredPreambleIds: [...retired, active[0].id], + preambles: active.slice(1).map((entry) => entry.id), + }); + }); + + it('accounts for the exact written payload including the trailing newline and incremented revision', async () => { + // A legal catalog cannot reach the 64 MiB ceiling (lifetime and content + // bounds cap it far below), so exact accounting is proven on the largest + // legal shape: the on-disk byte length must equal the store's serialized + // form with the incremented revision and trailing newline included. + // Stay within the 64,000-code-unit combined composition budget: scope + // each entry to a distinct exact path so no matching set combines them. + const definitions = []; + for (let index = 0; index < 3; index += 1) { + definitions.push(preamble(`00000000-0000-4000-8000-${String(index).padStart(12, '0')}`, { + content: 'C'.repeat(30_000), + scope: { + type: 'project-paths', + rules: [{ projectPath: `/workspace/exact-${index}`, includeNested: false }], + }, + })); + } + const directory = await temporaryDirectory(); + const store = new PreambleStore(directory); + await store.init(); + let revision = 0; + for (const definition of definitions) { + await store.create(definition, revision); + revision += 1; + } + const expectedPayload = `${JSON.stringify({ + version: 2, + revision, + preambles: definitions, + retiredPreambleIds: [], + }, null, 2)}\n`; + const onDisk = await fs.readFile(path.join(directory, 'preambles.json')); + expect(onDisk.byteLength).toBe(new TextEncoder().encode(expectedPayload).byteLength); + expect(onDisk.byteLength).toBeGreaterThan(90_000); + const reopened = new PreambleStore(directory); + await reopened.init(); + expect(reopened.snapshot().preambles).toHaveLength(definitions.length); + }); + + it('fails closed when the persisted file exceeds the byte bound', async () => { + const directory = await temporaryDirectory(); + await fs.writeFile( + path.join(directory, 'preambles.json'), + `${JSON.stringify({ version: 2, revision: 0, preambles: [], retiredPreambleIds: [] })}\n${'x'.repeat(PREAMBLES_FILE_MAX_BYTES)}`, + ); + await expect(new PreambleStore(directory).init()).rejects.toThrow('maximum file size'); + }); + + it('installs the candidate and fences mutations after a post-rename failure', async () => { + const directory = await temporaryDirectory(); + const store = new PreambleStore(directory); + await store.init(); + await store.create(preamble(ID_A), 0); + + // The temp-file write uses flag 'w'; the durability sync opens the + // directory with flag 'r'. Failing only the latter simulates a post-rename + // durability failure. + const originalOpen = fs.open; + fs.open = async (target, flags, ...rest) => { + if (flags === 'r' && typeof target === 'string' && target === directory) { + throw new Error('injected directory sync failure'); + } + return originalOpen(target, flags, ...rest); + }; + try { + await expect(store.remove(ID_A, 1)).rejects.toBeInstanceOf(PreambleCatalogCommittedUnknownError); + } finally { + fs.open = originalOpen; + } + + // The renamed candidate is authoritative: the tombstone is installed. + expect(store.snapshot().preambles).toEqual([]); + expect(await readCatalog(directory)).toMatchObject({ + retiredPreambleIds: [ID_A], + }); + // Later mutations stay fenced until an explicit sync retry or restart. + await expect(store.create(preamble(ID_C), 2)).rejects.toMatchObject({ + code: 'PREAMBLE_CATALOG_SAVE_UNKNOWN', + }); + // Reads remain available from the installed snapshot. + expect(store.snapshot().revision).toBe(2); + // Restart reloads the file and clears the fence; the tombstone survives. + const reopened = new PreambleStore(directory); + await reopened.init(); + expect(reopened.snapshot().preambles).toEqual([]); + await reopened.create(preamble(ID_C), 2); + expect(reopened.snapshot().preambles.map((entry) => entry.id)).toEqual([ID_C]); + }); + + it('keeps the old snapshot authoritative on a pre-rename failure', async () => { + const directory = await temporaryDirectory(); + const store = new PreambleStore(directory); + await store.init(); + await store.create(preamble(ID_A), 0); + const before = store.snapshot(); + + const originalRename = fs.rename; + fs.rename = async () => { + throw new Error('injected rename failure'); + }; + try { + await expect(store.remove(ID_A, 1)).rejects.toThrow('injected rename failure'); + } finally { + fs.rename = originalRename; + } + + expect(store.snapshot()).toEqual(before); + expect(await readCatalog(directory)).toMatchObject({ revision: 1 }); + }); +}); diff --git a/server/preambles/__tests__/store.test.js b/server/preambles/__tests__/store.test.js index dc0f1ce5c8..56ebd2c618 100644 --- a/server/preambles/__tests__/store.test.js +++ b/server/preambles/__tests__/store.test.js @@ -8,6 +8,8 @@ import { PreambleStore } from '../store.ts'; const createdDirectories = []; const AT = '2026-09-03T10:00:00.000Z'; +const ID_A = '3502b645-222b-49d2-ac39-1c91f9fb1174'; +const ID_B = '80becfa6-c9c7-4b31-9190-fd23c0bedf9c'; const originalProjectBaseDir = process.env.GARCON_PROJECT_BASE_DIR; async function temporaryDirectory() { @@ -49,11 +51,11 @@ describe('PreambleStore', () => { await store.init(); expect(store.snapshot()).toEqual({ revision: 0, preambles: [] }); - await store.create(preamble('a'), 0); - await store.create(preamble('b'), 1); - await store.reorder(['b', 'a'], 2); + await store.create(preamble(ID_A), 0); + await store.create(preamble(ID_B), 1); + await store.reorder([ID_B, ID_A], 2); - expect(store.snapshot().preambles.map((entry) => entry.id)).toEqual(['b', 'a']); + expect(store.snapshot().preambles.map((entry) => entry.id)).toEqual([ID_B, ID_A]); const filePath = path.join(directory, 'preambles.json'); expect((await fs.stat(filePath)).mode & 0o777).toBe(0o600); @@ -66,8 +68,8 @@ describe('PreambleStore', () => { const directory = await temporaryDirectory(); const store = new PreambleStore(directory); await store.init(); - await store.create(preamble('a'), 0); - await store.create(preamble('b'), 1); + await store.create(preamble(ID_A), 0); + await store.create(preamble(ID_B), 1); const before = store.snapshot(); await expect(store.remove('a', 1)).rejects.toMatchObject({ @@ -83,15 +85,15 @@ describe('PreambleStore', () => { expect(store.snapshot()).toEqual(before); }); - it('reports a duplicate generated ID as validation failure', async () => { + it('reports a duplicate generated ID as a collision', async () => { const directory = await temporaryDirectory(); const store = new PreambleStore(directory); await store.init(); - await store.create(preamble('a'), 0); + await store.create(preamble(ID_A), 0); - await expect(store.create(preamble('a'), 1)).rejects.toMatchObject({ - code: 'PREAMBLE_VALIDATION_FAILED', - status: 400, + await expect(store.create(preamble(ID_A), 1)).rejects.toMatchObject({ + code: 'PREAMBLE_ID_COLLISION', + status: 409, }); expect(store.snapshot()).toMatchObject({ revision: 1 }); }); @@ -100,19 +102,19 @@ describe('PreambleStore', () => { const directory = await temporaryDirectory(); const store = new PreambleStore(directory); await store.init(); - await store.create(preamble('a'), 0); + await store.create(preamble(ID_A), 0); const snapshot = store.snapshot(); snapshot.preambles[0].title = 'mutated'; - expect(store.snapshot().preambles[0].title).toBe('Preamble a'); + expect(store.snapshot().preambles[0].title).toBe(`Preamble ${ID_A}`); - await store.update('a', { + await store.update(ID_A, { enabled: false, title: 'Updated', content: 'Updated body', scope: { type: 'global' }, }, AT, 1); expect(store.snapshot().preambles[0]).toMatchObject({ - id: 'a', + id: ID_A, enabled: false, title: 'Updated', createdAt: AT, @@ -123,11 +125,11 @@ describe('PreambleStore', () => { it('fails initialization for malformed, relative-path, or over-budget persisted catalogs', async () => { const cases = [ { version: 2, revision: 0, preambles: [] }, - { version: 1, revision: 0, preambles: [preamble('a'), preamble('a')] }, + { version: 1, revision: 0, preambles: [preamble(ID_A), preamble(ID_A)] }, { version: 1, revision: 0, - preambles: [preamble('a', { + preambles: [preamble(ID_A, { scope: { type: 'project-paths', rules: [{ projectPath: 'relative/project', includeNested: true }], @@ -137,7 +139,7 @@ describe('PreambleStore', () => { { version: 1, revision: 0, - preambles: [preamble('a', { + preambles: [preamble(ID_A, { scope: { type: 'project-paths', rules: [{ projectPath: '/workspace/project/../project', includeNested: true }], @@ -148,16 +150,16 @@ describe('PreambleStore', () => { version: 1, revision: 0, preambles: [ - preamble('a', { content: 'a'.repeat(32_000) }), - preamble('b', { content: 'b'.repeat(32_000) }), + preamble(ID_A, { content: 'a'.repeat(32_000) }), + preamble(ID_B, { content: 'b'.repeat(32_000) }), ], }, { version: 1, revision: 0, preambles: [ - preamble('a', { content: 'First body' }), - preamble('b', { + preamble(ID_A, { content: 'First body' }), + preamble(ID_B, { content: 'Referenced file contents from @file mentions:\n\nSynthetic content', }), ], @@ -165,7 +167,7 @@ describe('PreambleStore', () => { { version: 1, revision: 0, - preambles: [preamble('a', { + preambles: [preamble(ID_A, { content: '\nReferenced file contents from @file mentions:\n\nSynthetic content', })], }, @@ -202,7 +204,7 @@ describe('PreambleStore', () => { await fs.writeFile(path.join(directory, 'preambles.json'), JSON.stringify({ version: 1, revision: 1, - preambles: [preamble('a', { + preambles: [preamble(ID_A, { scope: { type: 'project-paths', rules: [{ projectPath: storedPath, includeNested: true }], @@ -227,7 +229,7 @@ describe('PreambleStore', () => { await fs.writeFile(path.join(directory, 'preambles.json'), JSON.stringify({ version: 1, revision: 1, - preambles: [preamble('a', { + preambles: [preamble(ID_A, { scope: { type: 'project-paths', rules: [{ projectPath: canonicalProjectPath, includeNested: true }], diff --git a/server/preambles/boundary.ts b/server/preambles/boundary.ts index befc751a13..5ce20202da 100644 --- a/server/preambles/boundary.ts +++ b/server/preambles/boundary.ts @@ -10,7 +10,7 @@ export interface PreambleBoundaryBinding { } export function createPreambleBoundaryBinding( - kind: PreambleBoundaryKind, + kind: Exclude, ownershipEpoch: string = crypto.randomUUID(), ): PreambleBoundaryBinding { return { diff --git a/server/preambles/chat-selection-service.ts b/server/preambles/chat-selection-service.ts new file mode 100644 index 0000000000..31d2052d91 --- /dev/null +++ b/server/preambles/chat-selection-service.ts @@ -0,0 +1,389 @@ +import { isDeepStrictEqual } from 'node:util'; +import type { + ChatPreambleSelectionTargetResponse, + UpdateChatPreambleSelectionRequest, +} from '../../common/chat-preamble-selection-contracts.js'; +import { + type ChatPreambleSelection, + type PendingPreambleBoundary, + type PreambleSelectionProjection, + type PreamblesSnapshot, +} from '../../common/preambles.js'; +import { stableJsonStringify } from '../../common/json.js'; +import type { AgentOwnershipJournal } from '../chats/agent-ownership-journal.js'; +import type { ChatRegistryEntry, IChatRegistry } from '../chats/store.js'; +import { + ChatRegistryDurabilityUnknownError, +} from '../chats/store.js'; +import { ownershipTransferPendingError } from '../agents/ownership-transfer-fence.js'; +import { DomainError } from '../lib/domain-error.js'; +import type { KeyedPromiseLock } from '../lib/keyed-lock.js'; +import type { TranscriptAdoptionService } from '../ledger/adoption.js'; +import { + LedgerFencedError, + StaleTranscriptViewError, +} from '../ledger/errors.js'; +import type { TranscriptLedgerService } from '../ledger/service.js'; +import { isLedgerPreambleSelectionChangedNoticeRow } from '../ledger/contracts.js'; +import type { PreambleService } from './service.js'; +import { + assertPreambleSelectionComposition, + projectPreambleSelection, + resolvePreambleSelection, +} from './selection.js'; + +export interface ChatPreambleSelectionUpdateOutcome { + readonly status: 'updated' | 'unchanged' | 'duplicate'; + readonly mutationRevision: number; + readonly noticeOrdinal: number | null; + readonly selection: ChatPreambleSelection; + readonly projection: PreambleSelectionProjection; +} + +// Raised when the registry decision committed but its presentation notice did +// not. Reports the committed state honestly; the caller never sees a rollback. +export class ChatPreambleSelectionPartialError extends DomainError { + constructor( + readonly selectionCommitted: true | 'unknown', + message: string, + readonly selection: ChatPreambleSelection | null, + ) { + super( + selectionCommitted === true + ? 'PREAMBLE_SELECTION_NOTICE_FAILED' + : 'PREAMBLE_SELECTION_SAVE_UNKNOWN', + message, + 503, + false, + ); + this.name = 'ChatPreambleSelectionPartialError'; + } +} +export interface ChatPreambleSelectionServiceDeps { + readonly registry: Pick< + IChatRegistry, + 'getChat' | 'updateChatPhased' | 'reconcileUnknownDurability' + >; + readonly adoption: Pick; + readonly ledger: Pick< + TranscriptLedgerService, + | 'appendSelectionChangeNotice' + | 'findSubmissionRow' + | 'hasPreambleBoundaryProof' + >; + readonly preambles: Pick; + readonly ownershipJournal: Pick; + readonly chatMutationLock: KeyedPromiseLock; + readonly selectionAdmissionLock: KeyedPromiseLock; + readonly now?: () => string; + // Invoked after a committed decision so the wiring can schedule the per-chat + // invalidation behind the notice's own row fanout. + readonly onSelectionCommitted?: (chatId: string, revision: number) => void; +} + +export class ChatPreambleSelectionService { + constructor(private readonly deps: ChatPreambleSelectionServiceDeps) {} + + async target(chatId: string, signal: AbortSignal): Promise { + // Reconciliation, adoption, and the registry re-read run under the shared + // chat mutation boundary so the reported view, selection, and project path + // describe one coherent chat state. + return this.deps.chatMutationLock.runExclusive(`chat:${chatId}`, async () => { + // A GET reconciles a durability-unknown phased commit before reporting + // state, so a client can recover into editing only after the server has + // confirmed the committed candidate is durably persisted. + const reconciliation = await this.deps.registry.reconcileUnknownDurability?.(chatId); + signal.throwIfAborted(); + if (reconciliation === 'still-unknown') { + throw new ChatPreambleSelectionPartialError( + 'unknown', + 'The saved selection could not be confirmed yet. Refresh again before editing.', + null, + ); + } + if (reconciliation === 'unavailable') { + throw new DomainError('SESSION_NOT_FOUND', 'Session not found', 404, false); + } + const view = await this.#withTranscriptErrors( + () => this.deps.adoption.ensure(chatId, signal), + ); + // Re-read after adoption: the ensure can adopt a view across an await, + // and the view, selection, and project path must describe the same chat. + const session = this.#requireChat(chatId); + const selection: ChatPreambleSelection = { + revision: session.preambleSelection.revision, + orderedPreambleIds: [...session.preambleSelection.orderedPreambleIds], + }; + return { + success: true, + chatId, + transcriptViewId: view.viewId, + canonicalProjectPath: session.projectPath, + selection, + projection: projectPreambleSelection( + selection, + this.deps.preambles.snapshot(), + session.projectPath, + ), + }; + }); + } + + async update( + input: UpdateChatPreambleSelectionRequest, + ): Promise { + // Lock order is fixed: the chat mutation lock precedes the narrow + // selection/admission lock, which queue admission never inverts. + return this.deps.chatMutationLock.runExclusive(`chat:${input.chatId}`, () => + this.deps.selectionAdmissionLock.runExclusive(`chat:${input.chatId}`, () => + this.#updateLocked(input))); + } + + async #updateLocked( + input: UpdateChatPreambleSelectionRequest, + ): Promise { + const chatId = input.chatId; + const session = this.#requireChat(chatId); + const view = await this.#withTranscriptErrors(() => this.deps.adoption.ensure(chatId)); + if (view.viewId !== input.transcriptViewId) { + throw new DomainError( + 'STALE_TRANSCRIPT_VIEW', + 'The transcript changed before the selection was saved.', + 409, + false, + ); + } + if (this.deps.ownershipJournal.hasPending(chatId)) { + throw ownershipTransferPendingError(); + } + + // The durable notice identity is checked only after the request's view is + // proven current, so a retry can never deduplicate into another view. + const requestFingerprint = selectionRequestFingerprint(input); + const existingSubmission = this.deps.ledger.findSubmissionRow( + chatId, + view.viewId, + input.clientMessageId, + ); + if (existingSubmission) { + // A user-input or CLI-row collision for this identity is a conflict, not + // a selection duplicate. + if ( + !isLedgerPreambleSelectionChangedNoticeRow(existingSubmission) + || existingSubmission.detail.requestFingerprint !== requestFingerprint + ) { + throw new DomainError( + 'IDEMPOTENCY_CONFLICT', + 'The selection notice identity was already used for a different request.', + 409, + false, + ); + } + return this.#outcome( + session, + this.deps.preambles.snapshot(), + 'duplicate', + existingSubmission.detail.selectionRevision, + existingSubmission.ordinal, + session.preambleSelection, + ); + } + + if (session.preambleSelection.revision !== input.expectedRevision) { + throw new DomainError( + 'PREAMBLE_SELECTION_REVISION_CONFLICT', + 'The chat preamble selection changed in another client; refresh and try again.', + 409, + true, + ); + } + + if (isDeepStrictEqual( + session.preambleSelection.orderedPreambleIds, + input.orderedPreambleIds, + )) { + // An unchanged Save is a no-op before composition validation: it creates + // no revision, notice, invalidation, or reapplication. + return this.#outcome( + session, + this.deps.preambles.snapshot(), + 'unchanged', + session.preambleSelection.revision, + null, + session.preambleSelection, + ); + } + + if (input.expectedRevision === Number.MAX_SAFE_INTEGER) { + throw new DomainError( + 'PREAMBLE_REVISION_EXHAUSTED', + 'Preamble selection revision limit reached', + 409, + ); + } + + const catalog = this.deps.preambles.snapshot(); + const nextSelection: ChatPreambleSelection = { + revision: input.expectedRevision + 1, + orderedPreambleIds: [...input.orderedPreambleIds], + }; + const resolved = resolvePreambleSelection(nextSelection, catalog, session.projectPath); + assertPreambleSelectionComposition(chatId, resolved.eligible); + + const nextBoundary = this.#nextBoundary(chatId, session, nextSelection.revision); + let durability: 'durable' | 'unknown'; + try { + const result = await this.deps.registry.updateChatPhased(chatId, { + preambleSelection: nextSelection, + pendingPreambleBoundary: nextBoundary, + }); + if (!result) throw new DomainError('SESSION_NOT_FOUND', 'Session not found', 404, false); + durability = result.durability; + } catch (error) { + if (error instanceof ChatRegistryDurabilityUnknownError) { + throw new ChatPreambleSelectionPartialError( + 'unknown', + 'The selection could not be saved because an earlier save has unconfirmed durability. Refresh and try again.', + null, + ); + } + throw error; + } + if (durability === 'unknown') { + this.#notifySelectionCommitted(chatId, nextSelection.revision); + throw new ChatPreambleSelectionPartialError( + 'unknown', + 'The selection was saved, but its durability could not be confirmed. Refresh before another edit.', + nextSelection, + ); + } + + const committedSelection: ChatPreambleSelection = { + revision: nextSelection.revision, + orderedPreambleIds: [...nextSelection.orderedPreambleIds], + }; + try { + const notice = this.deps.ledger.appendSelectionChangeNotice({ + chatId, + viewId: view.viewId, + at: (this.deps.now ?? (() => new Date().toISOString()))(), + detail: { + type: 'preamble-selection-change', + clientMessageId: input.clientMessageId, + requestFingerprint, + selectionRevision: nextSelection.revision, + preambles: resolved.eligible.map(({ id, title }) => ({ id, title })), + }, + }); + // A committed notice's invalidation is derived by the server event + // wiring from the committed row itself; no direct callback here. + return this.#outcome( + session, + catalog, + notice.inserted ? 'updated' : 'duplicate', + notice.row.detail.selectionRevision, + notice.row.ordinal, + committedSelection, + ); + } catch (error) { + if (error instanceof ChatRegistryDurabilityUnknownError) throw error; + this.#notifySelectionCommitted(chatId, nextSelection.revision); + throw new ChatPreambleSelectionPartialError( + true, + 'The selection was saved, but its transcript notice could not be recorded.', + committedSelection, + ); + } + } + + // A stale, ledger-proven boundary is replaced; an unconsumed lifecycle + // boundary is retained and its first input resolves the newest selection. + #nextBoundary( + chatId: string, + session: ChatRegistryEntry, + nextRevision: number, + ): PendingPreambleBoundary { + const current = session.pendingPreambleBoundary; + if (current && current.kind !== 'selection-change') { + if (!this.deps.ledger.hasPreambleBoundaryProof(chatId, current)) return current; + } + return { + kind: 'selection-change', + ownershipEpoch: session.agentOwnershipEpoch, + selectionRevision: nextRevision, + }; + } + + #notifySelectionCommitted(chatId: string, revision: number): void { + // Only partial outcomes reach this hook: a committed notice's invalidation + // is scheduled by the server event wiring directly behind the notice's own + // chat-messages fanout. With no committed row, the invalidation goes out + // immediately and synchronously. + this.deps.onSelectionCommitted?.(chatId, revision); + } + + #requireChat(chatId: string) { + const session = this.deps.registry.getChat(chatId); + if (!session) throw new DomainError('SESSION_NOT_FOUND', 'Session not found', 404, false); + return session; + } + + #outcome( + session: ChatRegistryEntry, + catalog: PreamblesSnapshot, + status: ChatPreambleSelectionUpdateOutcome['status'], + mutationRevision: number, + noticeOrdinal: number | null, + selection: ChatPreambleSelection, + ): ChatPreambleSelectionUpdateOutcome { + return { + status, + mutationRevision, + noticeOrdinal, + selection: { + revision: selection.revision, + orderedPreambleIds: [...selection.orderedPreambleIds], + }, + projection: projectPreambleSelection( + selection, + catalog, + session.projectPath, + ), + }; + } + + async #withTranscriptErrors(work: () => T | Promise): Promise { + try { + return await work(); + } catch (error) { + if (error instanceof StaleTranscriptViewError) { + throw new DomainError( + 'STALE_TRANSCRIPT_VIEW', + 'The transcript changed before the selection was saved.', + 409, + false, + { cause: error }, + ); + } + if (error instanceof LedgerFencedError) { + throw new DomainError( + 'TRANSCRIPT_UNAVAILABLE', + 'The transcript is unavailable.', + 422, + false, + { cause: error }, + ); + } + throw error; + } + } +} + +function selectionRequestFingerprint(input: UpdateChatPreambleSelectionRequest): string { + return stableJsonStringify({ + chatId: input.chatId, + transcriptViewId: input.transcriptViewId, + expectedRevision: input.expectedRevision, + orderedPreambleIds: input.orderedPreambleIds, + }); +} diff --git a/server/preambles/selection.ts b/server/preambles/selection.ts new file mode 100644 index 0000000000..a2f5197c6a --- /dev/null +++ b/server/preambles/selection.ts @@ -0,0 +1,144 @@ +import { renderPreamblePrefix } from '../../common/preamble-prefix.js'; +import { CHAT_ID_LENGTH } from '../../common/chat-id.js'; +import { + PREAMBLE_COMBINED_MAX_LENGTH, + PREAMBLE_FILE_CONTEXT_SEPARATOR, + renderPreambleContent, + type ChatPreambleSelection, + type Preamble, + type PreambleId, + type PreambleSelectionProjection, + type PreamblesSnapshot, + type UnavailablePreambleSelectionReference, +} from '../../common/preambles.js'; +import { DomainError } from '../lib/domain-error.js'; +import { preambleRuleMatches } from './matching.js'; + +const CHAT_ID_VALIDATION_SAMPLE = '1'.repeat(CHAT_ID_LENGTH); + +export const PREAMBLE_SELECTION_COMPOSITION_INVALID_MESSAGE = + 'Selected preambles can\u2019t be applied in this order. Reconfigure this chat\u2019s preambles and try again.'; + +function preambleScopeMatches(preamble: Preamble, canonicalProjectPath: string): boolean { + return preamble.scope.type === 'global' + || preamble.scope.rules.some((rule) => preambleRuleMatches(rule, canonicalProjectPath)); +} + +// Iterates the saved ID order against one catalog snapshot; filtering the +// catalog through the selection would preserve the wrong order. +export function resolvePreambleSelection( + selection: ChatPreambleSelection, + catalog: PreamblesSnapshot, + canonicalProjectPath: string, +): { + readonly eligible: readonly Preamble[]; + readonly unavailable: readonly UnavailablePreambleSelectionReference[]; +} { + const byId = new Map(catalog.preambles.map((preamble) => [preamble.id, preamble])); + const eligible: Preamble[] = []; + const unavailable: UnavailablePreambleSelectionReference[] = []; + + for (const id of selection.orderedPreambleIds) { + const preamble = byId.get(id); + if (!preamble) { + unavailable.push({ id, reason: 'missing' }); + } else if (!preamble.enabled) { + unavailable.push({ id, reason: 'disabled' }); + } else if (!preambleScopeMatches(preamble, canonicalProjectPath)) { + unavailable.push({ id, reason: 'out-of-scope' }); + } else { + eligible.push(structuredClone(preamble)); + } + } + return { eligible, unavailable }; +} + +export function projectPreambleSelection( + selection: ChatPreambleSelection, + catalog: PreamblesSnapshot, + canonicalProjectPath: string, +): PreambleSelectionProjection { + const resolved = resolvePreambleSelection(selection, catalog, canonicalProjectPath); + return { + catalogRevision: catalog.revision, + eligiblePreambles: resolved.eligible.map(({ id, title }) => ({ id, title })), + unavailable: resolved.unavailable.map((reference) => ({ ...reference })), + }; +} + +// New-chat defaults: enabled, matching catalog IDs in catalog order. +export function defaultOrderedPreambleIds( + catalog: PreamblesSnapshot, + canonicalProjectPath: string, +): PreambleId[] { + return catalog.preambles + .filter((preamble) => preamble.enabled && preambleScopeMatches(preamble, canonicalProjectPath)) + .map((preamble) => preamble.id); +} + +// Validates the exact currently eligible composition in selected order. The +// catalog-wide mutation validator proves only catalog order; chat-level +// reordering can reconstruct the reserved separator across individually valid +// entries, so every changed Save, explicit creation, and admission re-proves it. +export function assertPreambleSelectionComposition( + chatId: string, + eligible: readonly Preamble[], +): void { + if (eligible.length === 0) return; + const prefix = renderPreamblePrefix(eligible.map((preamble) => + renderPreambleContent(preamble.content, chatId))); + if ( + prefix.includes(PREAMBLE_FILE_CONTEXT_SEPARATOR) + || prefix.length > PREAMBLE_COMBINED_MAX_LENGTH + ) { + throw selectionCompositionInvalidError(); + } +} + +export function selectionCompositionInvalidError(): DomainError { + return new DomainError( + 'PREAMBLE_SELECTION_COMPOSITION_INVALID', + PREAMBLE_SELECTION_COMPOSITION_INVALID_MESSAGE, + 422, + ); +} + +// The two recoverable preamble admission errors: an affected prepared target is +// retained for reconfiguration, a queued entry is consumed with a failure, and +// the pending boundary survives both. +export function isRecoverablePreambleAdmissionError( + error: unknown, +): error is DomainError { + return error instanceof DomainError + && (error.code === 'PREAMBLE_SELECTION_COMPOSITION_INVALID' + || error.code === 'PREAMBLE_SLASH_COMMAND_BLOCKED'); +} + +// Resolves an explicit or defaulted creation selection against one catalog +// snapshot. Omitted IDs take enabled matching entries in catalog order; an +// explicit list, including empty, is stored exactly as supplied after its +// currently eligible composition is proven safe. +export function resolveNewChatPreambleSelection(input: { + readonly catalog: PreamblesSnapshot; + readonly canonicalProjectPath: string; + readonly chatId: string; + readonly orderedPreambleIds?: readonly PreambleId[]; +}): ChatPreambleSelection { + if (input.orderedPreambleIds === undefined) { + return { + revision: 0, + orderedPreambleIds: defaultOrderedPreambleIds(input.catalog, input.canonicalProjectPath), + }; + } + const selection: ChatPreambleSelection = { + revision: 0, + orderedPreambleIds: [...input.orderedPreambleIds], + }; + assertPreambleSelectionComposition( + input.chatId, + resolvePreambleSelection(selection, input.catalog, input.canonicalProjectPath).eligible, + ); + return selection; +} + +export { CHAT_ID_VALIDATION_SAMPLE }; diff --git a/server/preambles/service.ts b/server/preambles/service.ts index 236a7d9221..fff2e974a7 100644 --- a/server/preambles/service.ts +++ b/server/preambles/service.ts @@ -1,7 +1,10 @@ import crypto from 'node:crypto'; import { EventEmitter } from 'node:events'; import { + isPreambleId, normalizePreambleDefinitionInput, + PREAMBLE_ID_LIFETIME_MAX_COUNT, + PREAMBLE_MAX_COUNT, type CreatePreambleRequest, type Preamble, type PreambleDefinitionInput, @@ -11,10 +14,10 @@ import { type ReorderPreamblesRequest, type UpdatePreambleRequest, } from '../../common/preambles.js'; +import { PreambleCatalogCommittedUnknownError, PreambleStore } from './store.js'; import { PreambleDomainError } from './errors.js'; import { applicablePreambles } from './matching.js'; import { PreambleProjectPathService } from './project-path-service.js'; -import { PreambleStore } from './store.js'; interface PreambleServiceEvents { invalidated: [reason: PreamblesInvalidationReason]; @@ -45,35 +48,65 @@ export class PreambleService extends EventEmitter { async create(request: CreatePreambleRequest): Promise { const definition = await this.#definition(request.preamble); const now = this.#now().toISOString(); - const preamble: Preamble = { - id: this.#newId(), - ...definition, - createdAt: now, - updatedAt: now, - }; - await this.deps.store.create(preamble, request.expectedRevision); - return this.#changed('created'); + return this.#mutate( + 'created', + () => this.deps.store.createWithGeneratedId( + request.expectedRevision, + this.deps.newId ?? (() => crypto.randomUUID()), + (id) => ({ id, ...definition, createdAt: now, updatedAt: now }), + ), + ); } async update(request: UpdatePreambleRequest): Promise { - const id = request.id.trim(); - if (!id) throw this.#validationError(); + // No trim or case folding: the canonical lowercase UUID-v4 spelling only. + const id = request.id; + if (!isPreambleId(id)) throw this.#validationError(); const definition = await this.#definition(request.preamble); - await this.deps.store.update(id, definition, this.#now().toISOString(), request.expectedRevision); - return this.#changed('updated'); + return this.#mutate( + 'updated', + () => this.deps.store.update(id, definition, this.#now().toISOString(), request.expectedRevision), + ); } async remove(request: RemovePreambleRequest): Promise { - const id = request.id.trim(); - if (!id) throw this.#validationError(); - await this.deps.store.remove(id, request.expectedRevision); - return this.#changed('removed'); + const id = request.id; + if (!isPreambleId(id)) throw this.#validationError(); + return this.#mutate('removed', () => this.deps.store.remove(id, request.expectedRevision)); } async reorder(request: ReorderPreamblesRequest): Promise { - if (!isUniqueStringList(request.orderedPreambleIds)) throw this.#validationError(); - await this.deps.store.reorder(request.orderedPreambleIds, request.expectedRevision); - return this.#changed('reordered'); + if ( + !Array.isArray(request.orderedPreambleIds) + || request.orderedPreambleIds.length > PREAMBLE_MAX_COUNT + || !request.orderedPreambleIds.every((id): id is string => isPreambleId(id)) + || new Set(request.orderedPreambleIds).size !== request.orderedPreambleIds.length + ) throw this.#validationError(); + return this.#mutate('reordered', () => + this.deps.store.reorder(request.orderedPreambleIds, request.expectedRevision)); + } + + // A post-rename failure leaves the installed candidate authoritative, so its + // invalidation is emitted before the unknown-durability outcome surfaces. + async #mutate( + reason: PreamblesInvalidationReason, + run: () => Promise, + ): Promise { + try { + await run(); + } catch (error) { + if (error instanceof PreambleCatalogCommittedUnknownError) { + this.emit('invalidated', reason); + throw new PreambleDomainError( + 'PREAMBLE_CATALOG_SAVE_UNKNOWN', + 'The preambles catalog was saved, but its durability could not be confirmed. Restart the server before further catalog changes.', + 503, + false, + ); + } + throw error; + } + return this.#changed(reason); } async #definition(value: unknown): Promise { @@ -104,17 +137,7 @@ export class PreambleService extends EventEmitter { return new PreambleDomainError('PREAMBLE_VALIDATION_FAILED', 'Preamble is invalid', 400); } - #newId(): string { - return (this.deps.newId ?? crypto.randomUUID)(); - } - #now(): Date { return (this.deps.now ?? (() => new Date()))(); } } - -function isUniqueStringList(value: unknown): value is string[] { - return Array.isArray(value) - && value.every((entry) => typeof entry === 'string') - && new Set(value).size === value.length; -} diff --git a/server/preambles/setup.ts b/server/preambles/setup.ts index 9f82092b67..bedbf56927 100644 --- a/server/preambles/setup.ts +++ b/server/preambles/setup.ts @@ -1,3 +1,9 @@ +import type { AgentOwnershipJournal } from '../chats/agent-ownership-journal.js'; +import type { IChatRegistry } from '../chats/store.js'; +import type { KeyedPromiseLock } from '../lib/keyed-lock.js'; +import type { TranscriptAdoptionService } from '../ledger/adoption.js'; +import type { TranscriptLedgerService } from '../ledger/service.js'; +import { ChatPreambleSelectionService } from './chat-selection-service.js'; import { PreambleProjectPathService } from './project-path-service.js'; import { PreambleService } from './service.js'; import { PreambleStore } from './store.js'; @@ -7,3 +13,26 @@ export async function initializePreambleService(workspaceDir: string): Promise

; + readonly adoption: Pick; + readonly ledger: Pick< + TranscriptLedgerService, + | 'appendSelectionChangeNotice' + | 'findSubmissionRow' + | 'hasPreambleBoundaryProof' + >; + readonly ownershipJournal: Pick; + readonly chatMutationLock: KeyedPromiseLock; + readonly selectionAdmissionLock: KeyedPromiseLock; + readonly onSelectionCommitted: (chatId: string, revision: number) => void; +}): ChatPreambleSelectionService { + return new ChatPreambleSelectionService(deps); +} diff --git a/server/preambles/store.ts b/server/preambles/store.ts index 78a20a548f..32ba438889 100644 --- a/server/preambles/store.ts +++ b/server/preambles/store.ts @@ -1,48 +1,54 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import { + isPreambleId, + PREAMBLE_ID_LIFETIME_MAX_COUNT, PREAMBLE_MAX_COUNT, + PREAMBLES_FILE_MAX_BYTES, normalizePreamble, type Preamble, type PreambleDefinitionInput, type PreamblesSnapshot, } from '../../common/preambles.js'; import { hasNodeErrorCode } from '../lib/errors.js'; -import { writeJsonFileAtomic } from '../lib/json-file-store.js'; +import { AtomicJsonWriteError, writeJsonFileAtomic } from '../lib/json-file-store.js'; import { KeyedPromiseLock } from '../lib/keyed-lock.js'; import { assertRealWithinProjectBase } from '../lib/path-boundary.js'; import { assertPreambleCatalogComposition, PreambleDomainError } from './errors.js'; import { preambleCatalogCompositionViolation } from './catalog-budget.js'; -const PREAMBLES_FILE_VERSION = 1; +const PREAMBLES_FILE_VERSION = 2; interface PreamblesFile { readonly version: typeof PREAMBLES_FILE_VERSION; revision: number; preambles: Preamble[]; + retiredPreambleIds: string[]; } function emptyFile(): PreamblesFile { - return { version: PREAMBLES_FILE_VERSION, revision: 0, preambles: [] }; + return { + version: PREAMBLES_FILE_VERSION, + revision: 0, + preambles: [], + retiredPreambleIds: [], + }; } -function normalizeFile(value: unknown): PreamblesFile { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error('preambles.json must contain an object'); - } - const raw = value as Record; - if (raw.version !== PREAMBLES_FILE_VERSION) { - throw new Error(`Unsupported preambles.json version: ${String(raw.version)}`); +const encoder = new TextEncoder(); + +// The exact final payload: the serialized draft plus the trailing newline the +// atomic writer appends. The ceiling must cover what is actually written. +function fileByteLength(file: PreamblesFile): number { + return encoder.encode(`${JSON.stringify(file, null, 2)}\n`).byteLength; +} + +function parseActivePreambles(raw: unknown): Preamble[] { + if (!Array.isArray(raw) || raw.length > PREAMBLE_MAX_COUNT) { + throw new Error('preambles.json is invalid'); } - if ( - Object.keys(raw).some((key) => !['version', 'revision', 'preambles'].includes(key)) - || !Number.isSafeInteger(raw.revision) - || (raw.revision as number) < 0 - || !Array.isArray(raw.preambles) - || raw.preambles.length > PREAMBLE_MAX_COUNT - ) throw new Error('preambles.json is invalid'); const ids = new Set(); - const preambles = raw.preambles.map((valuePreamble) => { + return raw.map((valuePreamble) => { const preamble = normalizePreamble(valuePreamble); if (!preamble || ids.has(preamble.id)) throw new Error('preambles.json contains an invalid preamble'); const rawRules = preamble.scope.type === 'project-paths' @@ -59,49 +65,192 @@ function normalizeFile(value: unknown): PreamblesFile { ids.add(preamble.id); return preamble; }); - if (preambleCatalogCompositionViolation(preambles)) { - throw new Error('preambles.json contains an invalid combined preamble composition'); +} + +function parseRetiredIds(raw: unknown): string[] { + if (!Array.isArray(raw)) throw new Error('preambles.json is invalid'); + const retired = new Set(); + for (const id of raw) { + if (!isPreambleId(id) || retired.has(id)) { + throw new Error('preambles.json contains an invalid retired preamble ID'); + } + retired.add(id); + } + return [...retired]; +} + +// The permanent non-reuse guarantee begins with the version-2 migration; every +// active version-1 ID must already be a canonical UUID v4. +function normalizeVersionOneFile(value: unknown): PreamblesFile { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('preambles.json must contain an object'); + } + const raw = value as Record; + if (raw.version !== 1) { + throw new Error(`Unsupported preambles.json version: ${String(raw.version)}`); + } + if ( + Object.keys(raw).some((key) => !['version', 'revision', 'preambles'].includes(key)) + || !Number.isSafeInteger(raw.revision) + || (raw.revision as number) < 0 + ) throw new Error('preambles.json is invalid'); + const preambles = parseActivePreambles(raw.preambles); + return { version: PREAMBLES_FILE_VERSION, revision: raw.revision as number, preambles, retiredPreambleIds: [] }; +} + +function normalizeVersionTwoFile(value: unknown): PreamblesFile { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('preambles.json must contain an object'); + } + const raw = value as Record; + if (raw.version !== PREAMBLES_FILE_VERSION) { + throw new Error(`Unsupported preambles.json version: ${String(raw.version)}`); + } + if ( + Object.keys(raw).some((key) => + !['version', 'revision', 'preambles', 'retiredPreambleIds'].includes(key)) + || !Number.isSafeInteger(raw.revision) + || (raw.revision as number) < 0 + ) throw new Error('preambles.json is invalid'); + const preambles = parseActivePreambles(raw.preambles); + const retiredPreambleIds = parseRetiredIds(raw.retiredPreambleIds); + if (retiredPreambleIds.some((id) => preambles.some((preamble) => preamble.id === id))) { + throw new Error('preambles.json contains an ID that is both active and retired'); + } + if (preambles.length + retiredPreambleIds.length > PREAMBLE_ID_LIFETIME_MAX_COUNT) { + throw new Error('preambles.json exceeds the preamble ID lifetime limit'); + } + return { version: PREAMBLES_FILE_VERSION, revision: raw.revision as number, preambles, retiredPreambleIds }; +} + +// The rename completed, so the candidate is authoritative in the file and has +// been installed in memory; only its durability is unknown. Later mutations are +// fenced until an explicit sync retry succeeds or a restart reloads the file. +export class PreambleCatalogCommittedUnknownError extends Error { + constructor(cause: unknown) { + super('The preambles catalog was committed, but its durability could not be confirmed.', { + cause, + }); + this.name = 'PreambleCatalogCommittedUnknownError'; } - return { version: PREAMBLES_FILE_VERSION, revision: raw.revision as number, preambles }; } export class PreambleStore { readonly #filePath: string; readonly #lock = new KeyedPromiseLock(); #file = emptyFile(); + #mutationFence: 'clear' | 'unknown-durability' = 'clear'; constructor(workspaceDir: string) { this.#filePath = path.join(workspaceDir, 'preambles.json'); } async init(): Promise { + let raw: string; try { - const file = normalizeFile(JSON.parse(await fs.readFile(this.#filePath, 'utf8'))); - await assertCanonicalProjectPaths(file.preambles); - this.#file = file; + raw = await fs.readFile(this.#filePath, 'utf8'); } catch (error) { if (!hasNodeErrorCode(error, 'ENOENT')) throw error; this.#file = emptyFile(); + this.#mutationFence = 'clear'; + return; + } + if (encoder.encode(raw).byteLength > PREAMBLES_FILE_MAX_BYTES) { + throw new Error('preambles.json exceeds the maximum file size'); + } + const parsed: unknown = JSON.parse(raw); + const migrated = (parsed as { version?: unknown })?.version === 1; + const file = migrated ? normalizeVersionOneFile(parsed) : normalizeVersionTwoFile(parsed); + await assertCanonicalProjectPaths(file.preambles); + if (preambleCatalogCompositionViolation(file.preambles)) { + throw new Error('preambles.json contains an invalid combined preamble composition'); + } + if (migrated) { + if (fileByteLength(file) > PREAMBLES_FILE_MAX_BYTES) { + throw new Error('Migrated preambles.json would exceed the maximum file size'); + } + await this.#write(file); } + this.#file = file; + this.#mutationFence = 'clear'; } snapshot(): PreamblesSnapshot { return { revision: this.#file.revision, preambles: structuredClone(this.#file.preambles) }; } - async create(preamble: Preamble, expectedRevision: number): Promise { + lifetimeIdCount(): number { + return this.#file.preambles.length + this.#file.retiredPreambleIds.length; + } + + // Generates exactly one canonical ID under the mutation lock after the + // revision check, so a stale request gets the revision conflict and the + // ceiling can never be raced past with a pre-generated ID. + async createWithGeneratedId( + expectedRevision: number, + generate: () => string, + build: (id: string) => Preamble, + ): Promise { await this.#mutate(expectedRevision, (draft) => { - if (draft.preambles.length >= PREAMBLE_MAX_COUNT) { + this.#assertCreateBounds(draft); + const id = generate(); + // A malformed, active, or retired generated ID is rejected, never + // normalized or retried in a loop; the collision may indicate a broken + // generator. + if (!isPreambleId(id)) { throw new PreambleDomainError( - 'PREAMBLE_LIMIT_REACHED', - `A maximum of ${PREAMBLE_MAX_COUNT} preambles is allowed`, - 409, + 'PREAMBLE_VALIDATION_FAILED', + 'Generated preamble ID is not a canonical UUID v4', + 500, ); } - if (draft.preambles.some((entry) => entry.id === preamble.id)) { - throw new PreambleDomainError('PREAMBLE_VALIDATION_FAILED', 'Preamble ID already exists', 400); + this.#assertIdAvailable(draft, id); + const preamble = normalizeCreatedPreamble(build(id)); + if (preamble.id !== id) { + throw new PreambleDomainError( + 'PREAMBLE_VALIDATION_FAILED', + 'Generated preamble does not use its assigned ID', + 500, + ); } - draft.preambles.push(structuredClone(preamble)); + draft.preambles.push(preamble); + }); + } + + #assertCreateBounds(draft: PreamblesFile): void { + if (draft.preambles.length >= PREAMBLE_MAX_COUNT) { + throw new PreambleDomainError( + 'PREAMBLE_LIMIT_REACHED', + `A maximum of ${PREAMBLE_MAX_COUNT} preambles is allowed`, + 409, + ); + } + if (draft.preambles.length + draft.retiredPreambleIds.length + >= PREAMBLE_ID_LIFETIME_MAX_COUNT) { + throw new PreambleDomainError( + 'PREAMBLE_ID_LIFETIME_LIMIT_REACHED', + `The workspace preamble ID lifetime limit of ${PREAMBLE_ID_LIFETIME_MAX_COUNT} is reached`, + 409, + ); + } + } + + #assertIdAvailable(draft: PreamblesFile, id: string): void { + const active = draft.preambles.some((entry) => entry.id === id); + if (!active && !draft.retiredPreambleIds.includes(id)) return; + throw new PreambleDomainError( + 'PREAMBLE_ID_COLLISION', + 'Generated preamble ID collides with an active or retired ID', + 409, + ); + } + + async create(preamble: Preamble, expectedRevision: number): Promise { + await this.#mutate(expectedRevision, (draft) => { + this.#assertCreateBounds(draft); + const normalized = normalizeCreatedPreamble(preamble); + this.#assertIdAvailable(draft, normalized.id); + draft.preambles.push(normalized); }); } @@ -127,7 +276,8 @@ export class PreambleStore { await this.#mutate(expectedRevision, (draft) => { const index = draft.preambles.findIndex((entry) => entry.id === id); if (index < 0) throw this.#notFound(); - draft.preambles.splice(index, 1); + const [removed] = draft.preambles.splice(index, 1); + draft.retiredPreambleIds.push(removed!.id); }); } @@ -143,6 +293,14 @@ export class PreambleStore { async #mutate(expectedRevision: number, change: (draft: PreamblesFile) => void): Promise { await this.#lock.runExclusive('preambles', async () => { + if (this.#mutationFence === 'unknown-durability') { + throw new PreambleDomainError( + 'PREAMBLE_CATALOG_SAVE_UNKNOWN', + 'The preambles catalog has an unconfirmed save; restart the server before further catalog changes.', + 503, + false, + ); + } if (expectedRevision !== this.#file.revision) { throw new PreambleDomainError( 'PREAMBLE_REVISION_CONFLICT', @@ -160,18 +318,65 @@ export class PreambleStore { } const draft = structuredClone(this.#file); change(draft); + if (draft.preambles.length + draft.retiredPreambleIds.length > PREAMBLE_ID_LIFETIME_MAX_COUNT) { + throw new PreambleDomainError( + 'PREAMBLE_ID_LIFETIME_LIMIT_REACHED', + `The workspace preamble ID lifetime limit of ${PREAMBLE_ID_LIFETIME_MAX_COUNT} is reached`, + 409, + ); + } assertPreambleCatalogComposition(draft.preambles); draft.revision += 1; - await writeJsonFileAtomic(this.#filePath, draft, { mode: 0o600 }); + if (fileByteLength(draft) > PREAMBLES_FILE_MAX_BYTES) { + throw new PreambleDomainError( + 'PREAMBLE_VALIDATION_FAILED', + 'The preambles catalog would exceed the maximum file size', + 400, + ); + } + try { + await this.#write(draft); + } catch (error) { + if (error instanceof PreambleCatalogCommittedUnknownError) { + // The renamed candidate is authoritative and installed; fencing keeps a + // later mutation from overwriting a committed selection or tombstone. + this.#file = draft; + this.#mutationFence = 'unknown-durability'; + } + throw error; + } this.#file = draft; }); } + async #write(draft: PreamblesFile): Promise { + try { + await writeJsonFileAtomic(this.#filePath, draft, { mode: 0o600 }); + } catch (error) { + if (error instanceof AtomicJsonWriteError && error.renamed) { + throw new PreambleCatalogCommittedUnknownError(error); + } + throw error; + } + } + #notFound(): PreambleDomainError { return new PreambleDomainError('PREAMBLE_NOT_FOUND', 'Preamble not found', 404); } } +function normalizeCreatedPreamble(value: unknown): Preamble { + const preamble = normalizePreamble(value); + if (!preamble) { + throw new PreambleDomainError( + 'PREAMBLE_VALIDATION_FAILED', + 'Preamble is invalid', + 500, + ); + } + return preamble; +} + async function assertCanonicalProjectPaths(preambles: readonly Preamble[]): Promise { const projectPaths = new Set(preambles.flatMap((preamble) => preamble.scope.type === 'project-paths' ? preamble.scope.rules.map((rule) => rule.projectPath) diff --git a/server/routes/__tests__/chat-preambles.test.js b/server/routes/__tests__/chat-preambles.test.js new file mode 100644 index 0000000000..b5d31be895 --- /dev/null +++ b/server/routes/__tests__/chat-preambles.test.js @@ -0,0 +1,203 @@ +import { describe, expect, it, mock } from 'bun:test'; + +class MalformedJsonError extends Error {} + +mock.module('../../lib/http-request.js', () => ({ + parseJsonBody: mock(() => Promise.resolve({})), + MalformedJsonError, +})); + +import { createChatPreambleRoutes } from '../chat-preambles.ts'; + +const CHAT_ID = '1783725900000200'; +const VIEW = '12345678-1234-4123-8123-123456789abc'; +const ID_A = '3502b645-222b-49d2-ac39-1c91f9fb1174'; + +function routes(selection = {}) { + const targetMock = mock(async () => ({ + success: true, + chatId: CHAT_ID, + transcriptViewId: VIEW, + canonicalProjectPath: '/repo', + selection: { revision: 0, orderedPreambleIds: [] }, + projection: { + catalogRevision: 0, + eligiblePreambles: [], + unavailable: [], + }, + ...selection, + })); + const updateMock = mock(async () => ({ + status: 'updated', + mutationRevision: 1, + noticeOrdinal: 1, + selection: { revision: 1, orderedPreambleIds: [ID_A] }, + projection: { + catalogRevision: 1, + eligiblePreambles: [{ id: ID_A, title: 'Repository conventions' }], + unavailable: [], + }, + })); + const preamblesMock = mock(() => ({ + revision: 1, + preambles: [{ + id: ID_A, + enabled: true, + title: 'Repository conventions', + content: 'body', + scope: { type: 'global' }, + createdAt: '2026-09-03T10:00:00.000Z', + updatedAt: '2026-09-03T10:00:00.000Z', + }], + })); + const projectPathsMock = mock(async (projectPath) => projectPath); + const map = createChatPreambleRoutes({ + selection: { target: targetMock, update: updateMock }, + preambles: { snapshot: preamblesMock }, + projectPaths: { resolve: projectPathsMock }, + }); + return { map, targetMock, updateMock, preamblesMock, projectPathsMock }; +} + +const validBody = { + chatId: CHAT_ID, + transcriptViewId: VIEW, + clientRequestId: 'req-1', + clientMessageId: 'msg-1', + expectedRevision: 0, + orderedPreambleIds: [ID_A], +}; + +function putRequest(body, headers = {}) { + return new Request('http://localhost/api/v1/chats/preambles', { + method: 'PUT', + headers: { 'content-type': 'application/json', ...headers }, + ...body, + }); +} + +describe('chat preamble routes', () => { + it('returns the body-free selection target', async () => { + const { map, targetMock } = routes(); + const request = new Request(`http://localhost/api/v1/chats/preambles?chatId=${CHAT_ID}`); + const response = await map['/api/v1/chats/preambles'].GET( + request, + new URL(request.url), + ); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + success: true, + chatId: CHAT_ID, + canonicalProjectPath: '/repo', + }); + expect(targetMock).toHaveBeenCalledWith(CHAT_ID, expect.any(AbortSignal)); + }); + + it('saves through the typed update contract', async () => { + const { map, updateMock } = routes(); + const response = await map['/api/v1/chats/preambles'].PUT(putRequest({ + body: JSON.stringify(validBody), + })); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + success: true, + commandType: 'chat-preambles-update', + status: 'updated', + }); + expect(updateMock).toHaveBeenCalledWith(validBody); + }); + + it('rejects an oversized declared body before reading it', async () => { + const { map, updateMock } = routes(); + const response = await map['/api/v1/chats/preambles'].PUT(putRequest({ + body: JSON.stringify(validBody), + }, { 'content-length': String(32 * 1024 + 1) })); + expect(response.status).toBe(413); + expect(updateMock).not.toHaveBeenCalled(); + }); + + it('rejects a chunked multibyte body that exceeds the UTF-8 byte limit', async () => { + const { map, updateMock } = routes(); + // Each character is 4 UTF-8 bytes, so the code-unit count stays far below + // the limit while the byte length exceeds it; a code-unit check would pass. + const payload = JSON.stringify({ + ...validBody, + orderedPreambleIds: [ID_A], + clientRequestId: 'x'.repeat(8 * 1024), + padding: '\u{1F680}'.repeat(8 * 1024), + }); + expect(new TextEncoder().encode(payload).byteLength).toBeGreaterThan(32 * 1024); + const response = await map['/api/v1/chats/preambles'].PUT(new Request( + 'http://localhost/api/v1/chats/preambles', + { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: ReadableStream.from([new TextEncoder().encode(payload)]), + duplex: 'half', + }, + )); + expect(response.status).toBe(413); + expect(updateMock).not.toHaveBeenCalled(); + }); + + it('rejects malformed UTF-8 as malformed JSON', async () => { + const { map, updateMock } = routes(); + const response = await map['/api/v1/chats/preambles'].PUT(new Request( + 'http://localhost/api/v1/chats/preambles', + { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: ReadableStream.from([new Uint8Array([0xc3, 0x28])]), + duplex: 'half', + }, + )); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ success: false, error: 'Malformed JSON' }); + expect(updateMock).not.toHaveBeenCalled(); + }); + + it('surfaces typed partial failures with committed state', async () => { + const { map } = routes(); + let updateMock; + const failing = mock(async () => { + const error = new Error('The selection was saved, but its transcript notice could not be recorded.'); + error.code = 'PREAMBLE_SELECTION_NOTICE_FAILED'; + throw error; + }); + updateMock = failing; + const mapFailing = createChatPreambleRoutes({ + selection: { + target: mock(async () => ({})), + update: failing, + }, + preambles: { snapshot: mock(() => ({ revision: 0, preambles: [] })) }, + projectPaths: { resolve: mock(async (p) => p) }, + }); + const response = await mapFailing['/api/v1/chats/preambles'].PUT(putRequest({ + body: JSON.stringify(validBody), + })); + expect(response.status).toBe(500); + expect(updateMock).toHaveBeenCalled(); + }); + + it('projects preview defaults and explicit drafts without bodies', async () => { + const { map, preamblesMock, projectPathsMock } = routes(); + const response = await map['/api/v1/preambles/selection-preview'].POST(new Request( + 'http://localhost/api/v1/preambles/selection-preview', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ projectPath: '/repo' }), + }, + )); + const body = await response.json(); + expect(body).toMatchObject({ + success: true, + canonicalProjectPath: '/repo', + orderedPreambleIds: [ID_A], + }); + expect(JSON.stringify(body)).not.toContain('body'); + expect(preamblesMock).toHaveBeenCalledTimes(1); + expect(projectPathsMock).toHaveBeenCalledWith('/repo'); + }); +}); diff --git a/server/routes/__tests__/chat-routes-test-utils.js b/server/routes/__tests__/chat-routes-test-utils.js index c79c562572..ac7d19ce31 100644 --- a/server/routes/__tests__/chat-routes-test-utils.js +++ b/server/routes/__tests__/chat-routes-test-utils.js @@ -78,6 +78,7 @@ export function createRouteCommandService({ agents, ledger: commandLedger, transientFeeds: transientFeeds ?? { validateAction: () => undefined }, + preambles: { snapshot: () => ({ revision: 0, preambles: [] }) }, handoffs: handoffs ?? { resolveTarget: async ({ handoff }) => ({ agentId: handoff.target.agentId, diff --git a/server/routes/__tests__/preambles.test.js b/server/routes/__tests__/preambles.test.js index 4dcdec10e5..eaec225b07 100644 --- a/server/routes/__tests__/preambles.test.js +++ b/server/routes/__tests__/preambles.test.js @@ -12,6 +12,8 @@ import { PreambleDomainError } from '../../preambles/errors.ts'; import createPreambleRoutes from '../preambles.ts'; const emptySnapshot = { revision: 0, preambles: [] }; +const PREAMBLE_A = '3502b645-222b-49d2-ac39-1c91f9fb1174'; +const PREAMBLE_B = '80becfa6-c9c7-4b31-9190-fd23c0bedf9c'; async function call(handler, body, method) { parseJsonBody.mockResolvedValueOnce(body); @@ -77,30 +79,30 @@ describe('preamble routes', () => { await call(routes['/api/v1/preambles'].PUT, { expectedRevision: 1, - id: 'preamble-a', + id: PREAMBLE_A, preamble: definition, }, 'PUT'); await call(routes['/api/v1/preambles'].DELETE, { expectedRevision: 2, - id: 'preamble-a', + id: PREAMBLE_A, }, 'DELETE'); await call(routes['/api/v1/preambles/reorder'].PUT, { expectedRevision: 3, - orderedPreambleIds: ['preamble-b', 'preamble-a'], + orderedPreambleIds: [PREAMBLE_B, PREAMBLE_A], }, 'PUT'); expect(preambles.update).toHaveBeenCalledWith({ expectedRevision: 1, - id: 'preamble-a', + id: PREAMBLE_A, preamble: definition, }); expect(preambles.remove).toHaveBeenCalledWith({ expectedRevision: 2, - id: 'preamble-a', + id: PREAMBLE_A, }); expect(preambles.reorder).toHaveBeenCalledWith({ expectedRevision: 3, - orderedPreambleIds: ['preamble-b', 'preamble-a'], + orderedPreambleIds: [PREAMBLE_B, PREAMBLE_A], }); }); @@ -135,7 +137,7 @@ describe('preamble routes', () => { const routes = createPreambleRoutes(preambles); const result = await call(routes['/api/v1/preambles'].PUT, { expectedRevision: 2, - id: 'preamble-a', + id: PREAMBLE_A, preamble: { invalid: true }, }, 'PUT'); diff --git a/server/routes/chat-preambles.ts b/server/routes/chat-preambles.ts new file mode 100644 index 0000000000..0d72c285dd --- /dev/null +++ b/server/routes/chat-preambles.ts @@ -0,0 +1,205 @@ +import { InvalidChatIdError, parseChatId } from '../../common/chat-id.js'; +import { CommandRequestValidationError } from '../../common/command-request-validation.js'; +import { + CHAT_PREAMBLE_SELECTION_BODY_MAX_BYTES, + parsePreambleSelectionPreviewRequest, + parseUpdateChatPreambleSelectionRequest, + type PreambleSelectionPreviewResponse, + type UpdateChatPreambleSelectionRequest, + type UpdateChatPreambleSelectionResponse, +} from '../../common/chat-preamble-selection-contracts.js'; +import { + defaultOrderedPreambleIds, + projectPreambleSelection, +} from '../preambles/selection.js'; +import type { ChatPreambleSelectionService } from '../preambles/chat-selection-service.js'; +import { ChatPreambleSelectionPartialError } from '../preambles/chat-selection-service.js'; +import { PreambleProjectPathService } from '../preambles/project-path-service.js'; +import type { PreambleService } from '../preambles/service.js'; +import { MalformedJsonError } from '../lib/http-request.js'; +import { jsonError, jsonErrorFromUnknown } from '../lib/http-error.js'; +import type { RouteMap } from '../lib/http-route-types.js'; +import { ValidationDomainError } from '../lib/domain-error.js'; + +export function createChatPreambleRoutes(deps: { + readonly selection: ChatPreambleSelectionService; + readonly preambles: Pick; + // Injectable for tests; defaults to the shared canonical-path authority. + readonly projectPaths?: Pick; +}): RouteMap { + const projectPaths = deps.projectPaths ?? new PreambleProjectPathService(); + return { + '/api/v1/chats/preambles': { + GET: async (request: Request, url: URL): Promise => { + try { + const rawChatId = url.searchParams.get('chatId'); + if (!rawChatId) throw new ValidationDomainError('chatId query parameter is required'); + const target = await deps.selection.target( + parseChatId(rawChatId), + request.signal, + ); + return noStore(Response.json(target)); + } catch (error) { + return noStore(jsonErrorFromUnknown(normalizeRouteError(error))); + } + }, + PUT: async (request: Request): Promise => { + let body: unknown; + try { + body = await parseLimitedJsonBody(request); + } catch (error) { + if (error instanceof BodyTooLargeError) { + return noStore(jsonError( + `Request body exceeds ${CHAT_PREAMBLE_SELECTION_BODY_MAX_BYTES} bytes`, + 413, + )); + } + if (error instanceof MalformedJsonError) { + return noStore(jsonError('Malformed JSON', 400)); + } + return noStore(jsonErrorFromUnknown(error)); + } + let input: UpdateChatPreambleSelectionRequest; + try { + input = parseUpdateChatPreambleSelectionRequest(body); + } catch (error) { + return noStore(jsonErrorFromUnknown(normalizeRouteError(error))); + } + try { + const outcome = await deps.selection.update(input); + const response: UpdateChatPreambleSelectionResponse = { + success: true, + commandType: 'chat-preambles-update', + clientRequestId: input.clientRequestId, + clientMessageId: input.clientMessageId, + chatId: input.chatId, + transcriptViewId: input.transcriptViewId, + status: outcome.status, + mutationRevision: outcome.mutationRevision, + noticeOrdinal: outcome.noticeOrdinal, + selection: outcome.selection, + projection: outcome.projection, + }; + return noStore(Response.json(response)); + } catch (error) { + if (error instanceof ChatPreambleSelectionPartialError) { + return noStore(Response.json({ + success: false, + errorCode: error.code, + message: error.message, + retryable: false, + selectionCommitted: error.selectionCommitted, + ...(error.selection === null ? {} : { selection: error.selection }), + }, { status: 503 })); + } + return noStore(jsonErrorFromUnknown(normalizeRouteError(error))); + } + }, + }, + '/api/v1/preambles/selection-preview': { + POST: async (request: Request): Promise => { + let body: unknown; + try { + body = await parseLimitedJsonBody(request); + } catch (error) { + if (error instanceof BodyTooLargeError) { + return noStore(jsonError( + `Request body exceeds ${CHAT_PREAMBLE_SELECTION_BODY_MAX_BYTES} bytes`, + 413, + )); + } + if (error instanceof MalformedJsonError) { + return noStore(jsonError('Malformed JSON', 400)); + } + return noStore(jsonErrorFromUnknown(error)); + } + let parsed; + try { + parsed = parsePreambleSelectionPreviewRequest(body); + } catch (error) { + return noStore(jsonErrorFromUnknown(normalizeRouteError(error))); + } + try { + const canonicalProjectPath = await projectPaths.resolve(parsed.projectPath); + const catalog = deps.preambles.snapshot(); + const orderedPreambleIds = parsed.orderedPreambleIds === undefined + ? defaultOrderedPreambleIds(catalog, canonicalProjectPath) + : [...parsed.orderedPreambleIds]; + const response: PreambleSelectionPreviewResponse = { + success: true, + canonicalProjectPath, + orderedPreambleIds, + projection: projectPreambleSelection( + { revision: 0, orderedPreambleIds }, + catalog, + canonicalProjectPath, + ), + }; + return noStore(Response.json(response)); + } catch (error) { + return noStore(jsonErrorFromUnknown(error)); + } + }, + }, + }; +} + +class BodyTooLargeError extends Error { + constructor() { + super('Request body exceeds the size limit'); + this.name = 'BodyTooLargeError'; + } +} + +// Reads the body as UTF-8 bytes with a hard cap; `text.length` would count +// code units, not bytes, so multibyte chunked bodies could bypass the limit. +async function parseLimitedJsonBody(request: Request): Promise { + const declared = Number(request.headers.get('content-length') ?? '0'); + if (Number.isFinite(declared) && declared > CHAT_PREAMBLE_SELECTION_BODY_MAX_BYTES) { + throw new BodyTooLargeError(); + } + if (!request.body) { + const text = await request.text(); + if (text.length > CHAT_PREAMBLE_SELECTION_BODY_MAX_BYTES) throw new BodyTooLargeError(); + return text ? JSON.parse(text) : {}; + } + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let received = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + received += value.byteLength; + if (received > CHAT_PREAMBLE_SELECTION_BODY_MAX_BYTES) { + await reader.cancel().catch(() => undefined); + throw new BodyTooLargeError(); + } + chunks.push(value); + } + const bytes = new Uint8Array(received); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + // Fatal decoding: replacement characters must not launder malformed byte + // sequences into a valid request body. + try { + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + return text ? JSON.parse(text) : {}; + } catch { + throw new MalformedJsonError(); + } +} + +function normalizeRouteError(error: unknown): unknown { + if (error instanceof CommandRequestValidationError || error instanceof InvalidChatIdError) { + return new ValidationDomainError(error.message); + } + return error; +} + +function noStore(response: Response): Response { + response.headers.set('Cache-Control', 'no-store'); + return response; +} diff --git a/server/routes/index.ts b/server/routes/index.ts index 22526480c5..9200e8ea52 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -13,6 +13,7 @@ import createShareRoutes from './shares.js'; import createWorkspaceRoutes from './workspace.js'; import createScheduledPromptRoutes from './scheduled-prompts.js'; import createPreambleRoutes from './preambles.js'; +import { createChatPreambleRoutes } from './chat-preambles.js'; import createTerminalRoutes from './terminals.js'; import createPromptRefinementRoutes from './prompt-refinement.js'; import { createRuntimeRoutes } from './runtime.js'; @@ -52,6 +53,7 @@ import type { ChatRowService } from '../chats/chat-row-service.js'; import type { TranscriptExportService } from '../chats/transcript-export/service.js'; import type { HandoffArtifactService } from '../chats/handoff-artifact/service.js'; import type { PreambleService } from '../preambles/service.js'; +import type { ChatPreambleSelectionService } from '../preambles/chat-selection-service.js'; export default function createAllRoutes({ registry, @@ -75,6 +77,7 @@ export default function createAllRoutes({ scheduledPrompts, snippets, preambles, + chatPreambleSelection, terminals, searchIndex, transcriptSearchSettings, @@ -106,6 +109,7 @@ export default function createAllRoutes({ scheduledPrompts: ScheduledPromptScheduler; snippets: SnippetService; preambles: PreambleService; + chatPreambleSelection: ChatPreambleSelectionService; terminals: TerminalManager; searchIndex: TranscriptSearchController; transcriptSearchSettings: TranscriptSearchSettingsCoordinator; @@ -167,6 +171,10 @@ export default function createAllRoutes({ ...createScheduledPromptRoutes(scheduledPrompts), ...createSnippetRoutes(snippets), ...createPreambleRoutes(preambles), + ...createChatPreambleRoutes({ + selection: chatPreambleSelection, + preambles, + }), ...createPromptRefinementRoutes({ settings, agents }), ...createTerminalRoutes(terminals), }; diff --git a/server/routes/preambles.ts b/server/routes/preambles.ts index 5cb924172e..25040267d5 100644 --- a/server/routes/preambles.ts +++ b/server/routes/preambles.ts @@ -1,8 +1,9 @@ -import type { - CreatePreambleRequest, - RemovePreambleRequest, - ReorderPreamblesRequest, - UpdatePreambleRequest, +import { + isPreambleId, + type CreatePreambleRequest, + type RemovePreambleRequest, + type ReorderPreamblesRequest, + type UpdatePreambleRequest, } from '../../common/preambles.js'; import { jsonError, jsonErrorFromUnknown } from '../lib/http-error.js'; import type { RouteMap } from '../lib/http-route-types.js'; @@ -50,8 +51,8 @@ export default function createPreambleRoutes(preambles: PreambleService): RouteM return jsonError('expectedRevision, id, and preamble are required', 400, 'PREAMBLE_VALIDATION_FAILED'); } const expectedRevision = revision(body?.expectedRevision); - const id = typeof body?.id === 'string' ? body.id.trim() : ''; - if (expectedRevision === null || !id || !body?.preamble) { + const id = typeof body?.id === 'string' ? body.id : ''; + if (expectedRevision === null || !isPreambleId(id) || !body?.preamble) { return jsonError('expectedRevision, id, and preamble are required', 400, 'PREAMBLE_VALIDATION_FAILED'); } try { @@ -66,8 +67,8 @@ export default function createPreambleRoutes(preambles: PreambleService): RouteM return jsonError('expectedRevision and id are required', 400, 'PREAMBLE_VALIDATION_FAILED'); } const expectedRevision = revision(body?.expectedRevision); - const id = typeof body?.id === 'string' ? body.id.trim() : ''; - if (expectedRevision === null || !id) { + const id = typeof body?.id === 'string' ? body.id : ''; + if (expectedRevision === null || !isPreambleId(id)) { return jsonError('expectedRevision and id are required', 400, 'PREAMBLE_VALIDATION_FAILED'); } try { @@ -85,7 +86,7 @@ export default function createPreambleRoutes(preambles: PreambleService): RouteM } const expectedRevision = revision(body?.expectedRevision); const orderedPreambleIds = Array.isArray(body?.orderedPreambleIds) - ? body.orderedPreambleIds.filter((id): id is string => typeof id === 'string') + ? body.orderedPreambleIds.filter((id): id is string => isPreambleId(id)) : null; if ( expectedRevision === null diff --git a/server/server-event-wiring.ts b/server/server-event-wiring.ts index 317643a9cd..33520ba4fe 100644 --- a/server/server-event-wiring.ts +++ b/server/server-event-wiring.ts @@ -4,6 +4,7 @@ import { isChatListInvalidationReason } from '../common/ws-events.ts'; import { isErrorCode } from '../common/error-codes.ts'; import { toClientChatExecutionControlState } from './chat-execution/control-state.ts'; import { createTranscriptEventFanout } from './ledger/event-fanout.js'; +import { isLedgerPreambleSelectionChangedNoticeDetail } from './ledger/contracts.js'; import type { TranscriptViewId } from './ledger/contracts.js'; import type { TurnEventMetadata } from './agents/event-bus.js'; import type { AgentRegistry } from './agents/registry.js'; @@ -42,6 +43,7 @@ import { ScheduledPromptsInvalidatedMessage, SnippetsInvalidatedMessage, PreamblesInvalidatedMessage, + ChatPreamblesInvalidatedMessage, } from '../common/ws-events.ts'; const logger = createLogger('server-events'); @@ -89,6 +91,9 @@ export interface ServerEventWiring { noticeType: ChatOperationalNoticeMessage['noticeType'], content: string, ): void; + // Scheduled through the per-chat task queue so the invalidation follows the + // committed update notice's own chat-messages fanout. + notifyChatPreamblesInvalidated(chatId: string, revision: number): void; broadcastTranscriptSearchStatus(status: TranscriptSearchStatusV1): void; waitForIdle(): Promise; } @@ -190,6 +195,16 @@ export function wireServerEvents({ )); } + // Body-free per-chat invalidation; a lost event is recoverable through a + // reconnect refresh of the already-loaded selection. + function notifyChatPreamblesInvalidated(chatId: string, revision: number): void { + if (!chatExists(chatId)) return; + scheduleChatTask(chatId, 'server-events: chat preambles invalidation failed', () => { + if (!chatExists(chatId)) return; + broadcast(new ChatPreamblesInvalidatedMessage(chatId, revision)); + }); + } + scheduledPrompts.onInvalidated((reason) => { broadcast(new ScheduledPromptsInvalidatedMessage(reason)); }); @@ -348,6 +363,25 @@ export function wireServerEvents({ }); agentRegistry.onTranscriptCommitted(async (event) => { transcriptFanout(event); + // A committed Preambles-updated notice invalidates the per-chat selection + // cache. Scheduling here, immediately after the fanout has enqueued the + // notice's chat-messages task on the per-chat queue, fixes the broadcast + // order deterministically: messages first, invalidation second. + if (event.type === 'rows') { + for (const row of event.rows) { + if ( + row.kind === 'notice' + && isLedgerPreambleSelectionChangedNoticeDetail(row.detail) + && row.detail.selectionRevision >= 0 + ) { + const revision = row.detail.selectionRevision; + scheduleChatTask(event.chatId, 'server-events: chat preambles invalidation failed', () => { + if (!chatExists(event.chatId)) return; + broadcast(new ChatPreamblesInvalidatedMessage(event.chatId, revision)); + }); + } + } + } const applied = transientFeeds.apply(event); if (applied.kind !== 'unchanged') { const mutation = applied.value; @@ -592,6 +626,7 @@ export function wireServerEvents({ notifyAgentHandoff, notifyTranscriptCompositionChanged, notifyOperationalNotice, + notifyChatPreamblesInvalidated, broadcastTranscriptSearchStatus(status: TranscriptSearchStatusV1): void { broadcast(new TranscriptSearchStatusMessage(status)); }, diff --git a/server/server.ts b/server/server.ts index 3e9b48826d..2f731a60d5 100644 --- a/server/server.ts +++ b/server/server.ts @@ -49,7 +49,7 @@ import { TranscriptExportService } from './chats/transcript-export/service.js'; import { HandoffArtifactService } from './chats/handoff-artifact/service.js'; import { TranscriptSearchController } from './chats/search/controller.js'; import { TranscriptSearchSettingsCoordinator } from './chats/search/settings-coordinator.js'; -import { AgentRegistry } from './agents/index.js'; +import { AgentRegistry, createForkNativeHistoryReader } from './agents/index.js'; import { CARRYOVER_COMPACTION_TIMEOUT_MS, CarryOverCompactionService, @@ -105,9 +105,8 @@ import { SnippetProjectPathService, SnippetService, } from './snippets/service.js'; -import { initializePreambleService } from './preambles/setup.js'; +import { initializeChatPreambleSelectionService, initializePreambleService } from './preambles/setup.js'; import { - importNativeHistoryDrafts, ledgerRowsToMessages, TranscriptAdoptionService, TranscriptLedgerService, @@ -347,7 +346,9 @@ export async function startServer(): Promise { // Every chat mutation shares one lock, including live settings changes. const chatMutationLock = new KeyedPromiseLock(); - + // Narrow per-chat lock shared by selection Save, direct admission, and queue + // draining so an input can never overtake a Save's update-notice attempt. + const selectionAdmissionLock = new KeyedPromiseLock(); // Agent registry wraps runtimes, persisted chat state, and endpoint selection. let eventWiring: ServerEventWiring | null = null; let unsubscribeSearchStatus = () => {}; @@ -390,6 +391,16 @@ export async function startServer(): Promise { transcriptAdoption, ); const preambles = await initializePreambleService(workspaceDir); + const chatPreambleSelection = initializeChatPreambleSelectionService({ + preambles, + registry: chatRegistry, + adoption: transcriptAdoption, + ledger: transcriptLedger, + ownershipJournal: agentOwnership, + chatMutationLock, + selectionAdmissionLock, + onSelectionCommitted: (chatId, revision) => eventWiring?.notifyChatPreamblesInvalidated(chatId, revision), + }); agentRegistry = new AgentRegistry({ registry: chatRegistry, @@ -429,6 +440,7 @@ export async function startServer(): Promise { ledger: transcriptLedger, adoption: transcriptAdoption, preambles, + selectionAdmissionLock, }); await chatRegistry.reconcileSessions((session, chatId) => @@ -523,6 +535,7 @@ export async function startServer(): Promise { new InMemoryChatExecutionControlRepository(runtimeState.identity.instanceId), (chatId) => commandLedger.unsettledQueueReceiptKeys(chatId), agentCommands.appendControlReceipt, + selectionAdmissionLock, ); executionQueries = queue; const transcriptReload = new TranscriptReloadService({ @@ -581,35 +594,17 @@ export async function startServer(): Promise { agents: agentRegistry, fileMentions: { resolve: resolveFileMentionsInCommand }, forkChatFileCopy, - readForkedNativeHistory: async ({ - targetChatId, - sourceSession, - fork, - signal, - preambleEvidence, - }) => { - const integration = integrationRegistry.get(sourceSession.agentId); - if (!integration?.nativeHistoryImport) return null; - return importNativeHistoryDrafts({ - chatId: targetChatId, - entry: sourceSession, - integration, - nativeHistoryImport: integration.nativeHistoryImport, - session: fork, - // The fork target starts with no carryover of its own; its history is the - // session it resumes from. - carryOverRevision: carryOver.revision([]), - signal, - now: () => new Date().toISOString(), - preambleEvidence, - }); - }, + readForkedNativeHistory: createForkNativeHistoryReader({ + integrations: integrationRegistry, + carryOver, + }), transcripts: transcriptLedger, chatListProjector, pathCache, ownership: agentOwnership, handoffs, transientFeeds, + preambles, chatMutationLock, }); agentCommands.initialize({ @@ -730,6 +725,7 @@ export async function startServer(): Promise { scheduledPrompts, snippets, preambles, + chatPreambleSelection, terminals: terminalManager, searchIndex: chatSearch, transcriptSearchSettings, From 0ad98f6e9d393f8537b1707d6a8b444080cba1c8 Mon Sep 17 00:00:00 2001 From: cfal Date: Sun, 6 Sep 2026 21:19:17 +0000 Subject: [PATCH 2/5] feat(web): configure preambles per chat --- .../tests/e2e/per-chat-preambles.test.ts | 339 ++++++++++++++ web/messages/en.json | 26 ++ .../__tests__/chat-preambles-contract.test.ts | 121 +++++ .../api/__tests__/preambles-contract.test.ts | 12 +- web/src/lib/api/chat-preambles.ts | 88 ++++ web/src/lib/api/chats.ts | 1 + .../chat/conversation/submission-routes.ts | 1 + .../__tests__/new-chat-form-state.test.ts | 139 +++++- .../new-chat/new-chat-form-state.svelte.ts | 12 + ...ew-chat-preamble-selection-state.svelte.ts | 124 +++++ .../lib/components/chat/ChatSurface.svelte | 10 + .../lib/components/chat/NewChatDialog.svelte | 1 + .../lib/components/chat/NewChatForm.svelte | 41 +- .../chat/__tests__/ChatSurface.test.ts | 1 + .../chat/__tests__/NewChatDialog.test.ts | 28 +- .../chat/__tests__/NewChatForm.test.ts | 9 + .../__tests__/PreambleApplicationRow.test.ts | 7 +- .../__tests__/TranscriptNoticeRow.test.ts | 42 ++ .../rows/PreambleSelectionChangedRow.svelte | 38 ++ .../chat/rows/TranscriptNoticeRow.svelte | 7 + web/src/lib/components/layout/AppShell.svelte | 10 + .../components/layout/CurrentChatMenu.svelte | 3 + .../layout/CurrentChatMenuItems.svelte | 9 + .../ChatPreambleSelectionDialog.svelte | 194 ++++++++ .../ChatPreambleSelectionPanel.svelte | 191 ++++++++ .../preambles/NewChatPreamblePicker.svelte | 168 +++++++ .../ChatPreambleSelectionDialog.test.ts | 71 +++ ...ChatPreambleSelectionDialogTestHost.svelte | 17 + .../ChatPreambleSelectionPanel.test.ts | 163 +++++++ .../ChatPreambleSelectionTestHost.svelte | 82 ++++ .../components/workspace/WorkspaceRoot.svelte | 4 + .../workspace/__tests__/WorkspaceRoot.test.ts | 35 ++ web/src/lib/context/index.ts | 4 + web/src/lib/events/preambles-router.svelte.ts | 14 +- .../chat-selection-controller.test.ts | 389 ++++++++++++++++ .../chat-selection-invalidation-hub.test.ts | 55 +++ .../__tests__/preamble-filter.logic.test.ts | 14 +- .../selection-projection.logic.test.ts | 145 ++++++ .../chat-selection-controller.svelte.ts | 428 ++++++++++++++++++ .../chat-selection-invalidation-hub.ts | 46 ++ web/src/lib/preambles/selection-projection.ts | 84 ++++ .../lib/stores/__tests__/app-shell.test.ts | 23 + web/src/lib/stores/app-shell.svelte.ts | 35 +- web/src/lib/types/app.ts | 1 + web/src/lib/types/chat-session.ts | 1 + web/src/routes/+layout.svelte | 9 +- 46 files changed, 3212 insertions(+), 30 deletions(-) create mode 100644 integration-tests/tests/e2e/per-chat-preambles.test.ts create mode 100644 web/src/lib/api/__tests__/chat-preambles-contract.test.ts create mode 100644 web/src/lib/api/chat-preambles.ts create mode 100644 web/src/lib/chat/new-chat/new-chat-preamble-selection-state.svelte.ts create mode 100644 web/src/lib/components/chat/rows/PreambleSelectionChangedRow.svelte create mode 100644 web/src/lib/components/preambles/ChatPreambleSelectionDialog.svelte create mode 100644 web/src/lib/components/preambles/ChatPreambleSelectionPanel.svelte create mode 100644 web/src/lib/components/preambles/NewChatPreamblePicker.svelte create mode 100644 web/src/lib/components/preambles/__tests__/ChatPreambleSelectionDialog.test.ts create mode 100644 web/src/lib/components/preambles/__tests__/ChatPreambleSelectionDialogTestHost.svelte create mode 100644 web/src/lib/components/preambles/__tests__/ChatPreambleSelectionPanel.test.ts create mode 100644 web/src/lib/components/preambles/__tests__/ChatPreambleSelectionTestHost.svelte create mode 100644 web/src/lib/preambles/__tests__/chat-selection-controller.test.ts create mode 100644 web/src/lib/preambles/__tests__/chat-selection-invalidation-hub.test.ts create mode 100644 web/src/lib/preambles/__tests__/selection-projection.logic.test.ts create mode 100644 web/src/lib/preambles/chat-selection-controller.svelte.ts create mode 100644 web/src/lib/preambles/chat-selection-invalidation-hub.ts create mode 100644 web/src/lib/preambles/selection-projection.ts diff --git a/integration-tests/tests/e2e/per-chat-preambles.test.ts b/integration-tests/tests/e2e/per-chat-preambles.test.ts new file mode 100644 index 0000000000..5a1b100b42 --- /dev/null +++ b/integration-tests/tests/e2e/per-chat-preambles.test.ts @@ -0,0 +1,339 @@ +import { describe, expect, test } from 'bun:test'; +import { join } from 'node:path'; +import type { PreamblesSnapshot } from '../../../common/preambles.js'; +import type { ChatPreambleSelectionTargetResponse } from '../../../common/chat-preamble-selection-contracts.js'; +import type { ChatListResponse } from '../../../common/chat-list.js'; +import { TranscriptNoticeMessage } from '../../../common/chat-types.js'; +import { SpaDriver } from '../../support/spa-driver.js'; +import { + type E2eFixture, + withE2eFixture, +} from '../../support/e2e-fixture.js'; + +async function exposeTranscriptViewport(fixture: E2eFixture): Promise { + await fixture.page.waitForFunction( + () => document.querySelector('[data-chat-scroll-viewport]')?.getAttribute('aria-busy') + === 'false', + { timeout: 20_000 }, + ); + await fixture.page.$eval('[data-chat-scroll-viewport]', (element) => { + Object.defineProperty(element, 'clientHeight', { + configurable: true, + get: () => 720, + }); + element.dispatchEvent(new Event('scroll', { bubbles: true })); + }); +} + +async function visibleTextCount(fixture: E2eFixture, selector: string, text: string): Promise { + return fixture.page.evaluate( + ({ selector, text }) => [...document.querySelectorAll(selector)] + .filter((element) => element.textContent?.trim() === text) + .length, + { selector, text }, + ); +} + +describe('Lightpanda per-chat preambles', () => { + test('[PREAMBLE-SELECTION.03-LIGHTPANDA-01] configures a new chat selection and applies chat order', async () => { + await withE2eFixture('per-chat-preambles', async (fixture) => { + const app = new SpaDriver(fixture.page, fixture.integration); + await fixture.page.setViewport({ width: 1_280, height: 2_000 }); + await app.open(); + await fixture.waitForSpaWebSocket(); + + // Seed the catalog with two enabled global preambles through the API. + let catalog: PreamblesSnapshot = await fixture.integration.client.post<{ snapshot: PreamblesSnapshot }>('/api/v1/preambles', { + expectedRevision: 0, + preamble: { + enabled: true, + title: 'Alpha rules', + content: 'SYNTHETIC_ALPHA_BODY', + scope: { type: 'global' }, + }, + }).then((response) => response.snapshot); + catalog = await fixture.integration.client.post<{ snapshot: PreamblesSnapshot }>('/api/v1/preambles', { + expectedRevision: catalog.revision, + preamble: { + enabled: true, + title: 'Beta rules', + content: 'SYNTHETIC_BETA_BODY', + scope: { type: 'global' }, + }, + }).then((response) => response.snapshot); + const alphaId = catalog.preambles.find((preamble) => preamble.title === 'Alpha rules')!.id; + const betaId = catalog.preambles.find((preamble) => preamble.title === 'Beta rules')!.id; + + // Open the New Chat dialog and wait for it to settle. + await app.clickButton('New Chat'); + await fixture.page.waitForFunction( + () => { + const dialog = document.querySelector('[role="dialog"]'); + return ( + dialog !== null && + dialog.querySelector('[role="status"][aria-label="Loading chat defaults..."]') === null + ); + }, + { timeout: 20_000 }, + ); + // The defaults preview count covers both catalog entries. + await fixture.page.waitForFunction( + () => document.querySelector('[data-slot="new-chat-preambles-label"]') !== null, + { timeout: 20_000 }, + ); + await fixture.page.waitForFunction( + (count) => document.querySelector('[data-slot="new-chat-preambles-label"]')?.textContent + ?.includes(count), + { timeout: 20_000 }, + '2', + ); + + // Customize to an explicit reversed order through the picker. + await fixture.page.evaluate(() => { + const button = document.querySelector( + '[data-slot="new-chat-preambles-configure"]', + ); + if (!button) throw new Error('Missing new-chat preambles configure action'); + button.click(); + }); + await fixture.page.waitForFunction( + (title) => [...document.querySelectorAll('[data-slot="chat-preamble-selection-row-title"]')] + .some((element) => element.textContent?.trim() === title), + { timeout: 20_000 }, + 'Alpha rules', + ); + await fixture.page.evaluate((title) => { + const row = [...document.querySelectorAll( + '[data-slot="chat-preamble-selection-row"]', + )].find((element) => element.querySelector( + '[data-slot="chat-preamble-selection-row-title"]', + )?.textContent?.trim() === title); + const button = row?.querySelector( + '[data-slot="chat-preamble-selection-move-up"]', + ); + if (!button || button.disabled) throw new Error(`Missing or disabled move action: ${title}`); + button.click(); + }, 'Beta rules'); + await fixture.page.waitForFunction( + (titles) => [...document.querySelectorAll('[data-slot="chat-preamble-selection-row-title"]')] + .map((element) => element.textContent?.trim()) + .join('|') === titles, + { timeout: 20_000 }, + 'Beta rules|Alpha rules', + ); + await fixture.page.evaluate(() => { + const button = document.querySelector( + '[data-slot="new-chat-preamble-apply"]', + ); + if (!button || button.disabled) throw new Error('Missing or disabled new-chat preamble apply'); + button.click(); + }); + await fixture.page.waitForFunction( + () => document.querySelector('[data-slot="new-chat-preamble-scroll-body"]') === null, + { timeout: 20_000 }, + ); + // The form now shows the reset affordance of an explicit choice. + await fixture.page.waitForFunction( + () => document.querySelector('[data-slot="new-chat-preambles-reset"]') !== null, + { timeout: 20_000 }, + ); + + await app.ensureDirectModelSelected({ + selectedAgentLabel: 'Direct (Chat Completions)', + optionAgentLabel: 'Chat Completions', + modelLabel: 'Integration Echo', + }); + await app.fill( + '[role="dialog"] textarea[placeholder="How can I help you today?"]', + 'per-chat boundary prompt', + ); + await app.waitForDialogButtonEnabled('Start session'); + const held = fixture.integration.fakeProviders.openAi.holdNext({ + model: fixture.integration.directAgents.openAi.provider.model, + }); + await app.clickButton('Start session'); + const providerRequest = await fixture.integration.fakeProviders.openAi.waitForRequest( + { model: fixture.integration.directAgents.openAi.provider.model }, + { timeoutMs: 20_000 }, + ); + await held.received; + const requestText = providerRequest.lastUserText; + expect(requestText).toContain('SYNTHETIC_BETA_BODY'); + expect(requestText.indexOf('SYNTHETIC_BETA_BODY')).toBeLessThan(requestText.indexOf('SYNTHETIC_ALPHA_BODY')); + expect(held.releaseText('per-chat synthetic response')).toBeTrue(); + + await exposeTranscriptViewport(fixture); + await fixture.page.waitForFunction( + () => [...document.querySelectorAll('[data-slot="preamble-application-title"]')] + .map((element) => element.textContent?.trim()) + .join('|') === 'Beta rules|Alpha rules', + { timeout: 20_000 }, + ); + + // The persisted registry selection keeps both explicit IDs. + const target = await fixture.integration.client.get( + `/api/v1/chats/preambles?chatId=${await currentChatId(fixture)}`, + ); + expect(new Set(target.selection.orderedPreambleIds)).toEqual(new Set([alphaId, betaId])); + }); + }); + + test('[PREAMBLE-SELECTION.03-LIGHTPANDA-02] configures an existing chat from its menu and applies on the next message', async () => { + await withE2eFixture('per-chat-preambles-existing', async (fixture) => { + const app = new SpaDriver(fixture.page, fixture.integration); + await fixture.page.setViewport({ width: 1_280, height: 2_000 }); + await app.open(); + await fixture.waitForSpaWebSocket(); + + const firstHeld = fixture.integration.fakeProviders.openAi.holdNext({ + model: fixture.integration.directAgents.openAi.provider.model, + }); + await app.startOpenAiDirectChat('existing chat first prompt', { + requestMatcher: { model: fixture.integration.directAgents.openAi.provider.model }, + }); + await firstHeld.received; + expect(firstHeld.releaseText('existing first response')).toBeTrue(); + + // The chat predates the catalog entry, so its saved selection is empty. + const catalog: PreamblesSnapshot = await fixture.integration.client.post<{ snapshot: PreamblesSnapshot }>('/api/v1/preambles', { + expectedRevision: 0, + preamble: { + enabled: true, + title: 'Existing rules', + content: 'SYNTHETIC_EXISTING_BODY', + scope: { type: 'global' }, + }, + }).then((response) => response.snapshot); + + // Configure preambles from the chat window's own surface menu; the + // callback captures the window's chat, not the sidebar selection. + await app.clickButton('Window actions'); + await app.waitForMenuItemEnabled('Configure preambles'); + await app.clickMenuItem('Configure preambles'); + + await fixture.page.waitForFunction( + () => document.querySelector('[data-slot="chat-preamble-selection-save"]') !== null, + { timeout: 20_000 }, + ); + // The empty saved selection shows None enabled. + await fixture.page.waitForFunction( + () => document.querySelector('[data-slot="chat-preamble-selection-empty"]') !== null, + { timeout: 20_000 }, + ); + await fixture.page.evaluate(() => { + const button = document.querySelector( + '[data-slot="chat-preamble-selection-toggle-candidates"]', + ); + if (!button) throw new Error('Missing selection candidates toggle'); + button.click(); + }); + await fixture.page.waitForFunction( + (title) => document.querySelector('[data-slot="chat-preamble-selection-candidates"]') + ?.textContent?.includes(title), + { timeout: 20_000 }, + 'Existing rules', + ); + await fixture.page.evaluate((title) => { + const row = [...document.querySelectorAll( + '[data-slot="chat-preamble-selection-candidate"]', + )].find((element) => element.querySelector( + '[data-slot="chat-preamble-selection-candidate-title"]', + )?.textContent?.trim() === title); + const button = row?.querySelector( + '[data-slot="chat-preamble-selection-candidate-add"]', + ); + if (!button) throw new Error(`Missing add action for ${title}`); + button.click(); + }, 'Existing rules'); + const providerRequestCountBeforeSave = fixture.integration.fakeProviders.openAi.requests().length; + await fixture.page.evaluate(() => { + const button = document.querySelector( + '[data-slot="chat-preamble-selection-save"]', + ); + if (!button || button.disabled) throw new Error('Missing or disabled selection save'); + button.click(); + }); + + // The committed update notice is the browser-visible Save barrier. + await fixture.page.waitForFunction( + (title) => [...document.querySelectorAll( + '[data-slot="preamble-selection-changed-title"]', + )].some((element) => element.textContent?.trim() === title), + { timeout: 20_000 }, + 'Existing rules', + ); + + // Once the WS row is visible, one authoritative read verifies the + // registry decision without timing-based polling. + const chatIdForSave = await currentChatId(fixture); + const savedTarget = await fixture.integration.client.get( + `/api/v1/chats/preambles?chatId=${chatIdForSave}`, + ); + expect(savedTarget.selection.revision).toBe(1); + expect(savedTarget.selection.orderedPreambleIds).toEqual([catalog.preambles[0]!.id]); + const savedHistory = await fixture.integration.client.getMessages(chatIdForSave); + const savedNotice = savedHistory.messages + .map((entry) => entry.message) + .find((message): message is TranscriptNoticeMessage => message instanceof TranscriptNoticeMessage + && message.detail?.type === 'preamble-selection-changed'); + expect(savedNotice?.content).toBe('Preambles updated'); + expect(savedNotice?.detail?.type === 'preamble-selection-changed' + ? savedNotice.detail.preambles + : []).toEqual([{ id: catalog.preambles[0]!.id, title: 'Existing rules' }]); + expect(fixture.integration.fakeProviders.openAi.requests()).toHaveLength( + providerRequestCountBeforeSave, + ); + + await fixture.page.evaluate(() => { + const button = document.querySelector( + '[data-slot="chat-preamble-selection-cancel"]', + ); + if (!button || button.disabled) throw new Error('Missing selection close action'); + button.click(); + }); + await fixture.page.waitForFunction( + () => document.querySelector('[data-slot="chat-preamble-selection-dialog"]') === null, + { timeout: 20_000 }, + ); + + const applied = fixture.integration.fakeProviders.openAi.holdNext({ + model: fixture.integration.directAgents.openAi.provider.model, + }); + await app.sendComposer('existing chat applies selection'); + const appliedRequest = await applied.received; + expect(appliedRequest.lastUserText).toContain('SYNTHETIC_EXISTING_BODY'); + expect(appliedRequest.lastUserText).toContain('existing chat applies selection'); + expect(applied.releaseText('selection applied response')).toBeTrue(); + await app.waitForText('selection applied response'); + await exposeTranscriptViewport(fixture); + await fixture.page.waitForFunction( + (title) => [...document.querySelectorAll( + '[data-slot="preamble-application-title"]', + )].some((element) => element.textContent?.trim() === title), + { timeout: 20_000 }, + 'Existing rules', + ); + + const ordinary = fixture.integration.fakeProviders.openAi.holdNext({ + model: fixture.integration.directAgents.openAi.provider.model, + }); + await app.sendComposer('existing chat ordinary follow-up'); + const ordinaryRequest = await ordinary.received; + expect(ordinaryRequest.lastUserText).toBe('existing chat ordinary follow-up'); + expect(ordinaryRequest.lastUserText).not.toContain('SYNTHETIC_EXISTING_BODY'); + expect(ordinary.releaseText('ordinary follow-up response')).toBeTrue(); + await app.waitForText('ordinary follow-up response'); + expect(await visibleTextCount( + fixture, + '[data-slot="preamble-application-title"]', + 'Existing rules', + )).toBe(1); + fixture.assertNoBrowserErrors(); + }); + }); +}); + +async function currentChatId(fixture: E2eFixture): Promise { + const chats = await fixture.integration.client.get('/api/v1/chats'); + return chats.sessions.at(-1)!.id; +} diff --git a/web/messages/en.json b/web/messages/en.json index 41132a80fd..3d54f03c8e 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -1487,6 +1487,31 @@ "settings_transcript_search_enabled_description": "A local transcript index is being kept for this workspace.", "settings_transcript_search_disabled_description": "Off by default. The local transcript index is deleted while disabled.", "settings_title": "Settings", + "preamble_selection_add": "Add preamble", + "preamble_selection_add_candidate": "Add {title}", + "preamble_selection_apply": "Apply", + "preamble_selection_conflict": "The saved preambles changed while editing. Review the list and save again.", + "preamble_selection_configure": "Configure", + "preamble_selection_dialog_title": "Chat preambles", + "preamble_selection_loading": "Loading preambles…", + "preamble_selection_manage_catalog": "Manage catalog", + "preamble_selection_move_down": "Move {title} down", + "preamble_selection_move_up": "Move {title} up", + "preamble_selection_next_message_hint": "Applies with the next regular message. Current responses are unchanged.", + "preamble_selection_no_candidates": "No more preambles in the catalog", + "preamble_selection_none_enabled": "None enabled", + "preamble_selection_refresh": "Refresh", + "preamble_selection_refresh_required": "The saved selection could not be confirmed. Refresh before another edit.", + "preamble_selection_remove": "Remove {title}", + "preamble_selection_reset_defaults": "Reset to defaults", + "preamble_selection_row_unavailable": "This entry could not be displayed.", + "preamble_selection_save": "Save", + "preamble_selection_saving": "Saving…", + "preamble_selection_status_disabled": "Disabled globally", + "preamble_selection_status_missing": "Deleted or unavailable", + "preamble_selection_status_out_of_scope": "Outside this project", + "preambles_label_with_count": "Preambles · {count}", + "preambles_updated_label": "Preambles updated", "preambles_add": "Add preamble", "preambles_add_path": "Add project path", "preambles_add_title": "Add Preamble", @@ -1688,6 +1713,7 @@ "sidebar_chat_archived_status": "Archived", "sidebar_chats_archive": "Archive", "sidebar_chats_details": "Details", + "sidebar_chats_configure_preambles": "Configure preambles", "sidebar_chats_fetching_chats": "Fetching chats...", "sidebar_chats_fork": "Fork", "sidebar_chats_loading": "Loading...", diff --git a/web/src/lib/api/__tests__/chat-preambles-contract.test.ts b/web/src/lib/api/__tests__/chat-preambles-contract.test.ts new file mode 100644 index 0000000000..3fcfa4beb6 --- /dev/null +++ b/web/src/lib/api/__tests__/chat-preambles-contract.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + getChatPreambleSelection, + preambleSelectionPreview, + updateChatPreambleSelection, +} from '../chat-preambles.js'; + +vi.stubGlobal('localStorage', { + getItem: () => 'test-token', + setItem: () => {}, + removeItem: () => {}, +}); + +const CHAT_ID = '1783725900000200'; +const VIEW_ID = '12345678-1234-4123-8123-123456789abc'; +const PREAMBLE_ID = '3502b645-222b-49d2-ac39-1c91f9fb1174'; + +function projection() { + return { + catalogRevision: 1, + eligiblePreambles: [{ id: PREAMBLE_ID, title: 'Repository conventions' }], + unavailable: [], + }; +} + +function targetResponse(overrides: Record = {}) { + return { + success: true, + chatId: CHAT_ID, + transcriptViewId: VIEW_ID, + canonicalProjectPath: '/repo', + selection: { revision: 1, orderedPreambleIds: [PREAMBLE_ID] }, + projection: projection(), + ...overrides, + }; +} + +function updateResponse(overrides: Record = {}) { + return { + success: true, + commandType: 'chat-preambles-update', + clientRequestId: 'request-1', + clientMessageId: 'message-1', + chatId: CHAT_ID, + transcriptViewId: VIEW_ID, + status: 'updated', + mutationRevision: 1, + noticeOrdinal: 4, + selection: { revision: 1, orderedPreambleIds: [PREAMBLE_ID] }, + projection: projection(), + ...overrides, + }; +} + +describe('chat preamble selection API contract', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => vi.restoreAllMocks()); + + it('requires the captured chat and transcript view on target reads', async () => { + fetchMock.mockResolvedValueOnce(Response.json(targetResponse())); + await expect(getChatPreambleSelection(CHAT_ID, VIEW_ID)).resolves.toMatchObject({ + chatId: CHAT_ID, + transcriptViewId: VIEW_ID, + }); + + fetchMock.mockResolvedValueOnce(Response.json(targetResponse({ chatId: '1783725900000299' }))); + await expect(getChatPreambleSelection(CHAT_ID, VIEW_ID)).rejects.toThrow( + 'Invalid chat preamble selection response', + ); + fetchMock.mockResolvedValueOnce(Response.json(targetResponse({ + transcriptViewId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + }))); + await expect(getChatPreambleSelection(CHAT_ID, VIEW_ID)).rejects.toThrow( + 'Invalid chat preamble selection response', + ); + }); + + it('requires every echoed update identity', async () => { + const request = { + chatId: CHAT_ID, + transcriptViewId: VIEW_ID, + clientRequestId: 'request-1', + clientMessageId: 'message-1', + expectedRevision: 0, + orderedPreambleIds: [PREAMBLE_ID], + }; + fetchMock.mockResolvedValueOnce(Response.json(updateResponse())); + await expect(updateChatPreambleSelection(request)).resolves.toMatchObject({ kind: 'committed' }); + + for (const mismatch of [ + { chatId: '1783725900000299' }, + { transcriptViewId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + { clientRequestId: 'request-2' }, + { clientMessageId: 'message-2' }, + ]) { + fetchMock.mockResolvedValueOnce(Response.json(updateResponse(mismatch))); + await expect(updateChatPreambleSelection(request)).rejects.toThrow( + 'Invalid chat preamble selection update response', + ); + } + }); + + it('requires an explicit preview to echo the exact requested order', async () => { + fetchMock.mockResolvedValueOnce(Response.json({ + success: true, + canonicalProjectPath: '/repo', + orderedPreambleIds: [], + projection: { catalogRevision: 1, eligiblePreambles: [], unavailable: [] }, + })); + await expect(preambleSelectionPreview({ + projectPath: '/repo', + orderedPreambleIds: [PREAMBLE_ID], + })).rejects.toThrow('Invalid preamble selection preview response'); + }); +}); diff --git a/web/src/lib/api/__tests__/preambles-contract.test.ts b/web/src/lib/api/__tests__/preambles-contract.test.ts index 2f7272d502..edd34445f8 100644 --- a/web/src/lib/api/__tests__/preambles-contract.test.ts +++ b/web/src/lib/api/__tests__/preambles-contract.test.ts @@ -13,8 +13,10 @@ vi.stubGlobal('localStorage', { removeItem: () => {}, }); +const PREAMBLE_ID = '3502b645-222b-49d2-ac39-1c91f9fb1174'; + const preamble = { - id: 'preamble-a', + id: PREAMBLE_ID, enabled: true, title: 'Repository conventions', content: 'Use the repository conventions.', @@ -57,9 +59,9 @@ describe('preambles API contract', () => { scope: { type: 'global' as const }, }; await createPreamble({ expectedRevision: 1, preamble: definition }); - await updatePreamble({ expectedRevision: 1, id: 'preamble-a', preamble: definition }); - await removePreamble({ expectedRevision: 1, id: 'preamble-a' }); - await reorderPreambles({ expectedRevision: 1, orderedPreambleIds: ['preamble-a'] }); + await updatePreamble({ expectedRevision: 1, id: PREAMBLE_ID, preamble: definition }); + await removePreamble({ expectedRevision: 1, id: PREAMBLE_ID }); + await reorderPreambles({ expectedRevision: 1, orderedPreambleIds: [PREAMBLE_ID] }); expect(fetchMock.mock.calls.map(([url, options]) => [url, options?.method])).toEqual([ ['/api/v1/preambles', undefined], @@ -79,7 +81,7 @@ describe('preambles API contract', () => { await expect(getPreambles()).rejects.toThrow('Invalid preambles response'); fetchMock.mockResolvedValueOnce(Response.json({ success: true, snapshot: { revision: -1 } })); - await expect(removePreamble({ expectedRevision: 1, id: 'preamble-a' })).rejects.toThrow( + await expect(removePreamble({ expectedRevision: 1, id: PREAMBLE_ID })).rejects.toThrow( 'Invalid preamble mutation response', ); }); diff --git a/web/src/lib/api/chat-preambles.ts b/web/src/lib/api/chat-preambles.ts new file mode 100644 index 0000000000..c670886247 --- /dev/null +++ b/web/src/lib/api/chat-preambles.ts @@ -0,0 +1,88 @@ +import { + parseChatPreambleSelectionTargetResponse, + parsePreambleSelectionPartialError, + parsePreambleSelectionPreviewResponse, + parseUpdateChatPreambleSelectionResponse, + type ChatPreambleSelectionTargetResponse, + type PreambleSelectionPreviewRequest, + type PreambleSelectionPreviewResponse, + type PreambleSelectionPartialErrorBody, + type UpdateChatPreambleSelectionRequest, + type UpdateChatPreambleSelectionResponse, +} from '$shared/chat-preamble-selection-contracts'; +import { ApiError, apiGet, apiPost, apiPut } from './client.js'; + +export type { + ChatPreambleSelectionTargetResponse, + PreambleSelectionPreviewRequest, + PreambleSelectionPreviewResponse, + UpdateChatPreambleSelectionRequest, + UpdateChatPreambleSelectionResponse, +}; + +/** Loads an existing chat's saved selection and its current projection. */ +export async function getChatPreambleSelection( + chatId: string, + expectedTranscriptViewId?: string, +): Promise { + const response = await apiGet(`/api/v1/chats/preambles?chatId=${encodeURIComponent(chatId)}`); + const parsed = parseChatPreambleSelectionTargetResponse(response); + if ( + !parsed + || parsed.chatId !== chatId + || (expectedTranscriptViewId !== undefined + && parsed.transcriptViewId !== expectedTranscriptViewId) + ) throw new Error('Invalid chat preamble selection response'); + return parsed; +} + +export type UpdateChatPreambleSelectionOutcome = + | { readonly kind: 'committed'; readonly response: UpdateChatPreambleSelectionResponse } + | { readonly kind: 'partial'; readonly partial: PreambleSelectionPartialErrorBody }; + +/** + * Saves an existing chat's selection. A 503 body carrying + * `selectionCommitted` is surfaced as a partial outcome rather than a thrown + * error so the editor can adopt the committed selection honestly. + */ +export async function updateChatPreambleSelection( + request: UpdateChatPreambleSelectionRequest, +): Promise { + let response: unknown; + try { + response = await apiPut('/api/v1/chats/preambles', request); + } catch (error) { + if (error instanceof ApiError) { + const partial = parsePreambleSelectionPartialError(error.payload); + if (partial) return { kind: 'partial', partial }; + } + throw error; + } + const parsed = parseUpdateChatPreambleSelectionResponse(response); + if ( + !parsed + || parsed.chatId !== request.chatId + || parsed.transcriptViewId !== request.transcriptViewId + || parsed.clientRequestId !== request.clientRequestId + || parsed.clientMessageId !== request.clientMessageId + ) throw new Error('Invalid chat preamble selection update response'); + return { kind: 'committed', response: parsed }; +} + +/** Side-effect-free new-chat selection preview; carries no bodies. */ +export async function preambleSelectionPreview( + request: PreambleSelectionPreviewRequest, +): Promise { + const response = await apiPost('/api/v1/preambles/selection-preview', request); + const parsed = parsePreambleSelectionPreviewResponse(response); + if ( + !parsed + || (request.orderedPreambleIds !== undefined + && !idsEqual(parsed.orderedPreambleIds, request.orderedPreambleIds)) + ) throw new Error('Invalid preamble selection preview response'); + return parsed; +} + +function idsEqual(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((id, index) => id === right[index]); +} diff --git a/web/src/lib/api/chats.ts b/web/src/lib/api/chats.ts index 800d4cd036..eea2a15727 100644 --- a/web/src/lib/api/chats.ts +++ b/web/src/lib/api/chats.ts @@ -138,6 +138,7 @@ export interface StartChatParams { command: string; images?: AgentCommandImage[]; tags?: string[]; + orderedPreambleIds?: readonly string[]; } export type { ChatDetailsResponse } from '$shared/chat-details'; diff --git a/web/src/lib/chat/conversation/submission-routes.ts b/web/src/lib/chat/conversation/submission-routes.ts index 98e1e9d967..e2497a7ad5 100644 --- a/web/src/lib/chat/conversation/submission-routes.ts +++ b/web/src/lib/chat/conversation/submission-routes.ts @@ -240,6 +240,7 @@ export async function submitDraftRoute( command: context.text, images: context.images.length > 0 ? context.images : undefined, tags: startup.tags, + orderedPreambleIds: startup.orderedPreambleIds, }); const composerRevisionAfterClear = beginOptimisticInput( deps, diff --git a/web/src/lib/chat/new-chat/__tests__/new-chat-form-state.test.ts b/web/src/lib/chat/new-chat/__tests__/new-chat-form-state.test.ts index b0b4158da7..8b2352f60c 100644 --- a/web/src/lib/chat/new-chat/__tests__/new-chat-form-state.test.ts +++ b/web/src/lib/chat/new-chat/__tests__/new-chat-form-state.test.ts @@ -1,6 +1,7 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { NewChatFormState } from '../new-chat-form-state.svelte'; import * as chatsApi from '$lib/api/chats'; +import * as preamblesApi from '$lib/api/chat-preambles'; import * as gitApi from '$lib/api/git'; import type { GitWorktreeItem } from '$lib/api/git'; import type { ModelOption } from '$lib/agents/model-catalog-store.svelte'; @@ -20,6 +21,10 @@ vi.mock('$lib/api/chats', () => ({ validateStart: vi.fn(), })); +vi.mock('$lib/api/chat-preambles', () => ({ + preambleSelectionPreview: vi.fn(), +})); + vi.mock('$lib/api/git', () => ({ getGitWorktrees: vi.fn(), gitCreateWorktree: vi.fn(), @@ -1283,3 +1288,135 @@ describe('NewChatFormState', () => { } }); }); + +describe('NewChatFormState preamble selection', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.mocked(chatsApi.validateStart).mockReset(); + vi.mocked(chatsApi.validateStart).mockResolvedValue({ valid: true, isGitRepo: false }); + vi.mocked(preamblesApi.preambleSelectionPreview).mockReset(); + }); + + afterEach(() => vi.useRealTimers()); + + const ID_A = '3502b645-222b-49d2-ac39-1c91f9fb1174'; + const ID_B = '80becfa6-c9c7-4b31-9190-fd23c0bedf9c'; + + function previewResponse( + ids: readonly string[], + eligible: readonly string[] = ids, + ) { + return { + success: true as const, + canonicalProjectPath: '/canonical/repo', + orderedPreambleIds: [...ids], + projection: { + catalogRevision: 2, + eligiblePreambles: eligible.map((id) => ({ id, title: `Title ${id}` })), + unavailable: [], + }, + }; + } + + it('clears the preview immediately when the path becomes empty or invalid', async () => { + const formState = new NewChatFormState({ + modelCatalog: mockModelCatalog as any, + remoteSettings: makeMockRemoteSettings() as any, + get selectableAgentIds() { + return ['claude']; + }, + }); + formState.projectPath = '/repo'; + formState.validationStatus = 'valid'; + formState.preambles.preview = previewResponse([ID_A]).projection; + + formState.projectPath = ' '; + formState.validatePath(); + expect(formState.preambles.preview).toBeNull(); + expect(formState.preambles.previewCount).toBe(0); + + formState.projectPath = '/repo'; + formState.validationStatus = 'valid'; + formState.preambles.preview = previewResponse([ID_A]).projection; + formState.validationStatus = 'invalid'; + formState.validatePath(); + expect(formState.preambles.preview).toBeNull(); + }); + + it('adopts a preview only when path and choice still match, ignoring reordered responses', async () => { + const preview = vi.fn() + .mockImplementationOnce(async () => { + await new Promise((resolve) => setTimeout(resolve, 40)); + return previewResponse([ID_A]); + }) + .mockImplementationOnce(async () => previewResponse([ID_A, ID_B])); + vi.mocked(preamblesApi.preambleSelectionPreview).mockImplementation(preview); + const formState = new NewChatFormState({ + modelCatalog: mockModelCatalog as any, + remoteSettings: makeMockRemoteSettings() as any, + get selectableAgentIds() { + return ['claude']; + }, + }); + formState.projectPath = '/repo'; + formState.validationStatus = 'valid'; + + const first = formState.validatePath(); + // A path change supersedes the in-flight request before it lands. + await vi.advanceTimersByTimeAsync(310); + formState.projectPath = '/other'; + const second = formState.validatePath(); + await vi.advanceTimersByTimeAsync(500); + void first; + void second; + + // Only the response for the current path was adopted. + expect(formState.preambles.preview?.eligiblePreambles.map((entry) => entry.id)) + .toEqual([ID_A, ID_B]); + expect(formState.preambles.canonicalProjectPath).toBe('/canonical/repo'); + expect(formState.preambles.previewCount).toBe(2); + }); + + it('drops an explicit-choice preview response that raced a reset to defaults', async () => { + const preview = vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 30)); + return previewResponse([ID_A]); + }); + vi.mocked(preamblesApi.preambleSelectionPreview).mockImplementation(preview); + const formState = new NewChatFormState({ + modelCatalog: mockModelCatalog as any, + remoteSettings: makeMockRemoteSettings() as any, + get selectableAgentIds() { + return ['claude']; + }, + }); + formState.projectPath = '/repo'; + formState.validationStatus = 'valid'; + formState.preambles.setExplicit([ID_A]); + await vi.advanceTimersByTimeAsync(320); + formState.preambles.resetToDefaults(); + await vi.advanceTimersByTimeAsync(200); + // The stale explicit response is discarded; the defaults preview wins. + expect(formState.preambles.choice.mode).toBe('defaults'); + expect(formState.preambles.orderedIds).toBeUndefined(); + }); + + it('omits orderedPreambleIds in defaults mode and sends explicit lists exactly', () => { + const formState = new NewChatFormState({ + modelCatalog: mockModelCatalog as any, + remoteSettings: makeMockRemoteSettings() as any, + get selectableAgentIds() { + return ['claude']; + }, + }); + formState.projectPath = '/repo'; + formState.validationStatus = 'valid'; + formState.settingsLoaded = true; + formState.firstMessage = 'hello'; + formState.validationStatus = 'valid'; + expect(formState.buildConfig()?.orderedPreambleIds).toBeUndefined(); + + formState.preambles.setExplicit([ID_B, ID_A]); + expect(formState.buildConfig()?.orderedPreambleIds).toEqual([ID_B, ID_A]); + }); +}); diff --git a/web/src/lib/chat/new-chat/new-chat-form-state.svelte.ts b/web/src/lib/chat/new-chat/new-chat-form-state.svelte.ts index 50e4ef2785..43b4703341 100644 --- a/web/src/lib/chat/new-chat/new-chat-form-state.svelte.ts +++ b/web/src/lib/chat/new-chat/new-chat-form-state.svelte.ts @@ -34,6 +34,7 @@ import { normalizeSupportedThinkingMode, } from '$shared/execution-defaults'; import { canSubmitNewChat, type PathValidationStatus } from '$lib/chat/new-chat/new-chat-submit.js'; +import { NewChatPreambleSelectionState } from './new-chat-preamble-selection-state.svelte.js'; import { isPinnedProjectPath, nextPinnedProjectPaths, @@ -84,6 +85,8 @@ export class NewChatFormState { chatTags = $state([]); showTagInput = $state(false); + readonly preambles: NewChatPreambleSelectionState; + // Form #firstMessage = $state(''); #contentRevision = 0; @@ -110,6 +113,7 @@ export class NewChatFormState { constructor(options: NewChatFormStateOptions) { this.#options = options; + this.preambles = new NewChatPreambleSelectionState(this); } // Derived accessors @@ -518,6 +522,8 @@ export class NewChatFormState { return; } + this.preambles.pathValidationStarted(); + this.validationStatus = 'checking'; this.validationError = null; @@ -532,16 +538,19 @@ export class NewChatFormState { this.validationStatus = 'valid'; this.validationError = null; this.gitRepoStatus = data.isGitRepo ? 'git' : 'non-git'; + void this.preambles.refreshPreview(); } else { this.validationStatus = 'invalid'; this.validationError = this.#validationErrorMessage(data.errorCode); this.gitRepoStatus = 'non-git'; + this.preambles.invalidatePreview(); } } catch (err) { if (requestVersion !== this.#validationRequestVersion) return; this.validationStatus = 'invalid'; this.validationError = m.chat_new_chat_errors_invalid_directory(); this.gitRepoStatus = 'non-git'; + this.preambles.invalidatePreview(); console.warn('[NewChatFormState] Path validation request failed', err); } }, 300); @@ -564,6 +573,7 @@ export class NewChatFormState { this.validationStatus = 'idle'; this.validationError = null; this.gitRepoStatus = 'unknown'; + this.preambles.invalidatePreview(); } // Pinned paths @@ -657,6 +667,7 @@ export class NewChatFormState { firstMessage: this.firstMessage.trim(), initialImages: this.attachedImages, tags: this.chatTags.length > 0 ? this.chatTags : undefined, + ...this.preambles.creationFields, }; } @@ -676,6 +687,7 @@ export class NewChatFormState { this.chatTags = []; this.showTagInput = false; this.#modesTouched = false; + this.preambles.reset(); } // Initialization diff --git a/web/src/lib/chat/new-chat/new-chat-preamble-selection-state.svelte.ts b/web/src/lib/chat/new-chat/new-chat-preamble-selection-state.svelte.ts new file mode 100644 index 0000000000..dc7357fe13 --- /dev/null +++ b/web/src/lib/chat/new-chat/new-chat-preamble-selection-state.svelte.ts @@ -0,0 +1,124 @@ +import { + preambleSelectionPreview, + type PreambleSelectionPreviewResponse, +} from '$lib/api/chat-preambles.js'; +import type { PathValidationStatus } from '$lib/chat/new-chat/new-chat-submit.js'; +import type { PreambleId, PreambleSelectionProjection } from '$shared/preambles'; + +export type NewChatPreambleChoice = + | { readonly mode: 'defaults' } + | { readonly mode: 'explicit'; readonly orderedPreambleIds: readonly PreambleId[] }; + +interface NewChatPreambleSelectionStateOptions { + readonly trimmedPath: string; + readonly validationStatus: PathValidationStatus; +} + +export class NewChatPreambleSelectionState { + choice = $state({ mode: 'defaults' }); + preview = $state(null); + previewLoading = $state(false); + canonicalProjectPath = $state(''); + + #previewVersion = 0; + #choiceVersion = 0; + #previewSourcePath = ''; + + constructor(private readonly options: NewChatPreambleSelectionStateOptions) {} + + get previewCount(): number { + return this.preview?.eligiblePreambles.length ?? 0; + } + + get configurable(): boolean { + return this.choice.mode === 'explicit' || this.preview !== null; + } + + get orderedIds(): readonly PreambleId[] | undefined { + return this.choice.mode === 'explicit' ? this.choice.orderedPreambleIds : undefined; + } + + get creationFields(): { orderedPreambleIds?: PreambleId[] } { + return this.choice.mode === 'explicit' + ? { orderedPreambleIds: [...this.choice.orderedPreambleIds] } + : {}; + } + + setExplicit(orderedPreambleIds: readonly PreambleId[]): void { + this.choice = { mode: 'explicit', orderedPreambleIds: [...orderedPreambleIds] }; + this.#choiceVersion += 1; + this.invalidatePreview(); + void this.refreshPreview(); + } + + resetToDefaults(): void { + this.choice = { mode: 'defaults' }; + this.#choiceVersion += 1; + this.invalidatePreview(); + void this.refreshPreview(); + } + + pathValidationStarted(): void { + const projectPath = this.options.trimmedPath; + this.#previewVersion += 1; + if ( + this.options.validationStatus === 'invalid' + || (this.#previewSourcePath !== '' && projectPath !== this.#previewSourcePath) + ) { + this.#clearPreview(); + } + } + + invalidatePreview(): void { + this.#previewVersion += 1; + this.#clearPreview(); + } + + async refreshPreview(): Promise { + const projectPath = this.options.trimmedPath; + if (!projectPath || this.options.validationStatus === 'invalid') { + this.invalidatePreview(); + return; + } + + const version = ++this.#previewVersion; + const choiceVersion = this.#choiceVersion; + this.previewLoading = true; + + try { + const preview: PreambleSelectionPreviewResponse = await preambleSelectionPreview({ + projectPath, + ...this.creationFields, + }); + if (!this.#isCurrentPreview(version, choiceVersion, projectPath)) return; + this.preview = preview.projection; + this.canonicalProjectPath = preview.canonicalProjectPath; + this.#previewSourcePath = projectPath; + } catch { + if (!this.#isCurrentPreview(version, choiceVersion, projectPath)) return; + this.#clearPreview(); + } finally { + if (version === this.#previewVersion) this.previewLoading = false; + } + } + + reset(): void { + this.choice = { mode: 'defaults' }; + this.#clearPreview(); + this.#previewVersion += 1; + this.#choiceVersion += 1; + } + + #clearPreview(): void { + this.preview = null; + this.previewLoading = false; + this.canonicalProjectPath = ''; + this.#previewSourcePath = ''; + } + + #isCurrentPreview(version: number, choiceVersion: number, projectPath: string): boolean { + return version === this.#previewVersion + && choiceVersion === this.#choiceVersion + && projectPath === this.options.trimmedPath; + } +} diff --git a/web/src/lib/components/chat/ChatSurface.svelte b/web/src/lib/components/chat/ChatSurface.svelte index 93f4b8a0f4..6186424a41 100644 --- a/web/src/lib/components/chat/ChatSurface.svelte +++ b/web/src/lib/components/chat/ChatSurface.svelte @@ -2,6 +2,7 @@ import { untrack } from 'svelte'; import { getChatSessions, + getConversationPanels, getGitViewLauncher, getModelCatalog, type WorkspaceChatActions, @@ -27,6 +28,7 @@ requestDetails() {}, requestShare() {}, requestProjectPath() {}, + configurePreambles() {}, fork() {}, reload() {}, }; @@ -60,6 +62,7 @@ } = $props(); const sessions = getChatSessions(); + const conversationPanels = getConversationPanels(); const modelCatalog = getModelCatalog(); const gitViews = getGitViewLauncher(); const transcriptCache = @@ -102,6 +105,10 @@ : false, ); const canReloadSelectedChat = $derived(selectedChat?.canReloadFromNativeHistory ?? false); + const selectedTranscriptViewId = $derived.by(() => { + const panel = conversationPanels.composerPanel; + return panel && panel.chatId === selectedChat?.id ? panel.transcript.transcriptViewId : ''; + }); $effect.pre(() => { if (conversationWorkspaceVisible) return; @@ -136,6 +143,9 @@ onDetails={() => chatActions.requestDetails(selectedChat)} onReload={() => chatActions.reload(selectedChat)} onShare={() => chatActions.requestShare(selectedChat)} + onConfigurePreambles={selectedTranscriptViewId + ? () => chatActions.configurePreambles(selectedChat, selectedTranscriptViewId) + : undefined} onProjectPath={() => chatActions.requestProjectPath(selectedChat)} onFork={() => chatActions.fork(selectedChat)} onDelete={() => chatActions.requestDelete(selectedChat)} diff --git a/web/src/lib/components/chat/NewChatDialog.svelte b/web/src/lib/components/chat/NewChatDialog.svelte index d979378fc3..d6c8119af5 100644 --- a/web/src/lib/components/chat/NewChatDialog.svelte +++ b/web/src/lib/components/chat/NewChatDialog.svelte @@ -37,6 +37,7 @@ firstMessage: config.firstMessage, initialImages: config.initialImages, tags: config.tags, + orderedPreambleIds: config.orderedPreambleIds, }, }); diff --git a/web/src/lib/components/chat/NewChatForm.svelte b/web/src/lib/components/chat/NewChatForm.svelte index bb659d5f5c..77643d8f3e 100644 --- a/web/src/lib/components/chat/NewChatForm.svelte +++ b/web/src/lib/components/chat/NewChatForm.svelte @@ -33,6 +33,7 @@ import { chatViewSurfaceId } from '$lib/workspace/surface-types.js'; import ComposerSnippetPalette from './ComposerSnippetPalette.svelte'; import AgentSettingsControls from './AgentSettingsControls.svelte'; + import NewChatPreamblePicker from '../preambles/NewChatPreamblePicker.svelte'; import ChatTagEditor from './ChatTagEditor.svelte'; import ChatTagToggleButton from './ChatTagToggleButton.svelte'; import { @@ -57,6 +58,7 @@ import FileVideo from '@lucide/svelte/icons/file-video'; import { CHAT_FILE_ATTACHMENT_MIME_TYPES } from '@garcon/common/attachments'; import X from '@lucide/svelte/icons/x'; + import { Button } from '$lib/components/ui/button'; import ComposerModelSelector from '$lib/components/model-selector/ComposerModelSelector.svelte'; import type { ModelSelectorChange, @@ -131,6 +133,7 @@ }); let isMobile = $state(false); + let preamblePickerOpen = $state(false); let pendingTextareaFocus = $state(true); let prospectiveChatId = $state(null); const allKnownTags = $derived( @@ -258,7 +261,7 @@ expansionProjectPath = projectPath; snippetExpansion.cancel(); } - form.validatePath(); + untrack(() => form.validatePath()); }); onDestroy(() => { @@ -696,6 +699,42 @@ onClose={() => (form.showTagInput = false)} /> +

+ + {m.preambles_label_with_count({ count: form.preambles.previewCount })} + + + {#if form.preambles.choice.mode === 'explicit'} + + {/if} +
+ + entry.id)} + canonicalProjectPath={form.preambles.canonicalProjectPath || form.trimmedPath} + projection={form.preambles.preview} + onClose={() => (preamblePickerOpen = false)} + onApplyExplicit={(ids) => form.preambles.setExplicit(ids)} + onResetToDefaults={() => form.preambles.resetToDefaults()} + /> + {#if displayedFormError}

{displayedFormError}

{/if} diff --git a/web/src/lib/components/chat/__tests__/ChatSurface.test.ts b/web/src/lib/components/chat/__tests__/ChatSurface.test.ts index 3b58f49d8a..6973b10040 100644 --- a/web/src/lib/components/chat/__tests__/ChatSurface.test.ts +++ b/web/src/lib/components/chat/__tests__/ChatSurface.test.ts @@ -17,6 +17,7 @@ const { sessions } = vi.hoisted(() => ({ vi.mock('$lib/context', () => ({ getChatSessions: () => sessions, getAppShell: () => ({ requestComposerFocus: vi.fn() }), + getConversationPanels: () => ({ composerPanel: null }), getModelCatalog: () => ({ supportsFork: () => true, supportsUpdateProjectPath: () => true, diff --git a/web/src/lib/components/chat/__tests__/NewChatDialog.test.ts b/web/src/lib/components/chat/__tests__/NewChatDialog.test.ts index a25fe01e82..17c2ff34e9 100644 --- a/web/src/lib/components/chat/__tests__/NewChatDialog.test.ts +++ b/web/src/lib/components/chat/__tests__/NewChatDialog.test.ts @@ -14,6 +14,15 @@ vi.mock('$lib/api/chats', () => ({ validateStart: vi.fn().mockResolvedValue({ valid: true, isGitRepo: false }), })); +vi.mock('$lib/api/chat-preambles', () => ({ + preambleSelectionPreview: vi.fn(async (request: { projectPath: string }) => ({ + success: true, + canonicalProjectPath: request.projectPath, + orderedPreambleIds: [], + projection: { catalogRevision: 0, eligiblePreambles: [], unavailable: [] }, + })), +})); + vi.mock('$lib/api/git', () => ({ getGitWorktrees: vi.fn(), gitCreateWorktree: vi.fn(), @@ -29,21 +38,20 @@ vi.mock('$lib/api/prompt-refinement', async (importOriginal) => { return { ...actual, refinePrompt: vi.fn() }; }); - vi.mock('$lib/api/snippets', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, expandSnippet: vi.fn() }; +vi.mock('$lib/api/snippets', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, expandSnippet: vi.fn() }; }); vi.mock('$lib/chat/actions/chat-navigation.js', () => ({ gotoChat: vi.fn() })); - vi.mock('$shared/client-chat-id', () => ({ - createClientChatId: vi.fn(() => '1787471053739199'), - })); +vi.mock('$shared/client-chat-id', () => ({ + createClientChatId: vi.fn(() => '1787471053739199'), +})); - vi.mock('$lib/components/prompt-editor/PromptEditor.svelte', async () => ({ - default: (await import('$lib/components/prompt-editor/__tests__/PromptEditorStub.svelte')) - .default, - })); +vi.mock('$lib/components/prompt-editor/PromptEditor.svelte', async () => ({ + default: (await import('$lib/components/prompt-editor/__tests__/PromptEditorStub.svelte')).default, +})); interface DeferredRefinement { promise: Promise; diff --git a/web/src/lib/components/chat/__tests__/NewChatForm.test.ts b/web/src/lib/components/chat/__tests__/NewChatForm.test.ts index edc5b1d422..eccface025 100644 --- a/web/src/lib/components/chat/__tests__/NewChatForm.test.ts +++ b/web/src/lib/components/chat/__tests__/NewChatForm.test.ts @@ -16,6 +16,15 @@ vi.mock('$lib/api/chats', () => ({ validateStart: vi.fn(), })); +vi.mock('$lib/api/chat-preambles', () => ({ + preambleSelectionPreview: vi.fn(async (request: { projectPath: string }) => ({ + success: true, + canonicalProjectPath: request.projectPath, + orderedPreambleIds: [], + projection: { catalogRevision: 0, eligiblePreambles: [], unavailable: [] }, + })), +})); + vi.mock('$lib/api/git', () => ({ getGitWorktrees: vi.fn(), })); diff --git a/web/src/lib/components/chat/__tests__/PreambleApplicationRow.test.ts b/web/src/lib/components/chat/__tests__/PreambleApplicationRow.test.ts index 9135360f30..04d514dc3f 100644 --- a/web/src/lib/components/chat/__tests__/PreambleApplicationRow.test.ts +++ b/web/src/lib/components/chat/__tests__/PreambleApplicationRow.test.ts @@ -2,14 +2,17 @@ import { render, screen } from '@testing-library/svelte'; import { describe, expect, it } from 'vitest'; import PreambleApplicationRow from '../rows/PreambleApplicationRow.svelte'; +const PREAMBLE_A = '3502b645-222b-49d2-ac39-1c91f9fb1174'; +const PREAMBLE_B = '80becfa6-c9c7-4b31-9190-fd23c0bedf9c'; + describe('PreambleApplicationRow', () => { it('renders immutable title snapshots as wrapping pills without catalog content', () => { const { container } = render(PreambleApplicationRow, { detail: { type: 'preamble-application', preambles: [ - { id: 'preamble-a', title: 'Repository conventions' }, - { id: 'preamble-b', title: 'Security constraints' }, + { id: PREAMBLE_A, title: 'Repository conventions' }, + { id: PREAMBLE_B, title: 'Security constraints' }, ], }, }); diff --git a/web/src/lib/components/chat/__tests__/TranscriptNoticeRow.test.ts b/web/src/lib/components/chat/__tests__/TranscriptNoticeRow.test.ts index f075adf739..c17b668a62 100644 --- a/web/src/lib/components/chat/__tests__/TranscriptNoticeRow.test.ts +++ b/web/src/lib/components/chat/__tests__/TranscriptNoticeRow.test.ts @@ -6,6 +6,48 @@ import TranscriptNoticeRow from '../rows/TranscriptNoticeRow.svelte'; const AT = '2026-08-28T00:00:00.000Z'; describe('TranscriptNoticeRow', () => { + it('renders an ordered title-only preamble update notice', () => { + const { container } = render(TranscriptNoticeRow, { + message: new TranscriptNoticeMessage( + AT, + 'Preambles updated', + { + type: 'preamble-selection-changed', + preambles: [ + { + id: '3502b645-222b-49d2-ac39-1c91f9fb1174', + title: 'Security constraints', + }, + { + id: '80becfa6-c9c7-4b31-9190-fd23c0bedf9c', + title: 'Repository conventions', + }, + ], + }, + ), + }); + + expect(screen.getByText('Preambles updated')).toBeTruthy(); + expect( + [...container.querySelectorAll('[data-slot="preamble-selection-changed-title"]')].map( + (element) => element.textContent, + ), + ).toEqual(['Security constraints', 'Repository conventions']); + expect(container.textContent).not.toContain('private body'); + }); + + it('renders None enabled for an empty preamble update notice', () => { + render(TranscriptNoticeRow, { + message: new TranscriptNoticeMessage( + AT, + 'Preambles updated', + { type: 'preamble-selection-changed', preambles: [] }, + ), + }); + + expect(screen.getByText('None enabled')).toBeTruthy(); + }); + it('[TLV5-CHAT-ID-DISCOVERY.07-WEB-UNIT-01] renders chat ID discovery failures as error event cards', () => { const { container } = render(TranscriptNoticeRow, { message: new TranscriptNoticeMessage( diff --git a/web/src/lib/components/chat/rows/PreambleSelectionChangedRow.svelte b/web/src/lib/components/chat/rows/PreambleSelectionChangedRow.svelte new file mode 100644 index 0000000000..d0a66b203b --- /dev/null +++ b/web/src/lib/components/chat/rows/PreambleSelectionChangedRow.svelte @@ -0,0 +1,38 @@ + + + + {#snippet body()} +
+ + {m.preambles_updated_label()} + + {#if detail.preambles.length === 0} + + {m.preamble_selection_none_enabled()} + + {:else} + {#each detail.preambles as preamble (preamble.id)} + + + {preamble.title} + + {#snippet failed()} + … + {/snippet} + + {/each} + {/if} +
+ {/snippet} +
diff --git a/web/src/lib/components/chat/rows/TranscriptNoticeRow.svelte b/web/src/lib/components/chat/rows/TranscriptNoticeRow.svelte index 099314c2ce..967e46c60d 100644 --- a/web/src/lib/components/chat/rows/TranscriptNoticeRow.svelte +++ b/web/src/lib/components/chat/rows/TranscriptNoticeRow.svelte @@ -9,11 +9,13 @@ isInterAgentMessageOutcomeNoticeDetail, isInterAgentMessageReceivedNoticeDetail, isPreambleApplicationNoticeDetail, + isPreambleSelectionChangedNoticeDetail, } from '$shared/transcript-notice-details'; import ChatEventCard from './ChatEventCard.svelte'; import CollapsibleBody from './CollapsibleBody.svelte'; import InterAgentMessageRow from './InterAgentMessageRow.svelte'; import PreambleApplicationRow from './PreambleApplicationRow.svelte'; + import PreambleSelectionChangedRow from './PreambleSelectionChangedRow.svelte'; import Markdown from '../Markdown.svelte'; import type { MarkdownLinkNavigateEvent } from '../Markdown.svelte'; import type { ConversationDisclosureStatePort } from '../ConversationFeedItemState.svelte.js'; @@ -42,6 +44,9 @@ const preambleApplication = $derived( isPreambleApplicationNoticeDetail(message.detail) ? message.detail : null, ); + const preambleSelectionChanged = $derived( + isPreambleSelectionChangedNoticeDetail(message.detail) ? message.detail : null, + ); const interAgentDetail = $derived.by(() => { if (isInterAgentMessageOutcomeNoticeDetail(message.detail)) return message.detail; if (isInterAgentMessageReceivedNoticeDetail(message.detail)) return message.detail; @@ -51,6 +56,8 @@ {#if preambleApplication} +{:else if preambleSelectionChanged} + {:else if interAgentDetail} import('../settings/Settings.svelte'); const lazyScheduledPrompts = () => import('../settings/ScheduledPromptsDialog.svelte'); const lazyPreambles = () => import('../preambles/PreamblesDialog.svelte'); + const lazyChatPreambleSelection = () => import('../preambles/ChatPreambleSelectionDialog.svelte'); const lazySnippets = () => import('../snippets/SnippetsDialog.svelte'); const lazyOnboardingWizard = () => import('../onboarding/OnboardingWizard.svelte'); import { @@ -520,6 +521,9 @@ requestDetails: requestDetailsChat, requestShare: requestShareChat, requestProjectPath: requestProjectPathChat, + configurePreambles(chat: ChatSessionRecord, transcriptViewId: string): void { + appShell.openChatPreambleSelection(chat.id, transcriptViewId); + }, fork(chat: ChatSessionRecord): void { void chatActionController.forkChat(chat.id); }, @@ -811,6 +815,12 @@ {/await} {/if} +{#if appShell.chatPreambleSelectionTarget} + {#await lazyChatPreambleSelection() then { default: ChatPreambleSelectionDialog }} + + {/await} +{/if} + {#if appShell.showSnippets} {#await lazySnippets() then { default: SnippetsDialog }} diff --git a/web/src/lib/components/layout/CurrentChatMenu.svelte b/web/src/lib/components/layout/CurrentChatMenu.svelte index 9440c2471e..24812989f8 100644 --- a/web/src/lib/components/layout/CurrentChatMenu.svelte +++ b/web/src/lib/components/layout/CurrentChatMenu.svelte @@ -25,6 +25,7 @@ onOpenUserMessageNavigator?: () => void; onOpenGitHistory?: () => void; onOpenGitCompare?: () => void; + onConfigurePreambles?: () => void; onRename: () => void; onDetails: () => void; onReload: () => void; @@ -45,6 +46,7 @@ onOpenUserMessageNavigator, onOpenGitHistory, onOpenGitCompare, + onConfigurePreambles, onRename, onDetails, onReload, @@ -76,6 +78,7 @@ {onOpenUserMessageNavigator} {onOpenGitHistory} {onOpenGitCompare} + {onConfigurePreambles} {onRename} {onDetails} {onReload} diff --git a/web/src/lib/components/layout/CurrentChatMenuItems.svelte b/web/src/lib/components/layout/CurrentChatMenuItems.svelte index b181f1064a..b2d44b48bb 100644 --- a/web/src/lib/components/layout/CurrentChatMenuItems.svelte +++ b/web/src/lib/components/layout/CurrentChatMenuItems.svelte @@ -6,6 +6,7 @@ import History from '@lucide/svelte/icons/history'; import Info from '@lucide/svelte/icons/info'; import ListIcon from '@lucide/svelte/icons/list'; + import ListPlus from '@lucide/svelte/icons/list-plus'; import RefreshCw from '@lucide/svelte/icons/refresh-cw'; import Share2 from '@lucide/svelte/icons/share-2'; import Trash2 from '@lucide/svelte/icons/trash-2'; @@ -26,6 +27,7 @@ onOpenUserMessageNavigator, onOpenGitHistory, onOpenGitCompare, + onConfigurePreambles, onRename, onDetails, onReload, @@ -43,6 +45,7 @@ onOpenUserMessageNavigator?: () => void; onOpenGitHistory?: () => void; onOpenGitCompare?: () => void; + onConfigurePreambles?: () => void; onRename: () => void; onDetails: () => void; onReload: () => void; @@ -83,6 +86,12 @@ {m.sidebar_chats_details()} +{#if onConfigurePreambles} + + + {m.sidebar_chats_configure_preambles()} + +{/if} {#if canFork} canForkNow && onFork()}> diff --git a/web/src/lib/components/preambles/ChatPreambleSelectionDialog.svelte b/web/src/lib/components/preambles/ChatPreambleSelectionDialog.svelte new file mode 100644 index 0000000000..ea836f8afd --- /dev/null +++ b/web/src/lib/components/preambles/ChatPreambleSelectionDialog.svelte @@ -0,0 +1,194 @@ + + + + + + + {m.preamble_selection_dialog_title()} + + {m.preamble_selection_next_message_hint()} + + +
+ {#if controller.status === 'loading'} +

+ {m.preamble_selection_loading()} +

+ {:else if controller.status === 'error'} + + {:else if controller.status === 'idle'} +
+ {:else} + {#if controller.error} + + {/if} + {#if controller.conflict} +
+ {m.preamble_selection_conflict()} + +
+ {/if} + {#if controller.partialWarning} +

+ {controller.partialWarning.message} +

+ {/if} + {#if controller.status === 'refresh-required'} +
+ + {controller.partialWarning?.message ?? m.preamble_selection_refresh_required()} + + +
+ {/if} + controller.move(id, direction)} + onRemove={(id) => controller.remove(id)} + onAdd={(id) => controller.add(id)} + /> +
+ +
+ {/if} +
+ +
+ + +
+
+
diff --git a/web/src/lib/components/preambles/ChatPreambleSelectionPanel.svelte b/web/src/lib/components/preambles/ChatPreambleSelectionPanel.svelte new file mode 100644 index 0000000000..9dfc6c859c --- /dev/null +++ b/web/src/lib/components/preambles/ChatPreambleSelectionPanel.svelte @@ -0,0 +1,191 @@ + + +
+ {#if projected.eligibleCount === 0} +

+ {m.preamble_selection_none_enabled()} +

+ {/if} + {#each projected.rows as row, index (row.id)} + +
+ + {row.title ?? m.preamble_selection_status_missing()} + + {#if row.reason} + + {reasonLabel(row.reason)} + + {/if} + + + + + +
+ {#snippet failed()} +
+ {m.preamble_selection_row_unavailable()} +
+ {/snippet} +
+ {/each} + + + + {#if showCandidates} +
+ {#if candidates.length === 0} +

+ {m.preamble_selection_no_candidates()} +

+ {:else} + {#each candidates as candidate (candidate.id)} + +
+ + {candidate.title} + + {#if candidate.reason} + + {reasonLabel(candidate.reason)} + + {/if} + +
+ {#snippet failed()} +
+ {m.preamble_selection_row_unavailable()} +
+ {/snippet} +
+ {/each} + {/if} +
+ {/if} +
diff --git a/web/src/lib/components/preambles/NewChatPreamblePicker.svelte b/web/src/lib/components/preambles/NewChatPreamblePicker.svelte new file mode 100644 index 0000000000..79955a7b0f --- /dev/null +++ b/web/src/lib/components/preambles/NewChatPreamblePicker.svelte @@ -0,0 +1,168 @@ + + + + + + + {m.preamble_selection_dialog_title()} + + {m.preamble_selection_next_message_hint()} + + +
+ +
+ +
+ +
+ + + +
+
+
+
diff --git a/web/src/lib/components/preambles/__tests__/ChatPreambleSelectionDialog.test.ts b/web/src/lib/components/preambles/__tests__/ChatPreambleSelectionDialog.test.ts new file mode 100644 index 0000000000..9c9b4a0c31 --- /dev/null +++ b/web/src/lib/components/preambles/__tests__/ChatPreambleSelectionDialog.test.ts @@ -0,0 +1,71 @@ +import { cleanup, fireEvent, render, waitFor } from '@testing-library/svelte'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const controller = vi.hoisted(() => ({ + open: vi.fn(), + close: vi.fn(), + save: vi.fn(), +})); + +vi.mock('$lib/preambles/chat-selection-controller.svelte.js', () => ({ + ChatPreambleSelectionController: class { + status = 'ready'; + error = null; + conflict = false; + partialWarning = null; + draftIds = []; + projection = { catalogRevision: 0, eligiblePreambles: [], unavailable: [] }; + canonicalProjectPath = '/workspace/project'; + saving = false; + canSave = true; + + open(target: unknown): void { + controller.open(target); + } + + close(): void { + controller.close(); + } + + save(): Promise { + controller.save(); + return Promise.resolve(); + } + + move(): void {} + remove(): void {} + add(): void {} + refreshBase(): Promise { return Promise.resolve(); } + }, +})); + +const ChatPreambleSelectionDialogTestHost = ( + await import('./ChatPreambleSelectionDialogTestHost.svelte') +).default; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe('ChatPreambleSelectionDialog', () => { + it('uses the captured target, exposes stable slots, and closes its controller on destruction', async () => { + const rendered = render(ChatPreambleSelectionDialogTestHost); + await waitFor(() => expect(controller.open).toHaveBeenCalledWith({ + chatId: '1783725900000200', + transcriptViewId: 'view-a', + })); + const dialog = document.querySelector( + '[data-slot="chat-preamble-selection-dialog"]', + ); + expect(dialog).not.toBeNull(); + expect(document.querySelector('[data-slot="chat-preamble-selection-scroll-body"]')) + .not.toBeNull(); + + await fireEvent.keyDown(dialog!, { key: 'Enter', metaKey: true }); + expect(controller.save).toHaveBeenCalledOnce(); + + rendered.unmount(); + expect(controller.close).toHaveBeenCalledOnce(); + }); +}); diff --git a/web/src/lib/components/preambles/__tests__/ChatPreambleSelectionDialogTestHost.svelte b/web/src/lib/components/preambles/__tests__/ChatPreambleSelectionDialogTestHost.svelte new file mode 100644 index 0000000000..5ff7d08dfd --- /dev/null +++ b/web/src/lib/components/preambles/__tests__/ChatPreambleSelectionDialogTestHost.svelte @@ -0,0 +1,17 @@ + + + diff --git a/web/src/lib/components/preambles/__tests__/ChatPreambleSelectionPanel.test.ts b/web/src/lib/components/preambles/__tests__/ChatPreambleSelectionPanel.test.ts new file mode 100644 index 0000000000..39ffce93bc --- /dev/null +++ b/web/src/lib/components/preambles/__tests__/ChatPreambleSelectionPanel.test.ts @@ -0,0 +1,163 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/svelte'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { AppShellStore } from '$lib/stores/app-shell.svelte'; +import type { + Preamble, + PreambleId, + PreambleSelectionProjection, + PreamblesSnapshot, +} from '$shared/preambles'; +import ChatPreambleSelectionTestHost from './ChatPreambleSelectionTestHost.svelte'; + +const ID_ELIGIBLE: PreambleId = '3502b645-222b-49d2-ac39-1c91f9fb1174'; +const ID_DISABLED: PreambleId = '80becfa6-c9c7-4b31-9190-fd23c0bedf9c'; +const ID_SCOPED: PreambleId = '936903ad-8b98-43eb-a7d4-c17ce0dc18d8'; +const ID_MISSING: PreambleId = 'fd16ec93-5395-4edc-9a57-7808203f73c7'; + +function slot(name: string): HTMLElement { + const element = document.querySelector(`[data-slot="${name}"]`); + if (!element) throw new Error(`Missing data slot: ${name}`); + return element; +} + +function slots(name: string): HTMLElement[] { + return [...document.querySelectorAll(`[data-slot="${name}"]`)]; +} + +function preamble( + id: PreambleId, + title: string, + overrides: Partial = {}, +): Preamble { + return { + id, + enabled: true, + title, + content: `Synthetic body for ${title}`, + scope: { type: 'global' }, + createdAt: '2029-01-01T00:00:00.000Z', + updatedAt: '2029-01-01T00:00:00.000Z', + ...overrides, + }; +} + +function snapshot(): PreamblesSnapshot { + return { + revision: 4, + preambles: [ + preamble(ID_ELIGIBLE, 'Eligible conventions'), + preamble(ID_DISABLED, 'Disabled conventions', { enabled: false }), + preamble(ID_SCOPED, 'Scoped conventions', { + scope: { + type: 'project-paths', + rules: [{ projectPath: '/workspace/other', includeNested: true }], + }, + }), + ], + }; +} + +const unavailableProjection: PreambleSelectionProjection = { + catalogRevision: 4, + eligiblePreambles: [], + unavailable: [ + { id: ID_MISSING, reason: 'missing' }, + { id: ID_DISABLED, reason: 'disabled' }, + ], +}; + +afterEach(() => cleanup()); + +describe('ChatPreambleSelectionPanel', () => { + it('renders retained missing selections and counts only eligible rows', async () => { + const remove = vi.fn(); + render(ChatPreambleSelectionTestHost, { + snapshot: snapshot(), + draftIds: [ID_MISSING, ID_DISABLED], + projection: unavailableProjection, + onRemove: remove, + }); + + expect(screen.getByText('None enabled')).toBeTruthy(); + const rows = slots('chat-preamble-selection-row'); + expect(rows).toHaveLength(2); + expect(rows[0]!.querySelector('[data-slot="chat-preamble-selection-row-status"]')?.textContent) + .toBe('Deleted or unavailable'); + expect(rows[1]!.querySelector('[data-slot="chat-preamble-selection-row-status"]')?.textContent) + .toBe('Disabled globally'); + await fireEvent.click(within(rows[0]!).getByRole('button', { name: /Remove/ })); + expect(remove).toHaveBeenCalledWith(ID_MISSING); + }); + + it('labels disabled and out-of-scope candidates and prevents adding them', async () => { + render(ChatPreambleSelectionTestHost, { + snapshot: snapshot(), + draftIds: [ID_ELIGIBLE], + projection: { + catalogRevision: 4, + eligiblePreambles: [{ id: ID_ELIGIBLE, title: 'Eligible conventions' }], + unavailable: [], + }, + }); + + await fireEvent.click(slot('chat-preamble-selection-toggle-candidates')); + const candidates = slots('chat-preamble-selection-candidate'); + const disabled = candidates.find((row) => row.textContent?.includes('Disabled conventions'))!; + const scoped = candidates.find((row) => row.textContent?.includes('Scoped conventions'))!; + expect(within(disabled).getByText('Disabled globally')).toBeTruthy(); + expect(within(scoped).getByText('Outside this project')).toBeTruthy(); + expect((within(disabled).getByRole('button') as HTMLButtonElement).disabled).toBe(true); + expect((within(scoped).getByRole('button') as HTMLButtonElement).disabled).toBe(true); + }); +}); + +describe('NewChatPreamblePicker', () => { + it('uses the same gate for Ctrl+Enter and Apply', async () => { + const apply = vi.fn(); + const close = vi.fn(); + render(ChatPreambleSelectionTestHost, { + mode: 'new-chat', + snapshot: snapshot(), + draftIds: [ID_ELIGIBLE], + defaultsIds: [ID_ELIGIBLE], + onApplyExplicit: apply, + onClose: close, + }); + + const dialog = slot('new-chat-preamble-selection-dialog'); + await fireEvent.keyDown(dialog, { key: 'Enter', ctrlKey: true }); + expect(apply).toHaveBeenCalledWith([ID_ELIGIBLE]); + expect(close).toHaveBeenCalledOnce(); + }); + + it('suspends for catalog management, preserves the draft, and restores focus', async () => { + let appShell!: AppShellStore; + const close = vi.fn(); + render(ChatPreambleSelectionTestHost, { + mode: 'new-chat', + snapshot: snapshot(), + draftIds: [ID_ELIGIBLE, ID_DISABLED], + defaultsIds: [ID_ELIGIBLE, ID_DISABLED], + onClose: close, + onAppShell: (value) => { appShell = value; }, + }); + + const selectedRows = slots('chat-preamble-selection-row'); + await fireEvent.click( + within(selectedRows[0]!).getByRole('button', { name: /Remove/ }), + ); + await fireEvent.click(slot('new-chat-preamble-manage-catalog')); + expect(appShell.showPreambles).toBe(true); + expect(document.querySelector('[data-slot="new-chat-preamble-selection-dialog"]')).toBeNull(); + expect(close).not.toHaveBeenCalled(); + + appShell.closePreambles(); + await waitFor(() => { + expect(slot('new-chat-preamble-selection-dialog')).toBeTruthy(); + expect(slots('chat-preamble-selection-row')).toHaveLength(1); + expect(document.activeElement).toBe( + slot('new-chat-preamble-manage-catalog'), + ); + }); + }); +}); diff --git a/web/src/lib/components/preambles/__tests__/ChatPreambleSelectionTestHost.svelte b/web/src/lib/components/preambles/__tests__/ChatPreambleSelectionTestHost.svelte new file mode 100644 index 0000000000..0fcdb6713c --- /dev/null +++ b/web/src/lib/components/preambles/__tests__/ChatPreambleSelectionTestHost.svelte @@ -0,0 +1,82 @@ + + +{#if mode === 'panel'} + +{:else} + +{/if} diff --git a/web/src/lib/components/workspace/WorkspaceRoot.svelte b/web/src/lib/components/workspace/WorkspaceRoot.svelte index 3ccf2b72c6..e268540c14 100644 --- a/web/src/lib/components/workspace/WorkspaceRoot.svelte +++ b/web/src/lib/components/workspace/WorkspaceRoot.svelte @@ -385,6 +385,7 @@ {#snippet surfaceMenuItems(surfaceId: string, menu: MenuPrimitives)} {@const surface = snapshot.surfaces[surfaceId]} {@const chat = surface?.type === 'chat' && surface.chatId ? sessions.byId[surface.chatId] : null} + {@const panel = surface?.type === 'chat' ? conversationPanels.panel(surface.id) : null} {#if chat} {@const supportsFork = modelCatalog.supportsFork(chat.agentId)} chatActions.requestDetails(chat)} onReload={() => chatActions.reload(chat)} onShare={() => chatActions.requestShare(chat)} + onConfigurePreambles={panel?.transcript.transcriptViewId + ? () => chatActions.configurePreambles(chat, panel.transcript.transcriptViewId) + : undefined} onProjectPath={() => chatActions.requestProjectPath(chat)} onFork={() => chatActions.fork(chat)} onDelete={() => chatActions.requestDelete(chat)} diff --git a/web/src/lib/components/workspace/__tests__/WorkspaceRoot.test.ts b/web/src/lib/components/workspace/__tests__/WorkspaceRoot.test.ts index 70596fa75f..c6e9a392c6 100644 --- a/web/src/lib/components/workspace/__tests__/WorkspaceRoot.test.ts +++ b/web/src/lib/components/workspace/__tests__/WorkspaceRoot.test.ts @@ -423,6 +423,7 @@ const chatActions = { requestDetails: vi.fn(), requestShare: vi.fn(), requestProjectPath: vi.fn(), + configurePreambles: vi.fn(), fork: vi.fn(), reload: vi.fn(), }; @@ -559,6 +560,40 @@ describe('WorkspaceRoot', () => { expect(screen.getByRole('menuitem', { name: m.sidebar_tooltips_delete_chat() })).toBeTruthy(); }); + it('configures the exact chat and transcript view from a nonselected window menu', async () => { + const { layout } = installContext(); + layout.publish( + layout.revision, + reduceWorkspaceLayout(layout.snapshot, [ + { + type: 'open-chat-in-new-window', + chatId: 'chat-b', + targetWindowId: 'window-main', + edge: 'right', + newWindowId: 'window-2', + partitionId: 'partition-1', + }, + ]), + ); + renderRoot(); + await waitFor(() => { + const panel = screen + .getAllByTestId('conversation-panel') + .find((entry) => entry.dataset.chatId === 'chat-b'); + expect(panel?.dataset.transcriptViewId).toBe('view-chat-b'); + }); + + await fireEvent.contextMenu(screen.getByRole('tab', { name: 'Chat B' })); + await fireEvent.click( + await screen.findByRole('menuitem', { name: m.sidebar_chats_configure_preambles() }), + ); + + expect(chatActions.configurePreambles).toHaveBeenCalledWith( + expect.objectContaining({ id: 'chat-b' }), + 'view-chat-b', + ); + }); + it('adds terminal actions to both tab menus', async () => { const { layout, workspace, terminals } = installContext(); const terminalId = 'terminal-1'; diff --git a/web/src/lib/context/index.ts b/web/src/lib/context/index.ts index 2b09297b66..bc95cafa41 100644 --- a/web/src/lib/context/index.ts +++ b/web/src/lib/context/index.ts @@ -27,6 +27,7 @@ import type { MinuteClockStore } from '$lib/stores/minute-clock.svelte'; import type { GhCapabilityContext } from '$lib/stores/gh-capability.svelte'; import type { ScheduledPromptsStore } from '$lib/scheduling/scheduled-prompts-store.svelte'; import type { PreamblesStore } from '$lib/preambles/preambles-store.svelte'; +import type { ChatPreambleSelectionInvalidationHub } from '$lib/preambles/chat-selection-invalidation-hub.js'; import type { SnippetsStore } from '$lib/snippets/snippets-store.svelte'; import type { WorkspaceLayoutReader } from '$lib/workspace/surface-types'; import type { WorkspaceContextStore } from '$lib/workspace/workspace-context.svelte'; @@ -65,6 +66,8 @@ export const [getAppTitle, setAppTitle] = createContext(); export const [getMinuteClock, setMinuteClock] = createContext(); export const [getScheduledPrompts, setScheduledPrompts] = createContext(); export const [getPreambles, setPreambles] = createContext(); +export const [getChatPreambleSelectionInvalidationHub, setChatPreambleSelectionInvalidationHub] = + createContext(); export const [getSnippets, setSnippets] = createContext(); export const [getWorkspaceLayout, setWorkspaceLayout] = createContext(); export const [getWorkspaceContext, setWorkspaceContext] = createContext(); @@ -105,6 +108,7 @@ export interface WorkspaceChatActions { requestDetails: (chat: ChatSessionRecord) => void; requestShare: (chat: ChatSessionRecord) => void; requestProjectPath: (chat: ChatSessionRecord) => void; + configurePreambles: (chat: ChatSessionRecord, transcriptViewId: string) => void; fork: (chat: ChatSessionRecord) => void; reload: (chat: ChatSessionRecord) => void; } diff --git a/web/src/lib/events/preambles-router.svelte.ts b/web/src/lib/events/preambles-router.svelte.ts index c9eee77bb2..64dc1178e8 100644 --- a/web/src/lib/events/preambles-router.svelte.ts +++ b/web/src/lib/events/preambles-router.svelte.ts @@ -1,7 +1,12 @@ import type { PreamblesStore } from '$lib/preambles/preambles-store.svelte.js'; +import type { ChatPreambleSelectionInvalidationHub } from '$lib/preambles/chat-selection-invalidation-hub.js'; import type { WsConnection } from '$lib/ws/connection.svelte.js'; import { createDrainCursor, type DrainHandle } from '$lib/ws/drain'; -import { parseServerWsMessage, PreamblesInvalidatedMessage } from '$shared/ws-events'; +import { + ChatPreamblesInvalidatedMessage, + parseServerWsMessage, + PreamblesInvalidatedMessage, +} from '$shared/ws-events'; export class PreamblesRouter { #handle: DrainHandle | null = null; @@ -9,6 +14,7 @@ export class PreamblesRouter { constructor( private readonly ws: WsConnection, private readonly preambles: Pick, + private readonly selectionInvalidations?: Pick, ) {} start(): void { @@ -20,6 +26,12 @@ export class PreamblesRouter { const parsed = parseServerWsMessage(message.data); if (parsed instanceof PreamblesInvalidatedMessage) { void this.preambles.refreshIfLoaded(); + } else if (parsed instanceof ChatPreamblesInvalidatedMessage) { + this.selectionInvalidations?.publish({ + kind: 'selection', + chatId: parsed.chatId, + revision: parsed.revision, + }); } } } diff --git a/web/src/lib/preambles/__tests__/chat-selection-controller.test.ts b/web/src/lib/preambles/__tests__/chat-selection-controller.test.ts new file mode 100644 index 0000000000..4e46aac8a0 --- /dev/null +++ b/web/src/lib/preambles/__tests__/chat-selection-controller.test.ts @@ -0,0 +1,389 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ApiError } from '$lib/api/client'; +import type { + ChatPreambleSelectionTargetResponse, + UpdateChatPreambleSelectionRequest, +} from '$shared/chat-preamble-selection-contracts'; +import type { UpdateChatPreambleSelectionOutcome } from '$lib/api/chat-preambles'; +import { + ChatPreambleSelectionController, +} from '$lib/preambles/chat-selection-controller.svelte'; +import { createChatPreambleSelectionInvalidationHub } from '$lib/preambles/chat-selection-invalidation-hub'; +import type { PreambleId, PreambleSelectionProjection } from '$shared/preambles'; + +const CHAT_ID = '1783725900000200'; +const OTHER_CHAT_ID = '1783725900000299'; +const VIEW_ID = '12345678-1234-4123-8123-123456789abc'; +const OTHER_VIEW_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const ID_A: PreambleId = '3502b645-222b-49d2-ac39-1c91f9fb1174'; +const ID_B: PreambleId = '80becfa6-c9c7-4b31-9190-fd23c0bedf9c'; + +function projection( + eligible: readonly { id: PreambleId; title: string }[] = [], + unavailable: readonly { id: PreambleId; reason: 'missing' | 'disabled' | 'out-of-scope' }[] = [], +): PreambleSelectionProjection { + return { + catalogRevision: 1, + eligiblePreambles: eligible, + unavailable, + }; +} + +function loadResponse( + ids: readonly PreambleId[], + revision: number, + chatId = CHAT_ID, + transcriptViewId = VIEW_ID, +) { + return { + success: true as const, + chatId, + transcriptViewId, + canonicalProjectPath: '/repo', + selection: { revision, orderedPreambleIds: ids }, + projection: projection( + ids.map((id) => ({ id, title: `Title ${id}` })), + ), + }; +} + +/** Seams matching the production transport ports. */ +type LoadPort = ( + chatId: string, + expectedTranscriptViewId?: string, +) => Promise; +type SavePort = ( + request: UpdateChatPreambleSelectionRequest, +) => Promise; + +function makeController(options: { load?: LoadPort; save?: SavePort } = {}) { + const hub = createChatPreambleSelectionInvalidationHub(); + let requestCounter = 0; + const controller = new ChatPreambleSelectionController({ + hub, + newRequestId: () => `request-${++requestCounter}`, + load: options.load ?? (async () => loadResponse([], 0)), + save: options.save, + }); + return { controller, hub }; +} + +describe('ChatPreambleSelectionController', () => { + it('loads the saved selection and marks the editor ready', async () => { + const load = vi.fn(async () => loadResponse([ID_A], 3)); + const { controller } = makeController({ load }); + await controller.open({ chatId: CHAT_ID, transcriptViewId: VIEW_ID }); + expect(load).toHaveBeenCalledWith(CHAT_ID, VIEW_ID); + expect(controller.status).toBe('ready'); + expect(controller.draftIds).toEqual([ID_A]); + expect(controller.dirty).toBe(false); + expect(controller.canonicalProjectPath).toBe('/repo'); + }); + + it('ignores a stale load response after retargeting', async () => { + let releaseFirst: ((value: unknown) => void) | undefined; + const first = new Promise((resolve) => { + releaseFirst = resolve; + }); + const load = vi.fn() + .mockImplementationOnce(() => first) + .mockImplementationOnce(async () => loadResponse( + [ID_B], + 1, + OTHER_CHAT_ID, + OTHER_VIEW_ID, + )); + const { controller } = makeController({ load }); + const opening = controller.open({ chatId: CHAT_ID, transcriptViewId: VIEW_ID }); + void controller.open({ chatId: OTHER_CHAT_ID, transcriptViewId: OTHER_VIEW_ID }); + releaseFirst?.(loadResponse([ID_A], 9)); + await opening; + expect(controller.chatId).toBe(OTHER_CHAT_ID); + expect(controller.draftIds).toEqual([ID_B]); + }); + + it('keeps a dirty draft and reports a conflict on external invalidation', async () => { + const { controller, hub } = makeController(); + await controller.open({ chatId: CHAT_ID, transcriptViewId: VIEW_ID }); + controller.add(ID_A); + expect(controller.dirty).toBe(true); + + hub.publish({ kind: 'selection', chatId: CHAT_ID, revision: 1 }); + expect(controller.conflict).toEqual({ revision: 1 }); + expect(controller.draftIds).toEqual([ID_A]); + expect(controller.status).toBe('ready'); + }); + + it('refreshes a clean editor on external invalidation', async () => { + let latest = loadResponse([], 0); + const load = vi.fn(async () => latest); + const { controller, hub } = makeController({ load }); + await controller.open({ chatId: CHAT_ID, transcriptViewId: VIEW_ID }); + expect(load).toHaveBeenCalledTimes(1); + + latest = loadResponse([ID_B], 2); + hub.publish({ kind: 'selection', chatId: CHAT_ID, revision: 2 }); + await vi.waitFor(() => expect(load).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(controller.baseRevision).toBe(2)); + expect(controller.draftIds).toEqual([ID_B]); + expect(controller.conflict).toBeNull(); + }); + + it('ignores stale revisions and foreign chats', async () => { + const load = vi.fn(async () => loadResponse([ID_A], 5)); + const { controller, hub } = makeController({ load }); + await controller.open({ chatId: CHAT_ID, transcriptViewId: VIEW_ID }); + hub.publish({ kind: 'selection', chatId: CHAT_ID, revision: 4 }); + hub.publish({ kind: 'selection', chatId: OTHER_CHAT_ID, revision: 9 }); + expect(controller.conflict).toBeNull(); + expect(load).toHaveBeenCalledTimes(1); + }); + + it('treats a same-revision self-invalidation as a no-op', async () => { + const load = vi.fn(async () => loadResponse([ID_A], 1)); + const { controller, hub } = makeController({ load }); + await controller.open({ chatId: CHAT_ID, transcriptViewId: VIEW_ID }); + hub.publish({ kind: 'selection', chatId: CHAT_ID, revision: 1 }); + expect(load).toHaveBeenCalledTimes(1); + expect(controller.conflict).toBeNull(); + }); + + it('adopts a committed save before reconciling a deferred invalidation', async () => { + let releaseSave: ((outcome: UpdateChatPreambleSelectionOutcome) => void) | undefined; + const save = vi.fn(() => new Promise((resolve) => { + releaseSave = resolve; + })); + const load = vi.fn(async () => loadResponse([], 0)); + const { controller, hub } = makeController({ load, save }); + await controller.open({ chatId: CHAT_ID, transcriptViewId: VIEW_ID }); + controller.add(ID_A); + const saving = controller.save(); + hub.publish({ kind: 'selection', chatId: CHAT_ID, revision: 1 }); + releaseSave?.({ + kind: 'committed', + response: { + success: true, + commandType: 'chat-preambles-update', + clientRequestId: 'request-1', + clientMessageId: 'request-2', + chatId: CHAT_ID, + transcriptViewId: VIEW_ID, + status: 'updated', + mutationRevision: 1, + noticeOrdinal: 4, + selection: { revision: 1, orderedPreambleIds: [ID_A] }, + projection: projection([{ id: ID_A, title: `Title ${ID_A}` }]), + }, + }); + await expect(saving).resolves.toBe(true); + expect(controller.baseRevision).toBe(1); + expect(controller.conflict).toBeNull(); + expect(load).toHaveBeenCalledTimes(1); + }); + + it('refreshes after a higher revision arrives during a committed save', async () => { + let releaseSave: ((outcome: UpdateChatPreambleSelectionOutcome) => void) | undefined; + const save = vi.fn(() => new Promise((resolve) => { + releaseSave = resolve; + })); + const load = vi.fn() + .mockResolvedValueOnce(loadResponse([], 0)) + .mockResolvedValueOnce(loadResponse([ID_B], 2)); + const { controller, hub } = makeController({ load, save }); + await controller.open({ chatId: CHAT_ID, transcriptViewId: VIEW_ID }); + controller.add(ID_A); + const saving = controller.save(); + hub.publish({ kind: 'selection', chatId: CHAT_ID, revision: 2 }); + releaseSave?.({ + kind: 'committed', + response: { + success: true, + commandType: 'chat-preambles-update', + clientRequestId: 'request-1', + clientMessageId: 'request-2', + chatId: CHAT_ID, + transcriptViewId: VIEW_ID, + status: 'updated', + mutationRevision: 1, + noticeOrdinal: 4, + selection: { revision: 1, orderedPreambleIds: [ID_A] }, + projection: projection([{ id: ID_A, title: `Title ${ID_A}` }]), + }, + }); + await saving; + await vi.waitFor(() => expect(controller.baseRevision).toBe(2)); + expect(controller.draftIds).toEqual([ID_B]); + }); + + it('refreshes on reconnect while preserving a dirty draft', async () => { + const load = vi.fn() + .mockResolvedValueOnce(loadResponse([], 0)) + .mockResolvedValueOnce(loadResponse([ID_B], 1)); + const { controller, hub } = makeController({ load }); + await controller.open({ chatId: CHAT_ID, transcriptViewId: VIEW_ID }); + controller.add(ID_A); + hub.publishReconnect(); + await vi.waitFor(() => expect(controller.baseRevision).toBe(1)); + expect(controller.draftIds).toEqual([ID_A]); + expect(controller.conflict).toEqual({ revision: 1 }); + }); + + it('retains save identity and payload across a transport-ambiguous retry', async () => { + const save = vi.fn() + .mockImplementationOnce(async () => { + throw new Error('network dropped'); + }) + .mockImplementationOnce(async () => ({ + kind: 'committed' as const, + response: { + success: true as const, + commandType: 'chat-preambles-update' as const, + clientRequestId: 'request-1', + clientMessageId: 'request-2', + chatId: CHAT_ID, + transcriptViewId: VIEW_ID, + status: 'duplicate' as const, + mutationRevision: 1, + noticeOrdinal: 4, + selection: { revision: 1, orderedPreambleIds: [ID_A] }, + projection: projection([{ id: ID_A, title: `Title ${ID_A}` }]), + }, + })); + const { controller } = makeController({ save }); + await controller.open({ chatId: CHAT_ID, transcriptViewId: VIEW_ID }); + controller.add(ID_A); + + await expect(controller.save()).resolves.toBe(false); + expect(controller.status).toBe('ready'); + expect(controller.error).toBe('network dropped'); + await expect(controller.save()).resolves.toBe(true); + + const first = save.mock.calls[0]?.[0] as UpdateChatPreambleSelectionRequest; + const second = save.mock.calls[1]?.[0] as UpdateChatPreambleSelectionRequest; + expect(second.clientRequestId).toBe(first.clientRequestId); + expect(second.clientMessageId).toBe(first.clientMessageId); + expect(second.orderedPreambleIds).toEqual(first.orderedPreambleIds); + expect(second.expectedRevision).toBe(first.expectedRevision); + }); + + it('starts a new intent when the draft changes after a failed attempt', async () => { + const save = vi.fn(async () => { + throw new Error('network dropped'); + }); + const { controller } = makeController({ save }); + await controller.open({ chatId: CHAT_ID, transcriptViewId: VIEW_ID }); + controller.add(ID_A); + await expect(controller.save()).resolves.toBe(false); + controller.add(ID_B); + await expect(controller.save()).resolves.toBe(false); + const secondRequest = save.mock.calls[1]?.[0] as UpdateChatPreambleSelectionRequest; + const firstRequest = save.mock.calls[0]?.[0] as UpdateChatPreambleSelectionRequest; + expect(secondRequest.clientRequestId).not.toBe(firstRequest.clientRequestId); + }); + + it('adopts a committed selection after a notice failure and stays editable', async () => { + const save = vi.fn(async () => ({ + kind: 'partial' as const, + partial: { + success: false as const, + errorCode: 'PREAMBLE_SELECTION_NOTICE_FAILED' as const, + message: 'The selection was saved, but its transcript notice could not be recorded.', + retryable: false, + selectionCommitted: true as const, + selection: { revision: 1, orderedPreambleIds: [ID_A] }, + }, + })); + const { controller } = makeController({ save }); + await controller.open({ chatId: CHAT_ID, transcriptViewId: VIEW_ID }); + controller.add(ID_A); + await expect(controller.save()).resolves.toBe(false); + expect(controller.status).toBe('ready'); + expect(controller.partialWarning).toMatchObject({ committed: true }); + expect(controller.baseRevision).toBe(1); + expect(controller.draftIds).toEqual([ID_A]); + }); + + it('gates editing after unknown durability and recovers through refresh', async () => { + let saveOutcome: unknown = { + kind: 'partial', + partial: { + success: false, + errorCode: 'PREAMBLE_SELECTION_SAVE_UNKNOWN', + message: 'The selection was saved, but its durability could not be confirmed.', + retryable: false, + selectionCommitted: 'unknown', + }, + }; + const save = vi.fn(async () => saveOutcome as UpdateChatPreambleSelectionOutcome); + let failReconcile = false; + let reconciledOnce = false; + const load = vi.fn(async () => { + if (failReconcile) throw new Error('reconciliation unavailable'); + // Until reconciliation succeeds, the server keeps reporting the + // original saved state; afterwards it reflects the committed one. + if (!reconciledOnce) return loadResponse([], 0); + return loadResponse([ID_A], 1); + }); + const { controller } = makeController({ load, save }); + await controller.open({ chatId: CHAT_ID, transcriptViewId: VIEW_ID }); + controller.add(ID_A); + + await expect(controller.save()).resolves.toBe(false); + expect(controller.status).toBe('refresh-required'); + expect(controller.canSave).toBe(false); + // A dirty draft is preserved through the refresh-gated state. + expect(controller.draftIds).toEqual([ID_A]); + + // Reconciliation still failing keeps the gate closed. + failReconcile = true; + await expect(controller.refreshBase()).resolves.toBe(false); + expect(controller.status).toBe('refresh-required'); + failReconcile = false; + + saveOutcome = { + kind: 'committed', + response: { + success: true, + commandType: 'chat-preambles-update', + clientRequestId: 'request-3', + clientMessageId: 'request-4', + chatId: CHAT_ID, + transcriptViewId: VIEW_ID, + status: 'updated', + mutationRevision: 2, + noticeOrdinal: 5, + selection: { revision: 2, orderedPreambleIds: [ID_A] }, + projection: projection([{ id: ID_A, title: `Title ${ID_A}` }]), + }, + }; + reconciledOnce = true; + await expect(controller.refreshBase()).resolves.toBe(true); + expect(controller.status).toBe('ready'); + expect(controller.baseRevision).toBe(1); + // The pending identity from the ambiguous attempt is cleared by the + // reconciled base; a new edit saves against the reconciled revision. + controller.add(ID_B); + await expect(controller.save()).resolves.toBe(true); + expect(save.mock.calls[1]![0].expectedRevision).toBe(1); + const secondRequest = save.mock.calls[1]?.[0] as UpdateChatPreambleSelectionRequest; + const firstRequest = save.mock.calls[0]?.[0] as UpdateChatPreambleSelectionRequest; + expect(secondRequest.clientRequestId).not.toBe(firstRequest.clientRequestId); + }); + + it('maps revision and idempotency conflicts to a rebasable conflict state', async () => { + const save = vi.fn(async () => { + throw new ApiError(409, 'changed', 'PREAMBLE_SELECTION_REVISION_CONFLICT'); + }); + const { controller } = makeController({ save }); + await controller.open({ chatId: CHAT_ID, transcriptViewId: VIEW_ID }); + controller.add(ID_A); + await expect(controller.save()).resolves.toBe(false); + expect(controller.status).toBe('ready'); + expect(controller.error).toBeNull(); + expect(controller.conflict).toEqual({ revision: 0 }); + // Refresh preserves the dirty draft while rebasing the revision. + await expect(controller.refreshBase()).resolves.toBe(true); + expect(controller.draftIds).toEqual([ID_A]); + expect(controller.dirty).toBe(true); + }); +}); diff --git a/web/src/lib/preambles/__tests__/chat-selection-invalidation-hub.test.ts b/web/src/lib/preambles/__tests__/chat-selection-invalidation-hub.test.ts new file mode 100644 index 0000000000..2401f09e9b --- /dev/null +++ b/web/src/lib/preambles/__tests__/chat-selection-invalidation-hub.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createChatPreambleSelectionInvalidationHub } from '$lib/preambles/chat-selection-invalidation-hub.js'; + +describe('ChatPreambleSelectionInvalidationHub', () => { + it('dispatches to a snapshot so a resubscribing listener is not revisited', () => { + const hub = createChatPreambleSelectionInvalidationHub(); + const calls: string[] = []; + let subscribeCount = 0; + + const subscribe = () => { + subscribeCount += 1; + const label = `listener-${subscribeCount}`; + hub.subscribe((event) => { + if (event.kind !== 'selection') return; + calls.push(`${label}:${event.revision}`); + if (label === 'listener-1') { + // A clean editor refresh: unsubscribe and resubscribe + // synchronously while the dispatch is running. + hub.subscribe((next) => { + if (next.kind === 'selection') calls.push(`listener-2:${next.revision}`); + }); + } + }); + }; + subscribe(); + + hub.publish({ kind: 'selection', chatId: 'chat-1', revision: 1 }); + // Only the original listener observes this dispatch; the replacement + // waits for the next event instead of being visited in the same loop. + expect(calls).toEqual(['listener-1:1']); + + hub.publish({ kind: 'selection', chatId: 'chat-1', revision: 2 }); + expect(calls).toEqual(['listener-1:1', 'listener-1:2', 'listener-2:2']); + }); + + it('supports external observers alongside editors', () => { + const hub = createChatPreambleSelectionInvalidationHub(); + const external = vi.fn(); + hub.subscribe(external); + hub.publish({ kind: 'selection', chatId: 'chat-2', revision: 5 }); + expect(external).toHaveBeenCalledWith({ + kind: 'selection', + chatId: 'chat-2', + revision: 5, + }); + }); + + it('publishes reconnect as an explicit event without a fake chat ID', () => { + const hub = createChatPreambleSelectionInvalidationHub(); + const listener = vi.fn(); + hub.subscribe(listener); + hub.publishReconnect(); + expect(listener).toHaveBeenCalledWith({ kind: 'reconnect' }); + }); +}); diff --git a/web/src/lib/preambles/__tests__/preamble-filter.logic.test.ts b/web/src/lib/preambles/__tests__/preamble-filter.logic.test.ts index 988bee718b..98c4892d9b 100644 --- a/web/src/lib/preambles/__tests__/preamble-filter.logic.test.ts +++ b/web/src/lib/preambles/__tests__/preamble-filter.logic.test.ts @@ -2,9 +2,12 @@ import { describe, expect, it } from 'vitest'; import { filterPreambles, matchesPreambleFilter } from '../preamble-filter'; import type { Preamble } from '$shared/preambles'; +const PREAMBLE_A = '3502b645-222b-49d2-ac39-1c91f9fb1174'; +const PREAMBLE_B = '80becfa6-c9c7-4b31-9190-fd23c0bedf9c'; + function preamble(overrides: Partial = {}): Preamble { return { - id: 'preamble-a', + id: PREAMBLE_A, enabled: true, title: 'Repository conventions', content: 'Run focused checks first.', @@ -29,10 +32,13 @@ describe('preamble filter', () => { it('preserves catalog order and treats whitespace as no filter', () => { const items = [ - preamble({ id: 'first', title: 'Shared title' }), - preamble({ id: 'second', title: 'Shared title' }), + preamble({ id: PREAMBLE_A, title: 'Shared title' }), + preamble({ id: PREAMBLE_B, title: 'Shared title' }), ]; - expect(filterPreambles(items, 'shared').map((item) => item.id)).toEqual(['first', 'second']); + expect(filterPreambles(items, 'shared').map((item) => item.id)).toEqual([ + PREAMBLE_A, + PREAMBLE_B, + ]); expect(filterPreambles(items, ' ')).toEqual(items); }); diff --git a/web/src/lib/preambles/__tests__/selection-projection.logic.test.ts b/web/src/lib/preambles/__tests__/selection-projection.logic.test.ts new file mode 100644 index 0000000000..f3cd9d6428 --- /dev/null +++ b/web/src/lib/preambles/__tests__/selection-projection.logic.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest'; +import { + candidateUnavailableReason, + projectDraftSelection, +} from '$lib/preambles/selection-projection'; +import type { Preamble, PreambleId } from '$shared/preambles'; + +const ID_A: PreambleId = '3502b645-222b-49d2-ac39-1c91f9fb1174'; +const ID_B: PreambleId = '80becfa6-c9c7-4b31-9190-fd23c0bedf9c'; +const ID_MISSING: PreambleId = '936903ad-8b98-43eb-a7d4-c17ce0dc18d8'; + +function preamble(id: PreambleId, overrides: Partial = {}): Preamble { + return { + id, + enabled: true, + title: `Title ${id}`, + content: `Body ${id}`, + scope: { type: 'global' }, + createdAt: '2029-01-01T00:00:00.000Z', + updatedAt: '2029-01-01T00:00:00.000Z', + ...overrides, + }; +} + +describe('projectDraftSelection', () => { + it('keeps missing IDs in order and labels each unavailable reason', () => { + const projected = projectDraftSelection({ + draftIds: [ID_MISSING, ID_A, ID_B], + savedProjection: null, + catalog: { + preambles: [ + preamble(ID_A), + preamble(ID_B, { enabled: false }), + ], + }, + canonicalProjectPath: '/repo', + }); + expect(projected.rows.map((row) => [row.id, row.reason])).toEqual([ + [ID_MISSING, 'missing'], + [ID_A, null], + [ID_B, 'disabled'], + ]); + expect(projected.eligibleCount).toBe(1); + }); + + it('reports out-of-scope rows for non-matching project paths', () => { + const scoped = preamble(ID_A, { + scope: { + type: 'project-paths', + rules: [{ projectPath: '/work/project', includeNested: true }], + }, + }); + const projected = projectDraftSelection({ + draftIds: [ID_A], + savedProjection: null, + catalog: { preambles: [scoped] }, + canonicalProjectPath: '/work/other', + }); + expect(projected.rows[0]!.reason).toBe('out-of-scope'); + expect(projected.eligibleCount).toBe(0); + }); + + it('matches exact and nested scopes without sibling-prefix confusion', () => { + const exact = preamble(ID_A, { + scope: { + type: 'project-paths', + rules: [{ projectPath: '/work/project', includeNested: false }], + }, + }); + const nested = preamble(ID_B, { + scope: { + type: 'project-paths', + rules: [{ projectPath: '/work/project', includeNested: true }], + }, + }); + const inside = projectDraftSelection({ + draftIds: [ID_A, ID_B], + savedProjection: null, + catalog: { preambles: [exact, nested] }, + canonicalProjectPath: '/work/project/child', + }); + expect(inside.rows.map((row) => row.reason)).toEqual(['out-of-scope', null]); + + const sibling = projectDraftSelection({ + draftIds: [ID_A, ID_B], + savedProjection: null, + catalog: { preambles: [exact, nested] }, + canonicalProjectPath: '/work/project-other', + }); + expect(sibling.rows.map((row) => row.reason)).toEqual(['out-of-scope', 'out-of-scope']); + }); + + it('matches normalized Windows paths without crossing drives or sibling prefixes', () => { + const scoped = preamble(ID_A, { + scope: { + type: 'project-paths', + rules: [{ projectPath: 'C:\\work\\project\\', includeNested: true }], + }, + }); + for (const [projectPath, reason] of [ + ['c:/work/project', null], + ['C:\\work\\project\\child', null], + ['c:/work/project-other', 'out-of-scope'], + ['d:/work/project/child', 'out-of-scope'], + ] as const) { + const projected = projectDraftSelection({ + draftIds: [ID_A], + savedProjection: null, + catalog: { preambles: [scoped] }, + canonicalProjectPath: projectPath, + }); + expect(projected.rows[0]!.reason).toBe(reason); + } + }); + + it('prefers the current catalog title after an eligible preamble is renamed', () => { + const projected = projectDraftSelection({ + draftIds: [ID_A], + savedProjection: { + catalogRevision: 3, + eligiblePreambles: [{ id: ID_A, title: 'Saved title' }], + unavailable: [], + }, + catalog: { preambles: [preamble(ID_A)] }, + canonicalProjectPath: '/repo', + }); + expect(projected.rows[0]!.title).toBe(`Title ${ID_A}`); + }); +}); + +describe('candidateUnavailableReason', () => { + it('distinguishes disabled and out-of-scope candidates', () => { + const scoped = preamble(ID_A, { + scope: { + type: 'project-paths', + rules: [{ projectPath: '/repo', includeNested: true }], + }, + }); + expect(candidateUnavailableReason(scoped, '/repo/child')).toBeNull(); + expect(candidateUnavailableReason(scoped, '/other')).toBe('out-of-scope'); + expect( + candidateUnavailableReason(preamble(ID_B, { enabled: false }), '/repo'), + ).toBe('disabled'); + }); +}); diff --git a/web/src/lib/preambles/chat-selection-controller.svelte.ts b/web/src/lib/preambles/chat-selection-controller.svelte.ts new file mode 100644 index 0000000000..de6982c75f --- /dev/null +++ b/web/src/lib/preambles/chat-selection-controller.svelte.ts @@ -0,0 +1,428 @@ +import { ApiError } from '$lib/api/client.js'; +import { + getChatPreambleSelection, + updateChatPreambleSelection, + type UpdateChatPreambleSelectionOutcome, +} from '$lib/api/chat-preambles.js'; +import type { + ChatPreambleSelectionInvalidationHub, + ChatPreambleSelectionHubEvent, +} from '$lib/preambles/chat-selection-invalidation-hub.js'; +import type { + ChatPreambleSelection, + PreambleId, + PreambleSelectionProjection, +} from '$shared/preambles'; + +export interface ChatPreambleSelectionTarget { + readonly chatId: string; + readonly transcriptViewId: string; +} + +export type ChatPreambleSelectionEditorStatus = + | 'idle' + | 'loading' + | 'ready' + | 'saving' + | 'refresh-required' + | 'error'; + +export interface ChatPreambleSelectionSavePartialWarning { + readonly committed: true | 'unknown'; + readonly message: string; +} + +/** Signals that the saved base moved under a dirty draft. */ +export interface ChatPreambleSelectionConflict { + readonly revision: number; +} + +/** One logical save operation, retained verbatim across ambiguous retries. */ +interface PendingSave { + readonly clientRequestId: string; + readonly clientMessageId: string; + readonly expectedRevision: number; + readonly orderedPreambleIds: readonly PreambleId[]; +} + +export interface ChatPreambleSelectionControllerDeps { + readonly hub: ChatPreambleSelectionInvalidationHub; + readonly newRequestId?: () => string; + readonly load?: typeof getChatPreambleSelection; + readonly save?: typeof updateChatPreambleSelection; +} + +export class ChatPreambleSelectionController { + status = $state('idle'); + error = $state(null); + projection = $state(null); + baseRevision = $state(0); + baseTranscriptViewId = $state(''); + canonicalProjectPath = $state(''); + draftIds = $state([]); + partialWarning = $state(null); + conflict = $state(null); + #target: ChatPreambleSelectionTarget | null = null; + #unsubscribe: (() => void) | null = null; + #loadVersion = 0; + #saveVersion = 0; + #baseIds: PreambleId[] = []; + #pendingSave: PendingSave | null = null; + // Highest invalidation revision observed while a Save was in flight; a + // second client can commit during the HTTP round trip. + #deferredInvalidation: number | null = null; + #deferredReconnect = false; + + readonly #deps: ChatPreambleSelectionControllerDeps; + + constructor(deps: ChatPreambleSelectionControllerDeps) { + this.#deps = deps; + } + + get target(): ChatPreambleSelectionTarget | null { + return this.#target; + } + + get chatId(): string | null { + return this.#target?.chatId ?? null; + } + + get dirty(): boolean { + if (!this.#target) return false; + return !idsEqual(this.#baseIds, this.draftIds); + } + + get saving(): boolean { + return this.status === 'saving'; + } + + get canSave(): boolean { + // A refresh-required editor stays read-gated until reconciliation + // confirms the committed server state. + return this.status === 'ready' && this.dirty; + } + + /** Opens the editor for a captured target; captures before any await. */ + async open(target: ChatPreambleSelectionTarget): Promise { + this.#teardown(); + this.#target = target; + this.status = 'loading'; + this.error = null; + this.partialWarning = null; + this.conflict = null; + this.draftIds = []; + this.#baseIds = []; + this.baseRevision = 0; + this.baseTranscriptViewId = target.transcriptViewId; + this.projection = null; + this.#pendingSave = null; + this.#subscribe(target.chatId); + await this.#load(target); + } + + /** + * Reloads the base from the server. A dirty draft is preserved; the base is + * rebased so a subsequent Save carries a fresh revision. This is the only + * path out of `refresh-required` after a durability-unknown commit. + */ + async refreshBase(): Promise { + return this.#refreshBase(false); + } + + async #refreshBase(markDirtyConflict: boolean): Promise { + const target = this.#target; + if (!target) return false; + // A captured view identity is never retargeted: if the server moved to + // another view, the editor surfaces the conflict instead of adopting it. + const version = ++this.#loadVersion; + const previousStatus = this.status; + this.status = 'loading'; + // This read satisfies any reconnect already observed. A reconnect that + // arrives while the read is in flight sets the flag again. + this.#deferredReconnect = false; + try { + const wasDirty = this.dirty; + const snapshot = await this.#loadNow(target); + if (this.#loadVersion !== version) return false; + // Rebase the base while preserving an in-progress dirty draft. + this.#adopt(snapshot.transcriptViewId, snapshot.selection, snapshot.projection, true); + this.canonicalProjectPath = snapshot.canonicalProjectPath; + this.partialWarning = null; + this.conflict = markDirtyConflict && wasDirty && this.dirty + ? { revision: snapshot.selection.revision } + : null; + // The server has definitively resolved any prior ambiguous save. + this.#pendingSave = null; + this.status = 'ready'; + this.#reconcileDeferredInvalidation(); + return true; + } catch (error) { + if (this.#loadVersion !== version) return false; + this.status = previousStatus; + this.error = errorMessage(error); + return false; + } + } + + close(): void { + this.#teardown(); + this.#target = null; + this.status = 'idle'; + this.error = null; + this.partialWarning = null; + this.conflict = null; + this.projection = null; + this.draftIds = []; + this.#baseIds = []; + this.#pendingSave = null; + this.#deferredInvalidation = null; + this.#deferredReconnect = false; + } + + #teardown(): void { + this.#unsubscribe?.(); + this.#unsubscribe = null; + this.#loadVersion += 1; + this.#saveVersion += 1; + } + + #subscribe(chatId: string): void { + this.#unsubscribe = this.#deps.hub.subscribe((event) => this.#onInvalidated(event, chatId)); + } + + // Clients accept only nondecreasing selection revisions. A clean editor + // refreshes; a dirty editor keeps its draft and shows a conflict. An + // invalidation that lands mid-Save is retained and reconciled after the + // save settles so the editor can never stay on a superseded revision. + #onInvalidated(event: ChatPreambleSelectionHubEvent, chatId: string): void { + if (event.kind === 'reconnect') { + this.#refreshAfterReconnect(); + return; + } + if (event.chatId !== chatId || !this.#target) return; + if (event.revision <= this.baseRevision) return; + if (this.saving) { + this.#deferredInvalidation = Math.max( + this.#deferredInvalidation ?? 0, + event.revision, + ); + return; + } + if (this.status === 'loading' || this.status === 'refresh-required') return; + if (this.dirty) { + this.conflict = { revision: event.revision }; + return; + } + void this.open(this.#target); + } + + // Reconnect refresh: reload the base for an already-open editor without + // overwriting a dirty draft. + #refreshAfterReconnect(): void { + if (!this.#target) return; + if (this.saving || this.status === 'loading') { + this.#deferredReconnect = true; + return; + } + if (this.status !== 'ready' && this.status !== 'refresh-required' && this.status !== 'error') { + return; + } + void this.#refreshBase(true); + } + + #reconcileDeferredInvalidation(): void { + const reconnect = this.#deferredReconnect; + this.#deferredReconnect = false; + const deferred = this.#deferredInvalidation; + this.#deferredInvalidation = null; + if (!this.#target) return; + if (reconnect) { + void this.#refreshBase(true); + return; + } + if (deferred === null) return; + if (deferred <= this.baseRevision) return; + if (this.dirty) { + this.conflict = { revision: deferred }; + return; + } + void this.refreshBase(); + } + + async #load(target: ChatPreambleSelectionTarget): Promise { + const version = this.#loadVersion; + try { + const snapshot = await this.#loadNow(target); + if (this.#loadVersion !== version) return; + this.#adopt(snapshot.transcriptViewId, snapshot.selection, snapshot.projection); + this.canonicalProjectPath = snapshot.canonicalProjectPath; + this.status = 'ready'; + this.#reconcileDeferredInvalidation(); + } catch (error) { + if (this.#loadVersion !== version) return; + this.status = 'error'; + this.error = errorMessage(error); + } + } + + async #loadNow(target: ChatPreambleSelectionTarget) { + const load = this.#deps.load ?? getChatPreambleSelection; + const response = await load(target.chatId, target.transcriptViewId); + if ( + response.chatId !== target.chatId + || response.transcriptViewId !== target.transcriptViewId + ) throw new Error('Chat transcript changed while loading preambles'); + return response; + } + + #adopt( + transcriptViewId: string, + selection: ChatPreambleSelection, + projection: PreambleSelectionProjection, + preserveDraft = false, + ): void { + const preserveCurrentDraft = preserveDraft && this.dirty; + this.baseTranscriptViewId = transcriptViewId; + this.baseRevision = selection.revision; + this.#baseIds = [...selection.orderedPreambleIds]; + if (!preserveCurrentDraft) { + this.draftIds = [...selection.orderedPreambleIds]; + } + this.projection = projection; + this.error = null; + this.conflict = null; + } + + move(id: PreambleId, direction: 'up' | 'down'): void { + const index = this.draftIds.indexOf(id); + const target = direction === 'up' ? index - 1 : index + 1; + if (index < 0 || target < 0 || target >= this.draftIds.length) return; + const next = [...this.draftIds]; + [next[index], next[target]] = [next[target], next[index]]; + this.draftIds = next; + } + + remove(id: PreambleId): void { + this.draftIds = this.draftIds.filter((entry) => entry !== id); + } + + add(id: PreambleId): void { + if (this.draftIds.includes(id)) return; + this.draftIds = [...this.draftIds, id]; + } + + setDraft(ids: readonly PreambleId[]): void { + this.draftIds = [...ids]; + } + + resetDraft(): void { + this.draftIds = [...this.#baseIds]; + this.conflict = null; + } + + // A pending save's identity and payload survive transport-ambiguous + // retries; any draft edit after a non-terminal attempt starts a new intent. + #pendingSaveForCurrentDraft(): PendingSave { + if ( + this.#pendingSave + && this.#pendingSave.expectedRevision === this.baseRevision + && idsEqual(this.#pendingSave.orderedPreambleIds, this.draftIds) + ) { + return this.#pendingSave; + } + this.#pendingSave = { + clientRequestId: this.#newRequestId(), + clientMessageId: this.#newRequestId(), + expectedRevision: this.baseRevision, + orderedPreambleIds: [...this.draftIds], + }; + return this.#pendingSave; + } + + #newRequestId(): string { + return this.#deps.newRequestId?.() ?? crypto.randomUUID(); + } + + /** Saves the changed selection through one gate; retains the draft on failure. */ + async save(): Promise { + const target = this.#target; + if (!target || !this.canSave) return false; + const version = ++this.#saveVersion; + this.status = 'saving'; + this.error = null; + this.conflict = null; + const pending = this.#pendingSaveForCurrentDraft(); + this.#deferredInvalidation = null; + this.#deferredReconnect = false; + let outcome: UpdateChatPreambleSelectionOutcome; + try { + const save = this.#deps.save ?? updateChatPreambleSelection; + outcome = await save({ + chatId: target.chatId, + transcriptViewId: this.baseTranscriptViewId, + clientRequestId: pending.clientRequestId, + clientMessageId: pending.clientMessageId, + expectedRevision: pending.expectedRevision, + orderedPreambleIds: [...pending.orderedPreambleIds], + }); + } catch (error) { + if (this.#saveVersion !== version) return false; + this.status = 'ready'; + this.#reconcileDeferredInvalidation(); + if (error instanceof ApiError + && (error.errorCode === 'PREAMBLE_SELECTION_REVISION_CONFLICT' + || error.errorCode === 'IDEMPOTENCY_CONFLICT')) { + this.conflict = { revision: this.baseRevision }; + } else { + this.error = errorMessage(error); + } + return false; + } + if (this.#saveVersion !== version) return false; + if (outcome.kind === 'partial') { + // The registry decision may already be committed; adopt the returned + // selection as the new base without claiming rollback. A committed + // notice stays editable; an unknown durability stays refresh-gated. + if (outcome.partial.selectionCommitted === true) { + this.status = 'ready'; + if (outcome.partial.selection) { + this.#adoptBaseOnly(outcome.partial.selection); + } + } else { + this.status = 'refresh-required'; + } + this.partialWarning = { + committed: outcome.partial.selectionCommitted, + message: outcome.partial.message, + }; + if (outcome.partial.selectionCommitted === true) { + this.#reconcileDeferredInvalidation(); + } + return false; + } + const response = outcome.response; + this.status = 'ready'; + this.partialWarning = null; + this.#pendingSave = null; + this.#adoptBaseOnly(response.selection); + this.projection = response.projection; + this.#reconcileDeferredInvalidation(); + return true; + } + + #adoptBaseOnly(selection: ChatPreambleSelection): void { + this.baseRevision = selection.revision; + this.#baseIds = [...selection.orderedPreambleIds]; + this.draftIds = [...selection.orderedPreambleIds]; + this.error = null; + this.conflict = null; + } +} + +function idsEqual(left: readonly PreambleId[], right: readonly PreambleId[]): boolean { + return left.length === right.length && left.every((id, index) => id === right[index]); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/web/src/lib/preambles/chat-selection-invalidation-hub.ts b/web/src/lib/preambles/chat-selection-invalidation-hub.ts new file mode 100644 index 0000000000..40b3cef162 --- /dev/null +++ b/web/src/lib/preambles/chat-selection-invalidation-hub.ts @@ -0,0 +1,46 @@ +// Per-chat selection invalidation hub. The WebSocket router publishes body-free +// `chat-preambles-invalidated` events here; an open selection editor subscribes +// for its captured target. No permanent all-chat cache exists. +import type { PreambleId } from '$shared/preambles'; + +export interface ChatPreambleInvalidation { + readonly kind: 'selection'; + readonly chatId: string; + readonly revision: number; +} + +export interface ChatPreambleReconnect { + readonly kind: 'reconnect'; +} + +export type ChatPreambleSelectionHubEvent = ChatPreambleInvalidation | ChatPreambleReconnect; + +type Listener = (event: ChatPreambleSelectionHubEvent) => void; + +export class ChatPreambleSelectionInvalidationHub { + #listeners = new Set(); + + publish(event: ChatPreambleSelectionHubEvent): void { + // A listener may unsubscribe or resubscribe synchronously (a clean + // editor refreshes by reopening). Iterate a snapshot so a replacement + // listener is never revisited within one dispatch. + for (const listener of [...this.#listeners]) { + listener(event); + } + } + + publishReconnect(): void { + this.publish({ kind: 'reconnect' }); + } + + subscribe(listener: Listener): () => void { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } +} + +export function createChatPreambleSelectionInvalidationHub(): ChatPreambleSelectionInvalidationHub { + return new ChatPreambleSelectionInvalidationHub(); +} + +export type { PreambleId }; diff --git a/web/src/lib/preambles/selection-projection.ts b/web/src/lib/preambles/selection-projection.ts new file mode 100644 index 0000000000..cee0cd868b --- /dev/null +++ b/web/src/lib/preambles/selection-projection.ts @@ -0,0 +1,84 @@ +// Client-side draft projection. Mirrors the server's ordered selection +// resolution closely enough to label unsaved rows: missing, disabled, and +// out-of-scope states keep their ID and position. The server projection stays +// authoritative for saved selections; this derives status for added rows. +import type { + Preamble, + PreambleId, + PreambleSelectionProjection, + PreambleSelectionUnavailableReason, + PreamblesSnapshot, +} from '$shared/preambles'; +import { isProjectPathAncestor, normalizeProjectPath } from '$lib/utils/project-path.js'; + +export interface DraftSelectionRow { + readonly id: PreambleId; + readonly title: string | null; + readonly reason: PreambleSelectionUnavailableReason | null; +} + +export interface ProjectedDraftSelection { + readonly rows: readonly DraftSelectionRow[]; + readonly eligibleCount: number; +} + +function ruleMatches(rule: { projectPath: string; includeNested: boolean }, projectPath: string) { + const rulePath = normalizeProjectPath(rule.projectPath); + const chatPath = normalizeProjectPath(projectPath); + if (!rulePath || !chatPath) return false; + if (rulePath === chatPath) return true; + return rule.includeNested && isProjectPathAncestor(rulePath, chatPath); +} + +function scopeMatches(preamble: Preamble, projectPath: string): boolean { + return preamble.scope.type === 'global' + || preamble.scope.rules.some((rule) => ruleMatches(rule, projectPath)); +} + +export function projectDraftSelection(input: { + readonly draftIds: readonly PreambleId[]; + readonly savedProjection: PreambleSelectionProjection | null; + readonly catalog: Pick; + readonly canonicalProjectPath: string; +}): ProjectedDraftSelection { + const byId = new Map(input.catalog.preambles.map((preamble) => [preamble.id, preamble])); + const savedEligible = new Map( + (input.savedProjection?.eligiblePreambles ?? []).map((entry) => [entry.id, entry.title]), + ); + const savedUnavailable = new Map( + (input.savedProjection?.unavailable ?? []).map((entry) => [entry.id, entry.reason]), + ); + const rows: DraftSelectionRow[] = []; + for (const id of input.draftIds) { + const preamble = byId.get(id); + if (!preamble) { + rows.push({ + id, + title: savedEligible.get(id) ?? null, + reason: savedUnavailable.get(id) ?? 'missing', + }); + continue; + } + if (!preamble.enabled) { + rows.push({ id, title: preamble.title, reason: 'disabled' }); + continue; + } + if (!scopeMatches(preamble, input.canonicalProjectPath)) { + rows.push({ id, title: preamble.title, reason: 'out-of-scope' }); + continue; + } + rows.push({ id, title: preamble.title, reason: null }); + } + return { + rows, + eligibleCount: rows.filter((row) => row.reason === null).length, + }; +} + +export function candidateUnavailableReason( + preamble: Preamble, + canonicalProjectPath: string, +): Exclude | null { + if (!preamble.enabled) return 'disabled'; + return scopeMatches(preamble, canonicalProjectPath) ? null : 'out-of-scope'; +} diff --git a/web/src/lib/stores/__tests__/app-shell.test.ts b/web/src/lib/stores/__tests__/app-shell.test.ts index e0c8348565..0fecd4783a 100644 --- a/web/src/lib/stores/__tests__/app-shell.test.ts +++ b/web/src/lib/stores/__tests__/app-shell.test.ts @@ -308,5 +308,28 @@ describe('AppShellStore', () => { expect(store.showPreambles).toBe(false); expect(store.settingsTab).toBe('remote'); }); + + it('restores the captured opener after catalog management closes', async () => { + const store = new AppShellStore(); + const restore = vi.fn(); + store.openPreambles(restore); + + store.closePreambles(); + await Promise.resolve(); + + expect(restore).toHaveBeenCalledOnce(); + }); + + it('captures both chat and transcript view for selection editing', () => { + const store = new AppShellStore(); + store.openChatPreambleSelection( + '1783725900000200', + '12345678-1234-4123-8123-123456789abc', + ); + expect(store.chatPreambleSelectionTarget).toEqual({ + chatId: '1783725900000200', + transcriptViewId: '12345678-1234-4123-8123-123456789abc', + }); + }); }); }); diff --git a/web/src/lib/stores/app-shell.svelte.ts b/web/src/lib/stores/app-shell.svelte.ts index f4bdc2c908..8bdb37265b 100644 --- a/web/src/lib/stores/app-shell.svelte.ts +++ b/web/src/lib/stores/app-shell.svelte.ts @@ -18,12 +18,19 @@ export interface NewChatDialogSeed { prefill?: string; } +/** Captured target for the per-chat preamble selection editor. */ +export interface ChatPreambleSelectionTarget { + readonly chatId: string; + readonly transcriptViewId: string; +} + export class AppShellStore { showSettings = $state(false); showScheduledPrompts = $state(false); showPreambles = $state(false); showSnippets = $state(false); showOnboardingWizard = $state(false); + chatPreambleSelectionTarget = $state(null); settingsTab = $state('providers'); sidebarOpen = $state(false); isMobile = $state(false); @@ -45,12 +52,13 @@ export class AppShellStore { #newChatDialogSeed = createActionSignal(); #sidebarSearch = createActionSignal(); #snippetsReturnFocus: (() => void) | null = null; + #preamblesReturnFocus: (() => void) | null = null; openSettings(section: string = 'providers'): void { this.dismissSnippets(); this.showScheduledPrompts = false; this.showOnboardingWizard = false; - this.showPreambles = false; + this.dismissPreambles(); this.showSettings = true; this.settingsTab = normalizeSettingsTab(section); } @@ -63,7 +71,7 @@ export class AppShellStore { this.dismissSnippets(); this.showSettings = false; this.showScheduledPrompts = false; - this.showPreambles = false; + this.dismissPreambles(); this.showOnboardingWizard = true; } @@ -75,7 +83,7 @@ export class AppShellStore { this.dismissSnippets(); this.showSettings = false; this.showOnboardingWizard = false; - this.showPreambles = false; + this.dismissPreambles(); this.showScheduledPrompts = true; } @@ -83,23 +91,40 @@ export class AppShellStore { this.showScheduledPrompts = false; } - openPreambles(): void { + openPreambles(returnFocus?: () => void): void { this.dismissSnippets(); this.showSettings = false; this.showScheduledPrompts = false; this.showOnboardingWizard = false; + this.#preamblesReturnFocus = returnFocus ?? null; this.showPreambles = true; } closePreambles(): void { this.showPreambles = false; + const returnFocus = this.#preamblesReturnFocus; + this.#preamblesReturnFocus = null; + if (returnFocus) queueMicrotask(returnFocus); + } + + dismissPreambles(): void { + this.showPreambles = false; + this.#preamblesReturnFocus = null; + } + + openChatPreambleSelection(chatId: string, transcriptViewId: string): void { + this.chatPreambleSelectionTarget = { chatId, transcriptViewId }; + } + + closeChatPreambleSelection(): void { + this.chatPreambleSelectionTarget = null; } openSnippets(returnFocus?: () => void): void { this.showSettings = false; this.showScheduledPrompts = false; this.showOnboardingWizard = false; - this.showPreambles = false; + this.dismissPreambles(); this.#snippetsReturnFocus = returnFocus ?? null; this.showSnippets = true; } diff --git a/web/src/lib/types/app.ts b/web/src/lib/types/app.ts index 6f49f41a8c..89437926c8 100644 --- a/web/src/lib/types/app.ts +++ b/web/src/lib/types/app.ts @@ -21,4 +21,5 @@ export interface NewChatConfig { firstMessage: string; initialImages?: File[]; tags?: string[]; + orderedPreambleIds?: readonly string[]; } diff --git a/web/src/lib/types/chat-session.ts b/web/src/lib/types/chat-session.ts index ef62e95e09..5961412843 100644 --- a/web/src/lib/types/chat-session.ts +++ b/web/src/lib/types/chat-session.ts @@ -22,6 +22,7 @@ export interface ChatStartupConfig { firstMessage: string; initialImages?: File[]; tags?: string[]; + orderedPreambleIds?: readonly string[]; } export interface ChatSessionRecord { diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index a560285781..07493f7762 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -11,6 +11,7 @@ import { createRemoteSettingsStore } from '$lib/stores/remote-settings.svelte.js'; import { createScheduledPromptsStore } from '$lib/scheduling/scheduled-prompts-store.svelte.js'; import { createPreamblesStore } from '$lib/preambles/preambles-store.svelte.js'; +import { createChatPreambleSelectionInvalidationHub } from '$lib/preambles/chat-selection-invalidation-hub.js'; import { createSnippetsStore } from '$lib/snippets/snippets-store.svelte.js'; import { createAppTitleStore } from '$lib/stores/app-title.svelte.js'; import { createMinuteClockStore } from '$lib/stores/minute-clock.svelte.js'; @@ -48,6 +49,7 @@ setGhCapability, setScheduledPrompts, setPreambles, + setChatPreambleSelectionInvalidationHub, setSnippets, setWorkspaceLayout, setWorkspaceContext, @@ -98,6 +100,7 @@ const remoteSettings = createRemoteSettingsStore(); const scheduledPrompts = createScheduledPromptsStore(); const preambles = createPreamblesStore(); + const chatPreambleSelectionInvalidationHub = createChatPreambleSelectionInvalidationHub(); const snippets = createSnippetsStore(); const appTitle = createAppTitleStore(); const navigation = createNavigationStore(); @@ -168,6 +171,7 @@ setRemoteSettings(remoteSettings); setScheduledPrompts(scheduledPrompts); setPreambles(preambles); + setChatPreambleSelectionInvalidationHub(chatPreambleSelectionInvalidationHub); setSnippets(snippets); setAppTitle(appTitle); setNavigation(navigation); @@ -290,7 +294,7 @@ sidebarSearch.applyTranscriptSearchStatus(status), ); const scheduledPromptsRouter = new ScheduledPromptsRouter(ws, scheduledPrompts); - const preamblesRouter = new PreamblesRouter(ws, preambles); + const preamblesRouter = new PreamblesRouter(ws, preambles, chatPreambleSelectionInvalidationHub); const snippetsRouter = new SnippetsRouter(ws, snippets); settingsRouter.start(); transcriptSearchStatusRouter.start(); @@ -317,6 +321,9 @@ untrack(() => void scheduledPrompts.refreshIfLoaded()); untrack(() => void preambles.refreshIfLoaded()); untrack(() => void snippets.refreshIfLoaded()); + // A reconnect also refreshes an already-open chat selection editor; + // its dirty draft is preserved by the controller's refresh path. + untrack(() => chatPreambleSelectionInvalidationHub.publishReconnect()); }); onMount(() => { From cdb8930236d7ac91a9e2fbc65b87ec04fa838794 Mon Sep 17 00:00:00 2001 From: cfal Date: Sun, 6 Sep 2026 21:19:28 +0000 Subject: [PATCH 3/5] docs: define per-chat preamble ledger semantics --- docs/transcript-ledger-v5-cts.md | 56 ++++++++- docs/transcript-ledger-v5-design.md | 180 ++++++++++++++++++++++++++-- 2 files changed, 217 insertions(+), 19 deletions(-) diff --git a/docs/transcript-ledger-v5-cts.md b/docs/transcript-ledger-v5-cts.md index 658d03f40e..d5136bdbbc 100644 --- a/docs/transcript-ledger-v5-cts.md +++ b/docs/transcript-ledger-v5-cts.md @@ -1,6 +1,6 @@ # Transcript Ledger V5 Conformance Test Suite -Status: Revision 34 integrated catalog. PR #500 release acceptance is anchored +Status: Revision 35 integrated catalog. PR #500 release acceptance is anchored historically at squash merge `80540fc80399957ebcfe18cb2c2a741938e5cf64`; the current post-merge corrections include PR #518, PR #521 presentation-only chat rows, the PR #527 native-drift @@ -25,13 +25,19 @@ distinct same-length prefix during native sanitation. Revision 34 makes a native occurrence mandatory after a provider-origin terminal proves the preamble turn completed; persistence lag fails retryably before Reload cutover or native-fidelity target publication. +Revision 35 adds revisioned ordered per-chat preamble selections, canonical +UUID-v4 catalog identity with permanent retirement, registry-first live Save, +and distinct title-only update notices. Missing selected IDs remain durable and +skip safely; omitted creation defaults, explicit empty, inheritance, selected +order, direct/queued admission serialization, and REST/WebSocket/browser +contracts become conformance obligations. Governing artifact: -- `docs/transcript-ledger-v5-design.md`, revision 34, SHA-256 - `b24125600fb05861659e06b9d24242cdc45088479453a8109ab43e7c5a99347e` +- `docs/transcript-ledger-v5-design.md`, revision 35, SHA-256 + `f990a32653b4e326e9922863453db195ddfea9ffb6e8f1153707d473e5e60329` -Current inventory: 417 discovered stable IDs, validated by +Current inventory: 419 discovered stable IDs, validated by `scripts/validate-transcript-ledger-v5-cases.js` against `scripts/conformance/transcript-ledger-v5-cases.txt`. The PR #500 squash merge above is the historical acceptance anchor for the first 256. Later cases are @@ -404,10 +410,16 @@ routine local testing. | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | | TLV5-PREAMBLE.01 | The public application notice contains only a fixed type plus unique immutable `{id, title}` snapshots; bodies, paths, scopes, revisions, and receipts are rejected. | Shared contract | | TLV5-PREAMBLE.02 | A matched boundary expands active `{{chat_id}}` tokens with the target chat ID, commits one adjacent notice/input group atomically, stores its private proof and exact expanded-prefix receipt using SHA-256 over little-endian UTF-16 code units, and keeps the body only in the transient provider prefix; a zero match stores only the proof-bearing input. | Store and service unit | -| TLV5-PREAMBLE.03 | New chat, fork, continuation, and in-place agent-switch boundaries resolve the current enabled matching catalog once in visible order and use that target chat ID for expansion; blocked provider slash input stores nothing and retains the boundary. | Core unit, server black-box, provider scripted | +| TLV5-PREAMBLE.03 | Lifecycle and selection-change boundaries resolve the chat's ordered selection against the current enabled matching catalog once and use that target chat ID for expansion; blocked provider slash input stores nothing and retains the complete boundary identity. | Core unit, server black-box, provider scripted | | TLV5-PREAMBLE.04 | Reload and native-fidelity fork collect exact binding evidence, sanitize carried context before receipt-proven prefixes, hash exact UTF-16 code units without surrogate replacement, require one matching receipt signature before selecting the earliest unused exact receipt, reconstruct the notice and private input fields, tolerate absent native occurrences only without provider-completion proof, retry before publication when completed turns are not yet persisted, and fail closed on unprovable, ambiguous, or excess leading frames. | Native-import, Reload, fork, and scripted-provider unit | | TLV5-PREAMBLE.05 | Application notices render, freeze, share, and export with IDs/titles only while remaining absent from search, preview, resend, model context, and carryover input. | Canonical read-fold matrix and presentation tests | -| TLV5-PREAMBLE.06 | The lazy SPA catalog creates, edits, deletes, reorders, filters, disables, and re-enables entries; scoped rules retain independent nested flags, the composer identifies the supported `{{chat_id}}` token, and the historical application row remains title-only and adjacent. | Component and Lightpanda browser behavior | +| TLV5-PREAMBLE.06 | The lazy SPA catalog creates, edits, deletes, reorders, filters, disables, and re-enables entries; scoped rules retain independent nested flags, the composer identifies the supported `{{chat_id}}` token, and historical application/update rows remain title-only. | Component and Lightpanda browser behavior | +| TLV5-PREAMBLE.07 | Catalog IDs are server-generated canonical UUID v4 values; deletion atomically retires each ID forever under bounded version-2 storage, including ambiguous post-rename durability. | Shared/store contract and fault injection | +| TLV5-PREAMBLE.08 | Every chat persists a revisioned ordered ID list; missing, disabled, and out-of-scope IDs remain selected with exact reasons but contribute no prefix, application notice, or slash block. | Registry, resolution, and server black-box | +| TLV5-PREAMBLE.09 | Omitted new-chat selection resolves current server defaults, explicit empty means none, forks and continuations clone source-current IDs at revision zero, and agent switch preserves selection/revision. | Command, lifecycle, and server black-box | +| TLV5-PREAMBLE.10 | Existing-chat Save is view-qualified, revisioned, idempotent, registry-first, and serialized before the next direct/queued admission without changing active provider work; partial and unknown outcomes never claim rollback. | Service fault/concurrency unit and server black-box | +| TLV5-PREAMBLE.11 | `Preambles updated` is a distinct title-only notice with empty-state support and ordinary notice folds; REST/WebSocket/browser parsers reject malformed identity, projection, and ordering relationships. | Shared/API/WS/component contract | +| TLV5-PREAMBLE.12 | Exact selected-order composition validation runs at changed Save, explicit creation, and admission; typed slash/composition failures retain prepared targets and cannot wedge queued successors. | Core/queue unit and server black-box | ### Presentation-Only Chat Rows @@ -695,6 +707,29 @@ classification, durable ordinals, and provider-metadata privacy. `TLV5-CHAT-ROW.03-READ-FOLDS-CORE-UNIT-01` is the required subtype companion: it proves that CLI rows of every style and integration-owned `ErrorMessage` rows remain presentation-only even though the latter retains `provider-row` kind. +`PREAMBLE-SELECTION.01-CONTRACT-01` and the canonical matrix distinguish the +two preamble facts: `preamble-application` survives frozen projection and is +receipt-backed, while `preamble-selection-changed` is an ordinary public +notice that renders, shares, and exports but is absent from every conversational +fold and may be dropped by manual native Reload. + +## Per-Chat Preamble Selection Coverage + +| Requirement | Primary evidence | State | +| ----------- | ---------------- | ----- | +| TLV5-PREAMBLE.07 | Shared UUID guards, catalog v2 migration/store collision and phase-aware fault tests | Covered | +| TLV5-PREAMBLE.08 | Registry codec/clone tests, ordered selection projection units, deleted-ID server case | Covered | +| TLV5-PREAMBLE.09 | Start/fork/continuation/ownership units and server creation/fork cases | Covered | +| TLV5-PREAMBLE.10 | Selection service/store fault and lock-interleaving units plus server Save workflow | Covered | +| TLV5-PREAMBLE.11 | Shared response/notice, route, WebSocket, controller, and component tests | Covered | +| TLV5-PREAMBLE.12 | Selected composition, registry admission, accepted-input, and queue-drainer tests | Covered | + +Browser behavior uses one shared selection panel for New Chat and existing +chat editing. Component coverage locks draft suspension/restoration around +catalog management, captured chat/view targeting, reconnect refresh, dirty +invalidation preservation, candidate eligibility labels, keyboard submission, +spinner/duplicate gating, and empty/update rendering. Lightpanda proves the +creation and existing-chat Save/application flows through real transport. ## Browser Behavior Matrix @@ -818,6 +853,10 @@ Static negative guards should reject reintroduction of: | Evicted ledger close fails | Failure belongs to evicted chat; requested chat opens | Covered | | Evicted ledger passive checkpoint fails | Evicted chat is never fenced; requested chat opens | Covered | | Permission response fails after claim | Claim restored only while exact occurrence remains actionable | Covered | +| Catalog write fails after rename while retiring an ID | Candidate/tombstone stays installed; later catalog mutations fence | Covered unit | +| Selection registry write fails before or after rename | Old state remains before rename; candidate stays installed and Saves fence after rename | Covered unit | +| Selection notice append fails after registry decision | Typed committed partial result; no compensation or fabricated notice | Covered unit and server black-box | +| Selection Save races direct or queued ordinary admission | One total order; admission cannot pass the Save's notice attempt | Covered unit | ## Existing Evidence Catalog @@ -889,6 +928,11 @@ for each atomic requirement and records any required complementary tier. | TLV5-PREAMBLE.04-NATIVE-UNIT-01 | `server/ledger/__tests__/preamble-history.test.js`: exact UTF-16 code-unit receipt sanitation strips the private prefix, rejects lone-surrogate replacement, maps distinct and byte-identical evidence deterministically without a provider-visible application identifier, counts required completed-turn occurrences by signature, retries on persistence lag, rejects prefix-related signature ambiguity, and returns immutable application evidence for reconstruction | PREAMBLE.04 | | TLV5-PREAMBLE.05-READ-FOLDS-CORE-UNIT-01 | `server/ledger/__tests__/read-fold-matrix.test.js`: the application notice stays visible and body-free while every conversational fold excludes it | PREAMBLE.05, L01.02 | | TLV5-PREAMBLE.06-LIGHTPANDA-01 | `integration-tests/tests/e2e/preambles.test.ts` and the preamble component suite: the SPA manages enabled and scoped catalog entries, documents `{{chat_id}}`, and renders the historical application notice adjacent to its user row | PREAMBLE.06 | +| PREAMBLE-SELECTION.01-CONTRACT-01/02 | `common/__tests__/transcript-notice-contract.test.js`: the public update detail permits empty title snapshots, rejects private data, and round-trips through the shared notice contract | PREAMBLE.11 | +| PREAMBLE-SELECTION.02-SERVER-01..05 | `integration-tests/tests/server/per-chat-preambles.test.ts`: registry-first Save, selected-order next-input application, explicit empty, idempotency/conflicts, unsafe-order rejection, fork inheritance, deleted-ID retention, and preview semantics cross HTTP, WebSocket, registry, and ledger boundaries | PREAMBLE.08 through PREAMBLE.12 | +| PREAMBLE-SELECTION.03-LIGHTPANDA-01/02 | `integration-tests/tests/e2e/per-chat-preambles.test.ts`: New Chat sends explicit chat order, existing-chat Save renders its update row without provider work, the next ordinary input applies once, and the following turn is unprefixed | PREAMBLE.09 through PREAMBLE.11 | +| PREAMBLE-SELECTION-STORE-UNIT | `server/preambles/__tests__/store-v2.test.js` and `server/chats/__tests__/store.test.js`: canonical generation, tombstones, lifetime/file bounds, phase-aware durability, normalized selection cloning, complete boundary binding, and field-isolated publication | PREAMBLE.07, PREAMBLE.08, PREAMBLE.10 | +| PREAMBLE-SELECTION-SERVICE-UNIT | `server/preambles/__tests__/chat-selection-service.test.js`, `server/agents/__tests__/registry-session-cache.test.js`, and `server/chat-execution/__tests__/queue-drainer.test.js`: Save/admission locking, partial outcomes, complete proof identity, selected-order composition, and recoverable direct/queued rejection | PREAMBLE.10, PREAMBLE.12 | | TLV5-L01.02-EXPORT-SERVER-01 | `integration-tests/tests/server/garcon-cli-export.test.ts`: authenticated CLI export captures succinct Markdown and XML artifacts, applies filters, preserves ordinal gaps, and writes/replaces files atomically | L01.02 | | TLV5-L01.02-SEARCH-LAZY-ADOPTION-SERVER-01 | `integration-tests/tests/server/transcript-search-lazy-adoption.test.ts`: first successful lazy adoption converges into an already-enabled index without a later commit, restart, toggle, or native request | L01.02, ADOPT.01 | | TLV5-L01.02-SEARCH-CATALOG-PRUNE-SERVICE-01 | `server/chats/search/__tests__/controller-service.test.js`: a chat adopted while a resync replacement is held remains searchable after exclusive pruning refreshes the catalog | L01.02, ADOPT.01 | diff --git a/docs/transcript-ledger-v5-design.md b/docs/transcript-ledger-v5-design.md index d29459683b..44227b212e 100644 --- a/docs/transcript-ledger-v5-design.md +++ b/docs/transcript-ledger-v5-design.md @@ -1,11 +1,38 @@ # Garcon Transcript Ledger V5: Core-Owned Append-Only Authority -Status: revision 34 integrated design. Supersedes +Status: revision 35 integrated design. Supersedes `AGENT_OWNED_TRANSCRIPT_PROJECTION_DESIGN.md` (V4, SHA-256 `12e6efbcbd30419c0b4580d8159f60e2b1948d8dd790857a070dee5b3f6873cf`), which remains untouched as the historical record of the reconciliation-based architecture and its implementation through commit `f029424c`. +Revision 35 adds ordered per-chat preamble selection while retaining the +workspace catalog as the body, enablement, and project-scope authority. Every +chat stores an explicit revisioned ID list in `chats.json`; missing, disabled, +and out-of-scope IDs stay selected but are skipped. New independent chats use +server-resolved defaults when the creation request omits the list, while an +explicit empty list means none. Forks and continuations copy the source's +current list at revision zero, and in-place agent switches preserve the list +and revision. Application order is the chat's saved order. + +Catalog IDs are server-generated canonical lowercase UUID v4 values. +`preambles.json` version 2 permanently records deleted IDs in a bounded +tombstone set, so an unavailable saved reference can never resolve to a new +definition. Active and retired IDs are disjoint, never reused, and protected by +phase-aware atomic persistence: a renamed catalog candidate remains installed +and later mutations fence when directory-sync durability is unknown. + +An existing-chat selection Save is intentionally lean and registry-first. It +persists selection plus a revision-qualified `selection-change` boundary, +then appends an idempotent title-only `Preambles updated` notice. Save never +starts, steers, interrupts, or changes admitted provider work; the next +ordinary direct or queued input applies the then-current eligible selection. +Save and admission share a narrow per-chat lock. A process failure between the +registry decision and notice commit may permanently omit that update notice; +the authoritative selection and the later atomic `Preambles applied`/input +pair remain correct. Typed REST, WebSocket, and browser contracts expose only +IDs, titles, revisions, and eligibility reasons, never bodies or scopes. + Revision 34 prevents native-history persistence lag from deleting the only receipt evidence for a completed preamble turn. A provider-origin `run-ended` after the application input and before the next ordinary non-steer input makes @@ -460,7 +487,10 @@ The decisions: opt-out state, is process-ephemeral overlay and dies on restart. A matched preamble application is one core-originated row group: its typed notice and boundary input commit atomically at adjacent ordinals and broadcast as one - ordered batch. + ordered batch. A preamble selection Save is a separate cross-store operation: + `chats.json` commits first and its update notice follows. The documented + crash window may lose only that presentation notice, never the registry + decision or a later application row group. - **L4 Durable before dispatch, idempotent by message identity.** An immediate input is appended and committed before provider dispatch; a steer before delivery is attempted; a dequeued queued entry before its @@ -474,7 +504,10 @@ The decisions: row uses the same view-local identity and conflict boundary over its type, title, and content, but never dispatches agent work. A pending preamble boundary is consumed only by the committed input's - private boundary proof. A matching application's body-free prefix receipt + private boundary proof. Lifecycle proofs retain `{kind, ownershipEpoch}`; + repeatable `selection-change` proofs additionally include the selection + revision, and proof lookup and compare-and-clear use the complete identity. + A matching application's body-free prefix receipt covers the exact target-chat-expanded prefix, is server-derived, and is excluded from the submission fingerprint, so an identical retry returns the original input without another notice, prefix, @@ -583,8 +616,9 @@ The decisions: mechanics stay behind `@garcon/server-agent-interface` under `server-agents//`. Capabilities are nullable facets, never optional methods or boolean flags. Preamble selection, target-chat template - expansion, framing, prompt concatenation, receipt validation, and - normalized-history sanitation remain in provider-neutral core. Integrations receive the existing single opaque + expansion, ordered per-chat resolution, framing, prompt concatenation, + receipt validation, and normalized-history sanitation remain in + provider-neutral core. Integrations receive the existing single opaque prompt and expose the existing normalized import stream unchanged. ## 3. Data Model @@ -697,7 +731,13 @@ Kind semantics: A preamble application uses the fixed message `Preambles applied` and exact public detail `{type: 'preamble-application', preambles: [{id, title}, ...]}`. The ID/title pairs are immutable snapshots; no body, scope, path, catalog - revision, or receipt is present. + revision, or receipt is present. A configuration change uses the distinct + fixed message `Preambles updated`. Its private ledger detail is + `{type: 'preamble-selection-change', clientMessageId, requestFingerprint, + selectionRevision, preambles}` for view-local idempotency; presentation + exposes only `{type: 'preamble-selection-changed', preambles}`. The list may + be empty and renders as `None enabled`. It never participates in application + evidence or native prefix sanitation. - `agent-switch`: the durable ownership boundary written at in-place handoff, carrying `{fromAgentId, toAgentId, fromModel, toModel}` and rendered as the shared `AgentSwitchMessage`. V4 synthesized this @@ -797,6 +837,32 @@ native session, and prior refs stay recorded for any future product flow. Reusing an old session never inserts rows before `content_start_ordinal`. +### 3.5 Per-chat preamble selection authority + +`chats.json` owns each chat's fixed-size configuration: + +```ts +interface ChatPreambleSelection { + readonly revision: number; + readonly orderedPreambleIds: readonly string[]; +} +``` + +The list contains at most 100 unique canonical UUID-v4 IDs and is cloned on +every registry ingress and egress. Missing persisted selection means revision +zero and an empty list; a present malformed selection fails registry loading. +Selection bodies, titles, scopes, paths, and catalog snapshots never enter the +registry. A `selection-change` pending boundary stores the current ownership +epoch and selection revision; every registry mutation verifies that binding. + +The workspace catalog remains authoritative for definitions and eligibility. +Its version-2 file stores active ordered definitions plus permanent retired-ID +tombstones. Resolution iterates the chat list, looks up each active catalog +record, and classifies it as eligible, missing, disabled, or out of scope. +Unavailable IDs remain in the list and contribute no prefix, application +notice, or slash-command block. Filtering the catalog through a selection set +is forbidden because it would restore catalog order instead of chat order. + ## 4. Storage: One SQLite Database Per Chat ### 4.1 Authority and physical scope @@ -1247,8 +1313,11 @@ The guarantee is durable before provider dispatch, not durable at send: - An **immediate input** (starting a turn now) is validated under the ownership and pending-ownership fences, appended, and committed before provider dispatch. If its binding has a pending preamble boundary, core - resolves the current enabled catalog against the canonical project path. - A nonempty match plus an otherwise-unhandled slash-leading input is rejected + resolves the chat's saved selection against one current catalog snapshot and + the canonical project path, preserving selected-ID order and skipping + unavailable references. It validates the exact selected composition, + including target-chat expansion, size, and reserved-separator safety. A + nonempty eligible set plus an otherwise-unhandled slash-leading input is rejected before admission and leaves the boundary armed; otherwise the input commits the boundary proof and consumes it exactly once. Each matched body expands the shared `{{chat_id}}` token to this target chat ID before prefix framing; @@ -1293,6 +1362,14 @@ The guarantee is durable before provider dispatch, not durable at send: intentionally loses queued entries and does not reconstruct them or mark them. +Direct admission and queued dequeue share the selection/admission lock with +selection Save. A direct input observes either the old selection before Save or +the new selection after the update-notice attempt. Queue draining takes the +same lock around dequeue plus synchronous admission, so a queued entry waits +rather than disappearing or being permanently paused. A slash-blocked or +composition-invalid queued entry is consumed with its typed actionable turn +failure, leaves the boundary armed, and allows later entries to drain. + Submission is idempotent by `(chatId, transcriptViewId, clientMessageId)`. The client generates `clientMessageId` once per logical message and reuses it on retry. While the message is queued, the @@ -1408,6 +1485,40 @@ V4's pending-input settlement machinery (`settledInputRequests`, native-binding proof) is deleted. What survives under that last name is only the client's in-flight marker from 7.1, which crosses no boundary. +### 7.4 Preamble selection configuration + +Existing-chat configuration uses `GET /api/v1/chats/preambles?chatId=...` +and `PUT /api/v1/chats/preambles`. The GET adopts and returns one coherent +current target containing chat/view identity, the normalized selection, +canonical chat project path, catalog revision, eligible `{id,title}` snapshots, +and unavailable `{id,reason}` references. The PUT is view-qualified, +revision-checked, limited to 32 KiB before JSON decoding, and idempotent by its +client message identity. A completed duplicate is checked before the expected +selection revision; changed identity reuse conflicts. An unchanged list +returns without composition validation, persistence, notice, invalidation, or +reapplication. + +A changed Save holds the chat mutation lock followed by the selection/admission +lock, captures one catalog snapshot, validates the eligible selected-order +composition, and writes selection plus pending boundary to `chats.json`. It +then appends the idempotent update notice and schedules the body-free +`chat-preambles-invalidated {chatId, revision}` event behind committed-row +fanout on the per-chat server-event queue. A pre-rename registry failure leaves +the old selection. A post-rename durability ambiguity retains the candidate, +fences another Save until explicit reconciliation, and reports commit state as +unknown. A later notice failure reports that the selection committed and never +compensates it. + +Pre-creation projection uses side-effect-free +`POST /api/v1/preambles/selection-preview`. Omitted IDs request current +server defaults for the canonical project path; present IDs project the exact +draft, including explicit empty and missing references. Actual creation repeats +omitted-default resolution. Browser state keeps defaults distinct from an +explicit draft, captures `{chatId, transcriptViewId}` before opening an +existing-chat editor, preserves dirty drafts across catalog management and +invalidation, refreshes loaded selection on reconnect, and uses one gated +click/Enter/shortcut submission path. + ## 8. Permission History Permission lifecycle is durable ledger history, not a transient-only @@ -1492,6 +1603,7 @@ presentation-only in every consumer fold. | error `provider-row` | yes | no | no | no | yes | yes | | ledger-private `chat-id-request` notice | no | no | no | no | no | no | | typed `preamble-application` notice | yes | no | no | no | yes | yes | +| typed `preamble-selection-changed` notice | yes | no | no | no | yes | yes | | every other `notice` | yes | no | no | no | yes | yes | | `agent-switch` | yes | no | no | no | yes | yes | | permission rows | specialized | no | no | no | specialized | yes | @@ -1539,7 +1651,9 @@ model context, carryover, or export. to the conversational matrix: it preserves carryover-quarantine and typed preamble-application notices. The former keeps permanent prior loss visible; the latter preserves immutable title snapshots next to copied boundary - inputs. Neither notice enters model context. + inputs. Selection-change notices are ordinary presentation notices and are + dropped by frozen projection and manual native Reload. None of these notices + enters model context. - **Shares are snapshot artifacts**: publishing a share copies its rendering fold into the share store (the existing product behavior — `server/routes/shares.ts`, `server/chats/share-store.ts`, @@ -1883,6 +1997,14 @@ crashes before registration may orphan a provider artifact; existing best-effort rollback applies and the residue is a named accepted loss — no new journal is invented for it. +Fork and continuation configuration is independent of transcript seeding. Each +target clones the source chat's current ordered preamble IDs at preparation and +starts at selection revision zero, including a historical-point fork. An +in-place agent switch preserves the current selection and revision while its +new lifecycle boundary supersedes an older selection-change boundary. A +delegated independent chat and a scheduled new chat use ordinary creation +defaults unless an explicit list is present. + ## 12. Handoff, Continuation, and Fork Three distinct product operations. None deletes a native session, and @@ -2175,6 +2297,15 @@ relevant-entry definition under the 10.2 obligation. | Crash after an input's commit, before dispatch | Accepted loss: a retry returns the existing row and never re-dispatches; the row shows the will-send-with-next-message affordance and the next fresh input's scan is the recovery path. | | Pending preamble boundary with enabled matches receives an otherwise-unhandled slash command | Typed `PREAMBLE_SLASH_COMMAND_BLOCKED`; no input, notice, prefix, or dispatch; the prepared target and boundary remain available for a later ordinary input. An identical command retry returns the same typed rejection. | | Candidate preamble catalog exceeds the combined budget after `{{chat_id}}` expansion | Mutation fails before persistence; every valid chat ID is 16 digits, so the fixed validation sample exactly bounds runtime output. | +| Catalog deletion commits | The active UUID moves to the permanent tombstone set in the same revisioned write; saved chat references remain `missing` and can never resolve to another record. | +| Catalog rename completes but directory sync is unknown | The candidate and tombstones remain installed, catalog mutation fences, and clients receive the typed unknown outcome; no later mutation can resurrect an ID. | +| Existing-chat selection Save fails before registry rename | The old selection remains authoritative and no update notice is appended. | +| Process fails after selection registry commit and before update-notice commit | The new selection and boundary remain authoritative; that one `Preambles updated` row may be absent permanently and is never synthesized. | +| Update-notice append fails after registry commit | Typed partial failure reports `selectionCommitted: true`; core does not restore the old selection or fabricate a row. | +| Selection registry rename completes but durability is unknown | The candidate remains installed, another Save fences until reconciliation, and the client must refresh before editing. | +| Selected ID is missing, disabled, or out of scope | The ID stays saved with its reason and is skipped without failing admission. | +| Selected-order composition is unsafe at Save or explicit creation | Typed `PREAMBLE_SELECTION_COMPOSITION_INVALID`; no selection or target is persisted. | +| Catalog/path changes make a saved order unsafe at admission | Direct input stores nothing; a prepared target remains available; a queued entry receives the recoverable failure and later entries continue; the boundary stays armed. | | Crash after a preamble boundary input commits but before the registry clear is durable | The input's private boundary proof suppresses reapplication and repairs the stale pending boundary only for the matching ownership epoch. | | Reload cannot durably flush the current registry before view replacement | Reload aborts before cutover; it cannot delete the only zero-match proof while disk still records the boundary as armed. | | Native import contains an exact receipt-matched preamble prefix | Core strips the receipt-covered prefix, reconstructs its title-only notice, and restores the private receipt and boundary proof on the imported input. | @@ -2319,6 +2450,11 @@ Every deliberate gap, in one place, so it is not "fixed" later: compactions, so run identity cannot bound discovery requests. A looping provider can therefore request repeated disclosures within one run; each iteration requires another provider emission and control delivery. +23. A process crash after an existing-chat preamble selection commits to + `chats.json` but before its presentation notice commits may permanently + omit that one `Preambles updated` row. The selection and next-input + application boundary remain authoritative; no recovery journal or notice + backfill exists. ## 17. Testing Strategy @@ -2542,7 +2678,13 @@ The catalog cites this revision, but its inventory is not repeated here. retention and typed replay, registry flush before Reload cutover, absent native evidence, fail-closed mismatches, duplicate admission, reserved separator boundaries, share/export privacy, and provider-neutral scripted - prompt delivery. `server-agents/**` contains no preamble-specific code. + prompt delivery. Per-chat selection cases additionally cover UUID-v4 + generation and permanent retirement, missing-reference retention, ordered + resolution, omitted versus explicit creation, fork/continuation inheritance, + revision-qualified repeat application, registry-first partial outcomes, + direct/queued serialization, REST/WS parsing, reconnect refresh, and shared + New Chat/existing-chat picker behavior. `server-agents/**` contains no + preamble-specific code. - **Reload**: gated on a native-bound binding with a non-null `nativeHistoryImport` (direct chats expose no Reload); staged build under a `staging` view with schema-enforced uniqueness; the frozen @@ -2905,9 +3047,13 @@ stabilization defects. The current case inventory and gate status live in continuation routing stays deleted. Directoryless legacy import remains an explicit follow-up. 25. Preambles are provider-neutral core composition. The current enabled - catalog resolves once at the first ordinary input after new chat, fork, - continuation, or in-place agent switch. Each active `{{chat_id}}` in a - matched body expands to that target chat's 16-digit ID, escaped tokens + catalog and the chat's explicit ordered selection resolve once at the first + ordinary input after a lifecycle or selection-change boundary. New + independent chats use server defaults when selection is omitted; explicit + empty means none; forks and continuations clone source-current IDs at + revision zero; agent switches preserve selection. Missing, disabled, and + out-of-scope IDs remain selected but are skipped. Each active `{{chat_id}}` + in an eligible body expands to that target chat's 16-digit ID, escaped tokens become literal, and unsupported variables remain unchanged; catalog validation applies the same fixed-length rendering before persistence. A nonempty slash-leading provider command is rejected before admission and leaves the boundary armed; core @@ -2921,6 +3067,14 @@ stabilization defects. The current case inventory and gate status live in flushes current registry state before Reload can delete the only zero-match proof. Provider integrations retain their existing opaque-prompt and normalized-history contracts; `server-agents/**` gains no preamble logic. +26. Preamble catalog identity is canonical server-generated UUID v4 with + permanent version-2 tombstones. Existing-chat Save is registry-first and + provider-independent: it never changes current work, arms the next ordinary + input, and appends a distinct idempotent title-only update notice. Save and + direct/queued admission serialize through one internal lock. The accepted + registry-commit/notice-crash gap loses only configuration presentation; + application remains an adjacent atomic notice/input group with exact native + sanitation evidence. Also resolved across revisions: store-what-you-showed with one core ledger and view-wide ordinals; pre-V5 adoption reconstructing the served composite from From 53c646ee5f7e41ad7817ce5d655f68a6f7fc1670 Mon Sep 17 00:00:00 2001 From: cfal Date: Sun, 6 Sep 2026 21:20:14 +0000 Subject: [PATCH 4/5] docs: record per-chat preamble design --- PER_CHAT_PREAMBLES_ADDENDUM.md | 945 +++++++++++++++++++ PREAMBLES_DESIGN.md | 1573 ++++++++++++++++++++++++++++++++ 2 files changed, 2518 insertions(+) create mode 100644 PER_CHAT_PREAMBLES_ADDENDUM.md create mode 100644 PREAMBLES_DESIGN.md diff --git a/PER_CHAT_PREAMBLES_ADDENDUM.md b/PER_CHAT_PREAMBLES_ADDENDUM.md new file mode 100644 index 0000000000..9688d2ddce --- /dev/null +++ b/PER_CHAT_PREAMBLES_ADDENDUM.md @@ -0,0 +1,945 @@ +# Per-Chat Preambles Addendum + +Status: execution-ready proposal + +Audience: Garcon server, transcript, command, and web engineers + +Governing design: `docs/transcript-ledger-v5-design.md`, revision 34 + +Extends: `PREAMBLES_DESIGN.md` + +## Summary + +The workspace preamble catalog remains the source of reusable preamble definitions and the master eligibility gate. Each chat additionally owns an explicit, independently ordered selection of catalog IDs in `chats.json`. An eligible preamble applies only when it is both selected by the chat and enabled and in scope in the current catalog. + +New independent chats snapshot the current enabled, matching catalog IDs in catalog order. Forks and continuations copy the source chat's current selection. An in-place agent switch preserves it. Existing persisted chats that predate this addendum normalize to an empty selection. + +Saving a changed selection on an existing chat persists the new selection first, appends a distinct title-only `Preambles updated` presentation notice, and arms the next ordinary input. Save never sends provider work, modifies an admitted prompt, steers, interrupts, cancels, or restarts an active run. The next ordinary input resolves the then-current catalog in the chat's saved order and produces the existing adjacent `Preambles applied` notice and receipt-backed private prefix. + +This addendum deliberately chooses lean cross-store persistence. `chats.json` is authoritative. There is no recovery journal or prepared operation artifact for the subsequent ledger notice. A process failure after the registry commit and before the notice commit may leave a correctly updated selection without its `Preambles updated` row. The API never claims that this partial outcome rolled back. + +Catalog IDs are canonical server-generated UUID v4 values. Deleted IDs move atomically into a bounded workspace tombstone set and can never be reused. A chat keeps deleted IDs in its ordered selection, reports them as unavailable, and skips them without failing application. The tombstone prevents a later catalog entry from silently acquiring the meaning of an old selection. + +## Resolved Product Decisions + +These decisions supersede only the catalog-wide resolution assumptions in the original preamble design. Its prefix, receipt, application notice, native sanitation, privacy, and provider-neutrality decisions remain unchanged. + +- Catalog `enabled` is a workspace-wide master switch. A disabled preamble never applies even when a chat selects it. +- Catalog project scope remains authoritative. Selection does not override exact or nested path matching. +- A chat stores an ordered allowlist of preamble IDs. Application order is this list order, never current catalog order. +- Selected IDs remain stored when their catalog entries are disabled, outside the current project scope, or deleted. +- Missing catalog IDs are reported as `missing` and skipped gracefully at Save projection and input application. +- UUID v4 IDs are generated by the server and permanently retired on deletion. No deleted ID can later resolve to a different preamble. +- Catalog create, edit, enable, disable, delete, or reorder never mutates a chat selection and never arms existing chats. +- An untouched new-chat form uses server defaults at actual chat creation. An explicit empty list means no preambles. +- Existing persisted chats with no selection field normalize to revision zero and an empty list. +- A fork, including a historical-point fork, copies the source chat's current configuration rather than inferring configuration from the fork watermark. +- Continuation and self-handoff copy the source selection. The target starts at selection revision zero and then diverges independently. +- In-place agent switch preserves the chat selection and its revision. +- Scheduled new chats resolve ordinary new-chat defaults at actual creation. Scheduled input to an existing chat preserves its current selection. +- A delegated new chat uses ordinary new-chat defaults unless the operation is explicitly a continuation. +- A changed live selection applies on the next ordinary input. Steers, goal controls, server controls, permission decisions, and compaction controls neither apply nor consume it. +- An already admitted input retains its captured prefix. Queued inputs resolve selection at dequeue and admission, not queue insertion. +- Every changed Save appends one `Preambles updated` notice. Multiple Saves before the next ordinary input retain all update notices, but only the latest saved selection applies. +- An unchanged Save creates no revision, notice, invalidation, or reapplication. +- Reordering selected IDs is a change. +- Saving `[]` appends `Preambles updated: None enabled`; the next ordinary input consumes a zero-match boundary without an application notice or prefix. +- The update notice records entries eligible in one catalog snapshot captured for the Save. The later application notice records what actually applied. +- Catalog changes between Save and application may change eligibility, titles, content, and the actual applied set. +- Selection changes do not erase instructions already present in a provider's conversation. + +Required UI copy: + +> Applies with the next regular message. Current responses are unchanged. + +## Goals + +- Let users choose and order preambles independently for each chat. +- Support configuration before creation and reconfiguration at any later time. +- Preserve correct behavior across new chat, fork, continuation, scheduled creation, and in-place agent switch. +- Keep the catalog as the single body/scope authority and the chat registry as the selection authority. +- Skip deleted references safely without silently dropping user intent from the saved list. +- Make catalog identity non-reusable for the lifetime of the workspace. +- Preserve provider-neutral prefixing, receipt-backed sanitation, and body privacy. +- Keep Save independent of active provider execution while strictly ordering it before the next input admission. +- Provide typed, revisioned REST and WebSocket contracts and durable, idempotent notice insertion. +- Keep the browser architecture split between catalog management and chat-selection editing. + +## Non-Goals + +- Overriding catalog enablement or project scope from a chat. +- Automatically reapplying preambles after a catalog edit, path edit, or browser reconnect. +- Changing an active run, reissuing an admitted prompt, or removing instructions from provider-native context. +- Inferring current selection from historical `Preambles applied` or `Preambles updated` notices. +- Persisting preamble bodies, scopes, paths, or catalog snapshots in `chats.json` or the ledger. +- Recovering a missing update notice after a process crash without a new explicit client operation. +- Introducing a cross-store transaction journal, durable execution recovery, or a new public busy predicate. +- Teaching `server-agents/**` about selection, notices, boundaries, or prefixes. +- Making update-notice history survive manual native Reload with precise placement. + +## Current System and Required Change + +The implemented catalog lives in `common/preambles.ts` and `server/preambles/{store,service,matching,catalog-budget}.ts`. `PreambleService.resolve()` currently scans enabled, matching entries in catalog order. Catalog create uses `crypto.randomUUID()`, but shared normalization currently accepts any nonblank ID and deletion forgets the ID. + +The chat registry in `server/chats/store.ts` is version 5 and currently stores only `pendingPreambleBoundary` for preamble behavior. `ALLOWED_PATCH_FIELDS`, the persisted-entry codec, `addChat()`, `updateChat()`, and `cloneRegistryEntry()` all enumerate nested ownership and must explicitly gain selection support. + +Boundary producers are: + +- `server/commands/start-commands.ts` for new chats; +- `server/chats/fork-chat.ts` for promptful and promptless forks; +- `server/commands/self-handoff-commands.ts` for continuation; +- `server/chats/agent-ownership-journal.ts` for in-place switches. + +`server/agents/registry.ts` resolves the catalog at ordinary input admission. Its durable proof query and pending-field compare-and-clear currently identify a boundary by `(kind, ownershipEpoch)`. A repeatable live selection-change boundary additionally requires `selectionRevision`; otherwise the first application in an ownership epoch would suppress every later Save in that epoch. + +`server/ledger/preamble-application.ts` and the existing receipt/sanitation path remain the application authority. The new update notice is not an application notice and must not enter `collectPreambleHistoryEvidence()`, which requires application notice/input adjacency. + +The existing catalog dialog remains the only catalog editor. New-chat state currently has no selection field, and `CurrentChatMenuItems.svelte` currently has no selection action. + +## Catalog Identity and Permanent Retirement + +### Canonical ID contract + +Add one shared identity guard and use it everywhere catalog or selection IDs cross a boundary: + +```ts +export type PreambleId = string; + +export function isPreambleId(value: unknown): value is PreambleId { + return typeof value === 'string' + && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u.test(value); +} +``` + +Only the canonical lowercase UUID v4 spelling is accepted. IDs are not trimmed or case-folded. `PreambleService.create()` calls `crypto.randomUUID()` once by default and validates the result before persistence. The injectable ID factory remains a test seam, but an invalid, active, or retired result is rejected rather than normalized or retried in a loop. + +Update `normalizePreamble()`, catalog mutation requests, selection requests, notice-reference validation, and persisted-file parsing to use the same guard. Test fixtures use deterministic canonical UUID v4 strings. + +### `preambles.json` version 2 + +The persisted catalog becomes: + +```json +{ + "version": 2, + "revision": 18, + "preambles": [], + "retiredPreambleIds": [ + "936903ad-8b98-43eb-a7d4-c17ce0dc18d8" + ] +} +``` + +The active array remains ordered. `retiredPreambleIds` is an unordered logical set serialized in insertion order for stable files. Normalization requires: + +- every active and retired ID is a canonical UUID v4; +- active IDs are unique; +- retired IDs are unique; +- active and retired sets are disjoint; +- no unknown file keys exist; +- all existing catalog invariants still hold. + +Version 1 migrates at initialization before the service becomes available. Every active version-1 ID must already be a canonical UUID v4; invalid state fails visibly instead of being rewritten under a different ID. Migration writes version 2 with an empty retired set. Deleted preambles from before version 2 cannot be reconstructed, but per-chat selection did not yet exist and therefore cannot reference them. The permanent non-reuse guarantee begins with the version-2 migration. + +Define: + +```ts +export const PREAMBLE_ID_LIFETIME_MAX_COUNT = 100_000; +export const PREAMBLES_FILE_MAX_BYTES = 64 * 1024 * 1024; +``` + +The lifetime count is `active IDs + retired IDs`, not merely tombstones. Creation fails with `PREAMBLE_ID_LIFETIME_LIMIT_REACHED` before generating or writing an ID when the count is at the ceiling. Deletion moves one active ID to the retired set, so it does not increase the lifetime count and can always retire every admitted active entry. The store never prunes, compacts, reuses, or silently drops tombstones. A persisted or candidate file above either bound fails closed. + +Creation checks collisions against both sets under the existing catalog mutation lock. A generated collision fails with `PREAMBLE_ID_COLLISION`; it is not silently regenerated because the collision may indicate a broken or injected generator. Deletion splices the active record and appends its ID to `retiredPreambleIds` in the same atomic catalog write and revision increment. + +Catalog persistence uses the same commit-aware atomic-write result required for `chats.json`. A failure before rename changes neither the in-memory snapshot nor the authoritative file. If rename completed but final directory sync failed, the store installs the candidate snapshot in memory, returns a committed-but-durability-unknown outcome, and fences later catalog mutations until an explicit sync retry succeeds or restart reloads the file. `PreambleService` emits invalidation for that installed snapshot before surfacing `PREAMBLE_CATALOG_SAVE_UNKNOWN` to the caller. The store never restores the old snapshot or permits a later mutation to overwrite the renamed candidate. This is essential for deletion: a committed tombstone cannot be forgotten while the process continues. Reads remain available from the installed snapshot during the mutation fence. + +The public catalog snapshot continues to return active preambles only. Tombstones are internal persistence state and are never sent to browsers, notices, shares, or exports. + +## Per-Chat Selection Model + +Add shared contracts in `common/preambles.ts`: + +```ts +export interface ChatPreambleSelection { + readonly revision: number; + readonly orderedPreambleIds: readonly PreambleId[]; +} + +export type PreambleSelectionUnavailableReason = + | 'missing' + | 'disabled' + | 'out-of-scope'; + +export interface PreambleSelectionReference { + readonly id: PreambleId; + readonly title: string; +} + +export interface UnavailablePreambleSelectionReference { + readonly id: PreambleId; + readonly reason: PreambleSelectionUnavailableReason; +} + +export interface PreambleSelectionProjection { + readonly catalogRevision: number; + readonly eligiblePreambles: readonly PreambleSelectionReference[]; + readonly unavailable: readonly UnavailablePreambleSelectionReference[]; +} +``` + +Selection normalization requires: + +- a nonnegative safe-integer revision; +- zero through `PREAMBLE_MAX_COUNT` IDs; +- canonical UUID v4 spelling; +- no duplicates; +- preserved order; +- no truncation, deduplication, sorting, or catalog lookup during persistence parsing. + +Missing selection on a persisted chat normalizes to: + +```ts +{ revision: 0, orderedPreambleIds: [] } +``` + +A present malformed selection rejects `chats.json`; corruption is not interpreted as deselection. + +### `chats.json` ownership + +Keep registry version 5 and add one additive field to every normalized entry: + +```json +{ + "preambleSelection": { + "revision": 3, + "orderedPreambleIds": [ + "3502b645-222b-49d2-ac39-1c91f9fb1174", + "80becfa6-c9c7-4b31-9190-fd23c0bedf9c" + ] + } +} +``` + +`ChatRegistryEntry` owns a required normalized `preambleSelection`. `NewChatRegistryEntry` also requires an already-resolved selection so creation callers cannot accidentally confuse persisted absence with creation defaults. Add it to `ALLOWED_PATCH_FIELDS`; normalize and clone its ID array on ingest, update, read, list, rollback snapshots, and persistence. + +Selection revision increments exactly once for a changed existing-chat Save. New chat, fork, and continuation targets start at revision zero regardless of how many IDs their initial snapshot contains. Agent switch preserves revision. Revision exhaustion rejects further changes. + +Bodies, title history, catalog copies, scope, paths, and eligibility are not registry fields. + +### Resolution + +Replace catalog filtering with an ordered lookup for selected chats: + +```ts +export function resolvePreambleSelection( + selection: ChatPreambleSelection, + catalog: PreamblesSnapshot, + canonicalProjectPath: string, +): { + readonly eligible: readonly Preamble[]; + readonly unavailable: readonly UnavailablePreambleSelectionReference[]; +} { + const byId = new Map(catalog.preambles.map((preamble) => [preamble.id, preamble])); + const eligible: Preamble[] = []; + const unavailable: UnavailablePreambleSelectionReference[] = []; + + for (const id of selection.orderedPreambleIds) { + const preamble = byId.get(id); + if (!preamble) { + unavailable.push({ id, reason: 'missing' }); + } else if (!preamble.enabled) { + unavailable.push({ id, reason: 'disabled' }); + } else if (!preambleScopeMatches(preamble.scope, canonicalProjectPath)) { + unavailable.push({ id, reason: 'out-of-scope' }); + } else { + eligible.push(structuredClone(preamble)); + } + } + return { eligible, unavailable }; +} +``` + +The implementation reuses the existing exact/nested scope matcher. It must iterate selection IDs and look up catalog records; filtering the catalog through a selection set would preserve the wrong order. + +Missing IDs remain in `orderedPreambleIds` through reads, Saves, forks, continuations, and agent switches. They appear in `unavailable` and do not appear in either update or application title lists. Permanent tombstones ensure they can never become eligible again. + +### Defaults and inheritance + +| Situation | Initial selection | +| --- | --- | +| New independent interactive or CLI chat, selection omitted | Enabled, matching catalog IDs in catalog order, resolved from the canonical creation path at actual creation | +| New independent chat, explicit list | Exactly the supplied ordered list, including explicit `[]` | +| Existing persisted chat with no field | Empty, revision zero | +| Fork at any watermark | Source chat's current ordered IDs, target revision zero | +| Continuation or self-handoff | Source chat's current ordered IDs, target revision zero | +| In-place agent switch | Preserve ordered IDs and revision | +| Scheduled new chat | Resolve omitted defaults at actual creation | +| Scheduled existing-chat input | Preserve existing selection | +| Delegated new chat with parent relation only | Ordinary new-chat defaults | + +Every creation path writes an explicit normalized selection. No persisted `inherit defaults` mode exists. + +## Pending Boundary Identity + +Extend the boundary union without changing existing lifecycle proof shapes: + +```ts +export type PendingPreambleBoundary = + | { + readonly kind: 'new-chat' | 'fork' | 'continuation' | 'agent-switch'; + readonly ownershipEpoch: string; + } + | { + readonly kind: 'selection-change'; + readonly ownershipEpoch: string; + readonly selectionRevision: number; + }; +``` + +Add `selection-change` to the boundary kind contract. The normalizer permits `selectionRevision` only on that variant. Persisted selection-change boundaries must match both the current `agentOwnershipEpoch` and current `preambleSelection.revision`. Lifecycle boundaries retain their released shape and remain readable unchanged. + +Add one shared equality helper and use it in every proof query and compare-and-clear: + +```ts +export function samePreambleBoundary( + left: PendingPreambleBoundary, + right: PendingPreambleBoundary, +): boolean { + return left.kind === right.kind + && left.ownershipEpoch === right.ownershipEpoch + && (left.kind !== 'selection-change' + || (right.kind === 'selection-change' + && left.selectionRevision === right.selectionRevision)); +} +``` + +`server/ledger/preamble-application.ts`, ledger codecs, native evidence, Reload reconciliation, and `server/agents/registry.ts` use the complete identity. The current ownership-epoch-only clearing check is insufficient and must be removed. + +When a changed selection is saved: + +- if an unconsumed lifecycle boundary is pending, retain it; its first input resolves the newest selection; +- if the persisted pending boundary is already proven consumed in the current ledger, treat it as stale and do not retain it; +- otherwise install or replace a `selection-change` boundary for the new selection revision; +- never rotate `agentOwnershipEpoch` merely to reapply preambles. + +An agent switch replaces an old selection-change boundary with the new `agent-switch` lifecycle boundary while preserving the current selection. Its first ordinary target-owner input applies that selection once. + +## Existing-Chat Save Protocol + +### Serialization and active execution + +Create a focused `ChatPreambleSelectionService` modeled structurally on `ChatRowService`: it owns chat/view validation, revision checks, the shared `chat:${chatId}` mutation lock, ownership-transfer fencing, registry persistence, the update notice, and error translation. It never calls a provider configuration API. + +Save remains allowed while a provider run owns execution. The service does not acquire a direct-turn reservation, compose a provider request, or add a public busy predicate. Output from the active run continues normally. + +The Save and the next direct or queued ordinary-input admission must additionally share a narrow per-chat selection/admission lock: + +- Save holds it from its final validation through the registry write and update-notice attempt. +- Direct admission holds it while it re-reads the registry selection, resolves the catalog, and commits the boundary input. +- Queue draining holds it around `dequeueNextTurn()` and its synchronous `registerQueued()` callback. +- Steers and control deliveries do not need it because they cannot consume the boundary. + +This lock is internal coordination, not execution ownership or a public busy predicate. It prevents an input from observing the new selection before its update notice attempt. It also avoids making synchronous queued admission await inside the dequeue transition. Lock acquisition order is fixed and tested: the existing chat mutation lock precedes the selection/admission lock for Save; queue admission never acquires the chat mutation lock. + +Reload, project-path changes, ownership transfers, fork/continuation capture, and deletion must use the shared chat mutation boundary and remain serialized with Save. Multi-chat operations retain their existing deterministic lock ordering while taking the source lock for selection capture. Pending ownership transfer rejects Save. The target chat ID and transcript view are captured from the invoked window before any `await` and never retargeted if browser focus changes. + +### Lean authoritative-first sequence + +For a request, under those locks: + +1. Require the chat, current transcript view, ownership fence, and expected selection revision. +2. Check durable notice identity in the current view before the revision conflict check. An identical completed retry returns `duplicate`; changed content for the identity returns `IDEMPOTENCY_CONFLICT`. +3. Normalize IDs without requiring catalog membership. Missing UUIDs remain valid selection references. +4. After the expected revision succeeds, compare the normalized ordered list with the stored list. If unchanged, capture a catalog snapshot only to return the current projection and return `unchanged` without composition validation, writing, noticing, invalidating, or arming. +5. For a changed list, capture one catalog snapshot and resolve current eligibility in selection order. +6. Validate the exact currently eligible composition in selected order, including chat-ID expansion, combined length, and the reserved file-context separator. +7. Build the next selection revision and pending boundary, repairing any ledger-proven stale pending boundary as described above. +8. Durably write the selection and pending boundary together to `chats.json`. This is the authoritative decision. +9. Append one idempotent typed update notice to the selected transcript view. +10. Schedule `chat-preambles-invalidated` after the committed notice's `chat-messages` event on the per-chat server-event queue. If notice append fails, still schedule invalidation before returning the partial failure when possible. +11. Return success only when both the registry decision and update notice committed. + +There is no compensating registry write after step 8. Restoring the old selection would make a committed `chats.json` decision disagree with the returned or possibly committed notice. + +The shared atomic JSON writer must expose whether rename occurred, and both registry and catalog stores consume that phase-aware result. A pre-rename registry failure leaves in-memory state unchanged and returns an ordinary save failure. If rename completed but final directory sync failed, retain the new in-memory selection, report durability as unknown, and fence another selection Save until the registry is reloaded or a later explicit flush succeeds. Do not use `ChatRegistry.updateChat(..., { flush: true })` unchanged, because its generic conditional rollback treats every rejection as pre-commit even though `writeJsonFileAtomic()` may reject after rename. + +### Accepted crash window + +The following is intentional: + +```text +chats.json selection + boundary commit + | + | process failure here + v +ledger Preambles updated notice +``` + +After such a failure: + +- the new selection remains authoritative; +- the next ordinary input still applies it once; +- the transcript may permanently lack that one `Preambles updated` notice; +- restart does not synthesize or backfill the notice; +- reconnect refreshes selection from the server; +- historical `Preambles applied` evidence remains exact and unaffected. + +This is the user-approved lean posture. It is narrower than the governing ledger's application guarantee: the later application notice and its input still commit atomically. Only the configuration-change presentation row is lossy. + +If registry persistence succeeds and ledger append fails without process death, return a typed partial error that says the selection was saved and the notice was not: + +```json +{ + "success": false, + "errorCode": "PREAMBLE_SELECTION_NOTICE_FAILED", + "message": "The selection was saved, but its transcript notice could not be recorded.", + "retryable": false, + "selectionCommitted": true, + "selection": { + "revision": 4, + "orderedPreambleIds": [] + } +} +``` + +The browser adopts the returned selection as its new base, keeps the dialog available, and shows the warning. It does not claim rollback or fabricate a notice. A registry write whose commit is unknown returns `PREAMBLE_SELECTION_SAVE_UNKNOWN` with `selectionCommitted: 'unknown'` and requires a refresh before another edit. + +## Idempotent Update Notice + +Add a distinct public notice detail: + +```ts +export interface PreambleSelectionChangedNoticeDetail { + readonly type: 'preamble-selection-changed'; + readonly preambles: readonly PreambleSelectionReference[]; +} +``` + +Unlike `preamble-application`, the list permits zero entries. The fixed message is `Preambles updated`. Empty presentation renders `None enabled`. References are eligible `{id,title}` snapshots from the Save's captured catalog revision. Disabled, out-of-scope, and missing selections are not listed. + +Do not reuse `preamble-application`. The native-history evidence collector requires each application notice to be adjacent to a receipt-bearing input; a standalone update notice under that discriminant would make Reload and native-fork sanitation fail closed. + +The ledger stores a private notice detail carrying only the durable submission identity needed for retry: + +```ts +export interface LedgerPreambleSelectionChangedNoticeDetail extends JsonObject { + readonly type: 'preamble-selection-change'; + readonly clientMessageId: string; + readonly requestFingerprint: string; + readonly selectionRevision: number; + readonly preambles: readonly PreambleSelectionReference[]; +} +``` + +The fingerprint covers the canonical request identity and ordered selected IDs, including the expected selection revision, but excludes catalog titles, bodies, paths, and catalog revision so a retry survives catalog edits. Presentation converts the private type to the exact public `preamble-selection-changed` detail. Share and export see only public references. + +Extend the ledger submission-index codec to allow `client_message_id` on this private notice type as well as CLI rows. The append checks the current view's submission index before mutation. Same identity and fingerprint returns the original row and selection revision without a second broadcast. Same identity with a different fingerprint, a user-input collision, or a CLI-row collision throws `IDEMPOTENCY_CONFLICT`. + +A completed duplicate check precedes expected selection revision validation, but only after the request's transcript view is proven current. A retry against a replaced view receives `STALE_TRANSCRIPT_VIEW` and can never deduplicate into another view. + +Manual Reload intentionally drops update notices from the imported current binding because provider-native history has no position for this Garcon-only configuration event. The selection itself remains in `chats.json`. Frozen projection does not preserve update notices. Fork and continuation configuration is copied from registry state rather than inferred from notice history. + +## REST Contracts + +### Existing-chat projection + +Add: + +```text +GET /api/v1/chats/preambles?chatId= +``` + +Response: + +```ts +export interface ChatPreambleSelectionTargetResponse { + readonly success: true; + readonly chatId: string; + readonly transcriptViewId: string; + readonly selection: ChatPreambleSelection; + readonly projection: PreambleSelectionProjection; +} +``` + +The route ensures/adopts the current transcript view, captures the chat's canonical project path, and projects one current catalog snapshot. It returns no body, scope, path, or tombstone data. An unavailable missing ID returns only its ID and `reason: 'missing'`. + +### Existing-chat mutation + +Add: + +```text +PUT /api/v1/chats/preambles +``` + +Request: + +```ts +export interface UpdateChatPreambleSelectionRequest { + readonly chatId: string; + readonly transcriptViewId: string; + readonly clientRequestId: string; + readonly clientMessageId: string; + readonly expectedRevision: number; + readonly orderedPreambleIds: readonly PreambleId[]; +} +``` + +The JSON request body is limited to 32 KiB before parsing. The normal selection limit remains 100 IDs. + +Success: + +```ts +export interface UpdateChatPreambleSelectionResponse { + readonly success: true; + readonly commandType: 'chat-preambles-update'; + readonly clientRequestId: string; + readonly clientMessageId: string; + readonly chatId: string; + readonly transcriptViewId: string; + readonly status: 'updated' | 'unchanged' | 'duplicate'; + readonly mutationRevision: number; + readonly noticeOrdinal: number | null; + readonly selection: ChatPreambleSelection; + readonly projection: PreambleSelectionProjection; +} +``` + +`mutationRevision` is the revision created by the original operation. For `unchanged` it is the current revision. For a duplicate followed by later edits, `selection` is the current chat selection while `mutationRevision` and `noticeOrdinal` identify the original operation. + +Errors include: + +- request validation or size failure (`400`/`413`); +- `SESSION_NOT_FOUND` (`404`); +- `STALE_TRANSCRIPT_VIEW` (`409`); +- `PREAMBLE_SELECTION_REVISION_CONFLICT` (`409`, refreshable); +- `IDEMPOTENCY_CONFLICT` (`409`); +- pending ownership transfer (`409`, retryable); +- `PREAMBLE_SELECTION_COMPOSITION_INVALID` (`422`); +- `TRANSCRIPT_UNAVAILABLE` (`422`); +- `PREAMBLE_SELECTION_NOTICE_FAILED` (`503`, selection committed); +- `PREAMBLE_SELECTION_SAVE_UNKNOWN` (`503`, commit unknown). + +### New-chat selection preview + +Add a body-qualified, authenticated endpoint for the pre-chat form: + +```text +POST /api/v1/preambles/selection-preview +``` + +Request is: + +```ts +{ + readonly projectPath: string; + readonly orderedPreambleIds?: readonly PreambleId[]; +} +``` + +The server canonicalizes the path through the same project-path authority used by chat creation. Omitted IDs request current defaults; present IDs request a projection of that exact draft, including explicit empty and missing references. The response is: + +```ts +{ + success: true; + canonicalProjectPath: string; + orderedPreambleIds: readonly PreambleId[]; + projection: PreambleSelectionProjection; +} +``` + +For omitted IDs, `orderedPreambleIds` contains enabled, matching entries in catalog order. For explicit IDs, order and missing references are preserved. The response contains no bodies. The new-chat picker separately loads the existing catalog store on user intent when it needs editable candidates. This preview is side-effect-free and does not guarantee later creation state; actual chat creation resolves omitted defaults again and validates an explicit selected composition before persistence. + +`StartChatCommandRequest`, `ChatStartInput`, `NormalizedChatStart`, `NewChatConfig`, browser draft startup state, scheduled creation normalization, and start-command idempotency payloads gain: + +```ts +readonly orderedPreambleIds?: readonly PreambleId[]; +``` + +Omitted in a creation request means resolve current defaults on the server after project-path canonicalization. Explicit `[]` means none. The normalized start carries both the request intent (`defaults` or explicit IDs) and the resolved list used for `addChat()`. `startPayload()` and `startReplayPayload()` fingerprint only the canonical request intent, so a catalog change does not turn an identical retry into an idempotency conflict. The command record does not need the resolved default list: if preparation committed a chat, its selection is already in `chats.json`; if preparation did not commit a chat, a permitted pre-schedule retry resolves defaults at the later actual creation time. A duplicate of a completed start replays its original chat and never resolves newer defaults. + +## WebSocket Contract + +Add a body-free event: + +```ts +export class ChatPreamblesInvalidatedMessage { + readonly type = 'chat-preambles-invalidated' as const; + + constructor( + public readonly chatId: string, + public readonly revision: number, + ) {} +} +``` + +Update the shared sender, parser, receiver, and contract tests together. This event is distinct from workspace `preambles-invalidated`, which continues to describe catalog mutations. + +For a successful Save, schedule the event after the update notice's committed-row event through `server-event-wiring`'s per-chat task queue. For a notice-failed partial outcome, send the invalidation without a preceding row. A process crash may lose the event; reconnect refreshes an already-loaded current selection. + +Clients accept only nondecreasing selection revisions. An open clean editor refreshes. An open dirty editor preserves its draft and shows a conflict. The event does not fabricate a transcript notice. + +Catalog invalidation refreshes the catalog and reprojects an open selection editor without changing its saved IDs. A deleted selected ID therefore changes from a titled candidate to a stable `missing` row while staying in its selected position. + +## Application and Composition + +At an unconsumed boundary, `server/agents/registry.ts` reads the chat's current `preambleSelection`, captures one catalog snapshot, and calls `resolvePreambleSelection()`. It passes only the returned eligible entries to the existing ledger application composer. + +Deleted, disabled, and out-of-scope entries contribute no prefix content, no application title, and no slash-command block. If all selected entries are unavailable, the ordinary input consumes the boundary with its private proof and creates no application notice or prefix. + +Application order is the selected-ID order. Catalog reorder has no effect on any existing selection. Catalog rename/content/scope/enablement changes affect the later resolution of an already-selected active ID. + +The exact eligible composition must be validated at each point that can introduce or consume a changed order: + +- at explicit new-chat selection preparation and existing-chat changed Save, for early actionable feedback against the captured catalog snapshot; +- immediately before input commit, because catalog or project eligibility may have changed. + +Validation uses selected order, target chat-ID expansion, exact prefix framing, combined length, and the reserved file-context separator. The current catalog-wide validator checks catalog order and does not prove every chat permutation safe. For example, individually valid entries can reconstruct the reserved separator only after chat-level reordering. A changed Save or explicit creation request rejects with `PREAMBLE_SELECTION_COMPOSITION_INVALID` before persistence. Admission can still encounter the same typed error after a later catalog or project change; it fails before storing anything and retains the pending boundary. + +Treat `PREAMBLE_SELECTION_COMPOSITION_INVALID` and `PREAMBLE_SLASH_COMMAND_BLOCKED` as the two recoverable preamble admission errors through one shared predicate. `AcceptedInputHandler` retains a prepared new-chat, fork, or continuation target for either error instead of compensating it, allowing the user to reconfigure and retry. `QueueDrainer` consumes the offending queued entry, publishes the ordinary actionable turn-failure notice, preserves the boundary, and continues draining later entries for either error. Every other admission error retains its existing compensation and drain behavior. + +The composition error message is actionable and body-free: `Selected preambles can’t be applied in this order. Reconfigure this chat’s preambles and try again.` + +The provider slash-command rule uses the eligible selected set. A slash command is blocked only when at least one selected entry is currently eligible. Missing selected IDs are never an error and never block slash commands by themselves. + +Same-`clientMessageId` input retries continue to return the original committed input and captured prefix consequence. They do not resolve a newer selection or catalog snapshot and do not redispatch. + +## Transcript, Privacy, Reload, and Fork Semantics + +Two separate durable facts exist: + +| Fact | Detail | Meaning | +| --- | --- | --- | +| Configuration changed | `preamble-selection-changed` | Entries eligible in the Save's catalog snapshot | +| Prefix applied | `preamble-application` | Entries actually composed into one accepted provider prompt | + +Both render immutable title snapshots. Neither contains bodies, scopes, paths, catalog revisions, or tombstone data. Only the application input carries its private prefix receipt and boundary proof. + +The update notice follows ordinary presentation-only notice folds: + +| Surface | Update notice | Body | +| --- | --- | --- | +| Live transcript, replay, background cache | yes | no | +| Search and preview | no | no | +| Resend, model context, carryover | no | no | +| Share snapshot | yes | no | +| Ordinary export | yes | no | +| Frozen projection | no | no | +| Manual native Reload of current binding | dropped | no | + +The application notice retains all behavior from the governing design: adjacent atomic commit with its input, receipt-backed native sanitation, frozen-projection preservation, native-fidelity reconstruction, and title-only sharing/export. + +Do not collect update notices as preamble history evidence. Do not infer selection from application/update notices after Reload, fork, continuation, restart, or catalog deletion. Registry configuration is authoritative. + +Fork configuration copies the source registry selection current at fork preparation, even for a historical watermark. The fork's transcript seed follows existing frozen/native rules independently. Continuation does the same at its target preparation. Both target lists are cloned and start at revision zero. A later edit to either source or target cannot alias the other. + +Preamble bodies remain absent from ledger rows, selection state, notices, API projections, WebSocket events, browser caches for selection metadata, search, preview, resend, carryover, shares, and ordinary exports. Existing receipt sanitation remains the only native-history body-removal authority. Logging may include chat ID, revisions, IDs, eligibility reasons, and error codes, but never content, composed prompts, or native excerpts. + +## Browser Architecture and UX + +Keep catalog management and selection editing separate. + +### Shared selection panel + +Add a focused selection panel under `web/src/lib/components/preambles/` and reusable controller/state under `web/src/lib/preambles/`. It renders: + +- selected entries in chat order; +- add/remove and accessible move up/down controls; +- `Disabled globally`, `Outside this project`, and `Deleted or unavailable` states without removing the ID; +- `None enabled` when the eligible projection is empty; +- a `Manage catalog` action that returns to the same draft; +- the required next-message/current-response explanation. + +The panel composes existing small catalog row/pill primitives where appropriate. It does not turn `PreamblesSection` into a multi-mode component and does not duplicate catalog mutation logic. + +### New chat + +`NewChatFormState` owns: + +```ts +type NewChatPreambleChoice = + | { readonly mode: 'defaults' } + | { readonly mode: 'explicit'; readonly orderedPreambleIds: readonly PreambleId[] }; +``` + +The form shows `Preambles · N` and a `Configure` button. While mode is `defaults`, a validated path change requests an omitted-ID selection preview and stale responses are ignored. Once the user customizes, path changes preserve the explicit list and request an explicit-ID preview. `Reset to defaults` returns to defaults mode; it does not silently reseed. + +An untouched form omits `orderedPreambleIds` from creation so the server resolves the newest defaults at actual creation. Explicit mode sends the exact list, including empty. Dialog reseed resets the choice to defaults and must not disturb the first-message draft when the nested picker opens or catalog management returns. + +### Existing chat and window menu + +Add `Configure preambles` to `CurrentChatMenuItems.svelte` and use the existing surface-menu path into `WorkspaceWindowMenu`. Do not put chat business logic in the generic window menu. The callback captures the chat belonging to the clicked surface before opening the dialog. Non-chat surfaces have no action; mobile menu parity is retained. + +The app shell owns only modal visibility and the captured `{chatId, transcriptViewId}` target. `ChatPreambleSelectionController` owns base selection revision, draft order, request identities, dirty state, loading/saving/error state, invalidation handling, and stale-response protection. No permanent all-chat cache is added. + +Save uses one gate for click, Enter, and shortcut submission. While saving, show a spinner, set `aria-busy`, and disable duplicate submission. Do not optimistically add a transcript row. Preserve the draft on validation, conflict, or unknown failure. A `selectionCommitted: true` partial result advances the base snapshot and presents its explicit warning. + +Dialog nesting uses the established Svelte 5 `Dialog`/FocusScope primitives. Catalog management, directory selection, and expanded prompt editing restore focus to their captured opener. Touch layouts remain full-screen through 768 px with controls computed at 16 px or larger. Selected pills and unavailable labels wrap without horizontal overflow. + +Rapid chat switching must not remount the active composer or move its dock/feed. Background `chat-messages` and `chat-preambles-invalidated` events update the intended chat state without retargeting the open Save request. + +## Failure Semantics + +| Window or condition | Outcome | +| --- | --- | +| New catalog ID is malformed, active, or retired | Creation rejects; no retry loop or write | +| Catalog lifetime-ID or file-size ceiling reached | Creation rejects; no tombstone is pruned and no ID reused | +| Catalog deletion succeeds | Active row removed and UUID retired in one catalog write | +| Catalog write fails before rename | Old catalog snapshot remains authoritative; no invalidation | +| Catalog rename succeeds but directory sync is unknown | Candidate catalog/tombstone snapshot remains installed; mutations fence and clients receive invalidation | +| Selected ID was deleted | ID stays selected, projects as `missing`, and is skipped at application | +| Selected ID is disabled or out of scope | ID stays selected with its reason and is skipped | +| Catalog later creates another preamble | Its UUID cannot equal any retired selected ID | +| Selection request contains duplicate or non-UUID IDs | Validation failure; no normalization or partial Save | +| Expected selection revision is stale | Conflict with current selection/projection; no notice or boundary change | +| Ordered list is unchanged | `unchanged`; no revision, notice, invalidation, or boundary change | +| Changed selected order is unsafe at Save or explicit creation preparation | Request rejects before selection persistence | +| Identical list is saved after a catalog edit made it unsafe | `unchanged`; no validation or mutation; any already-pending boundary remains pending | +| Catalog or project change makes composition unsafe before application | Typed input rejection before ledger commit; boundary remains armed | +| Prepared new-chat/fork/continuation input hits unsafe composition | Prepared target is retained for reconfiguration and retry | +| Queued input hits unsafe composition | Entry is consumed with an actionable turn failure; boundary remains armed and later entries continue draining | +| Save while a provider run is active | Selection and notice commit; current run is untouched; next ordinary input applies | +| Ordinary input races Save | Selection/admission lock gives a total order; input is wholly before Save or after its notice attempt | +| Queued input reaches the head during Save | Dequeue waits outside its synchronous callback; the entry is neither lost nor paused permanently | +| Registry failure before atomic rename | Old selection remains authoritative; no notice | +| Registry rename succeeds but durability is unknown | New in-memory state retained, typed unknown result, further Saves fenced until reconciliation | +| Crash after registry commit, before notice | Selection and boundary survive; update notice may be absent permanently | +| Ledger notice append fails after registry commit | Typed `selectionCommitted: true` partial failure; never claim rollback | +| Invalidation is lost with process crash | Reconnect refreshes loaded selection; no synthetic notice | +| Multiple Saves precede input | Each successful change has an update notice; latest selection-change boundary replaces the older one | +| Save occurs with unconsumed lifecycle boundary | Lifecycle boundary retained and resolves latest selection | +| Stale persisted boundary already has ledger proof | Save does not retain it; new changed selection gets a selection-change boundary | +| Steer/control follows Save | Does not consume selection-change boundary | +| Next ordinary input has zero eligible entries | Stores boundary proof, no application notice/prefix | +| Duplicate completed Save request | Original update notice returned, no second write or broadcast | +| Same notice identity with changed request | `IDEMPOTENCY_CONFLICT` | +| Manual Reload | Selection remains; Garcon-only update notices in replaced current binding may be lost | +| Fork/continuation after update notice loss | Current registry selection still copies correctly | + +## Migration, Rollout, and Rollback + +### Migration + +- Migrate `preambles.json` version 1 to version 2 with an empty retired-ID set after validating canonical active UUID v4 IDs. +- Keep `chats.json` at version 5. Missing `preambleSelection` becomes revision-zero empty selection. +- Existing pending lifecycle boundaries remain readable. If their old chat lacks selection, their first input consumes with zero matches. +- Existing ledger rows and version-one prefix receipts remain readable unchanged. +- The new update notice uses the existing `notice` row kind, so no SQLite schema or `PRAGMA user_version` change is required. +- The ledger codec gains a new private notice identity shape under the existing `client_message_id` index. +- Server and browser contracts deploy together; no dual API period is required. + +### Rollout order + +- Land UUID/tombstone migration and selection contracts before accepting selections. +- Land registry selection normalization/cloning and inheritance before changing admission resolution. +- Land complete boundary identity and selected-order validation before arming `selection-change`. +- Land private/public update-notice parsing and idempotency before exposing Save. +- Land REST/WS and browser controller before the menu/new-chat affordances. +- Enable the UI only with server, Lightpanda, and Chromium coverage in the same change. + +### Rollback + +A safe rollback stops accepting new selection changes and new selection-change boundaries while retaining: + +- `preambles.json` version-2 parsing and retired IDs; +- `chats.json` selection and boundary parsing; +- update/application notice parsing and rendering; +- existing receipt/native sanitation logic. + +Never downgrade the catalog to a representation that loses tombstones, reuse retired IDs, or remove receipt sanitation while native histories may contain framed prefixes. + +## Implementation Map + +### Shared contracts and catalog identity + +- Update `common/preambles.ts` with UUID validation, selection/projection contracts, the new boundary variant, and complete identity comparison. +- Update `common/transcript-notice-details.ts` with the public selection-changed notice. +- Update `common/ws-events.ts`, command contracts, and error-code unions. +- Update `server/preambles/store.ts` to migrate/version tombstones and enforce lifetime/file bounds. +- Update `server/preambles/service.ts` to validate generated IDs and collision outcomes. +- Add selected-order resolution/composition validation and the shared recoverable-admission predicate beside existing preamble matching and budget logic. + +### Registry, inheritance, and admission + +- Update every `server/chats/store.ts` normalization, construction, patch, rollback, and clone path. +- Update start normalization and identity payloads in `common/chat-command-contracts.ts`, `server/commands/command-support.ts`, and `server/commands/start-commands.ts`. +- Update fork and continuation target construction to clone source IDs at revision zero. +- Preserve selection through `server/chats/agent-ownership-journal.ts` roll-forward. +- Update `server/ledger/preamble-application.ts`, ledger contracts/codecs, Reload, and `server/agents/registry.ts` to compare complete boundary identity and resolve selection order. +- Add the narrow selection/admission lock to direct admission and the outer queued dequeue step. + +### Existing-chat service and transport + +- Add `server/preambles/chat-selection-service.ts` as the existing-chat operation owner. +- Add `common/chat-preamble-selection-contracts.ts` for strict request/response parsing. +- Add authenticated GET/PUT chat-selection routes and the new-chat selection-preview route. +- Add an idempotent ledger selection-notice append and private codec detail. +- Route committed row and per-chat invalidation events through `server-event-wiring` in order. + +### Browser + +- Add API adapters under `web/src/lib/api/` and an existing-chat controller under `web/src/lib/preambles/`. +- Add a focused selection panel/dialog under `web/src/lib/components/preambles/`. +- Extend `NewChatFormState`, `NewChatForm.svelte`, draft startup state, and request composition. +- Add the captured chat action through `CurrentChatMenuItems.svelte` and the existing workspace surface-menu path. +- Add Paraglide keys and run `cd web && bun run i18n:compile`. + +No production or test file under `server-agents/**` changes for this feature. + +## Verification Plan + +### Shared and store contracts + +- Accept canonical lowercase UUID v4; reject uppercase, other UUID versions, malformed strings, whitespace, and control characters. +- Migrate a valid version-1 catalog; reject an invalid legacy ID without rewriting it. +- Reject active/active, retired/retired, and active/retired duplicates. +- Prove deletion moves an ID to tombstones atomically and restart preserves it. +- Inject an active and retired collision from `newId`; creation rejects without looping or revision change. +- Prove lifetime and file-size ceilings fail closed and tombstones are never pruned. +- Cover missing versus explicit empty selection, duplicate IDs, count/revision bounds, malformed persistence, and clone isolation. + +### Resolution and application + +- Selection order overrides catalog order. +- Catalog reorder leaves saved order unchanged. +- Missing, disabled, and out-of-scope IDs remain in selection with the correct reason and are skipped. +- A retired missing ID never resolves after later catalog creation. +- Actual selected-order delimiter and length validation run for changed Save, explicit creation, and application; identical Save remains `unchanged` without validation. +- Repeated selection Saves in one ownership epoch apply independently through `selectionRevision`. +- Lifecycle boundary retention and stale-proof repair are exact. +- Empty and all-unavailable selections consume the boundary with no application notice. +- Slash blocking considers eligible selected entries only. +- Composition and slash rejections both retain prepared targets and consume rejected queued entries without wedging the drain. +- Same-ID input retry does not resolve new selection or catalog state. + +### Persistence and concurrency + +- Selection and pending boundary persist in one registry write. +- Fault before rename leaves old state; fault after rename returns unknown without reverting memory. +- Catalog post-rename failure installs and fences the tombstoned candidate so no later mutation can resurrect its ID. +- Fault after registry commit and before notice produces the documented saved-without-notice state after restart. +- Ledger notice failure returns `selectionCommitted: true` and does not compensate. +- Successful notice retry is idempotent; changed/cross-kind identity conflicts. +- Two clients conflict by selection revision. +- Save versus direct admission and Save versus queued dequeue each produce only the two allowed total orders. +- Queue entry remains present while Save holds the admission lock and drains after release. +- Save during a held provider response causes no provider request, steer, cancel, restart, or composer remount. +- Save serializes with Reload, project-path update, deletion, fork/continuation capture, and agent switch. + +### Lifecycle inheritance + +- New defaults, explicit order, and explicit none persist and apply. +- Existing missing field remains empty. +- Historical-point and tip forks copy source-current selection, start at revision zero, and diverge independently. +- Continuation/self-handoff copy source-current selection. +- In-place agent switch preserves selection/revision and applies through its lifecycle boundary. +- Scheduled new chat resolves defaults at creation; scheduled existing input preserves selection. +- Delegated new chat uses defaults rather than implicit parent inheritance. + +### Notice, privacy, and native operations + +- Update notice allows empty refs and renders `None enabled`. +- Historical update/application titles survive rename and deletion. +- Update notice never enters preamble application evidence or conversational folds. +- Manual Reload retains registry configuration while accepting update-notice lossiness. +- Application receipt/native sanitation remains exact after selection and catalog deletion. +- A unique synthetic body is absent from every ledger payload other than no location, HTTP/WS payload, rendered DOM, search, preview, resend, carryover, share, and ordinary export. +- `git diff origin/main -- server-agents` is empty. + +### Browser coverage + +Extend Lightpanda for the complete workflow and Chromium for rendering/focus/layout contracts: + +- untouched defaults, explicit customization/order/none, reset to defaults, creation persistence; +- configure from New Chat without losing path, message, images, tags, or model settings; +- configure from the correct window's menu in a two-window/two-chat scenario; +- switch active chats during Save and prove the captured target receives the selection and notice; +- held provider response: spinner visible, no additional request, next regular message applies once; +- missing selected entry after catalog deletion while the picker is open; remaining list saves and applies; +- dirty invalidation conflict, double-click/Enter parity, retry, reconnect, and browser reload; +- background update/application notice delivery from WebSocket cache without target-history refetch; +- composer element identity, value, focus, geometry, feed scroll, and dock position remain stable; +- dialog focus containment/restoration on desktop, touch, and 641–768 px coarse-pointer layouts; +- wrapping title pills, empty notice, narrow/touch layout, and zero body leakage. + +Use durable `data-slot` selectors for selection rows, dialogs, scroll bodies, status labels, and notice pills. Do not use sibling relationships, element names, or Tailwind utility classes as test selectors. Wait for browser-visible lifecycle state after WebSocket delivery, and block/observe target history reads where a cache-delivery assertion depends on them. + +### Required commands + +After implementation: + +```sh +cd web && bun run i18n:compile +bun run check +bun run lint +bun run test +timeout 30s bun run start --port 0 +``` + +Run focused shared, registry, preamble, ledger, command, fork, handoff, server integration, Lightpanda, and Chromium suites before the complete gate. Routine integration tests use scripted providers at their lowest supported reasoning effort; credential-backed live suites remain CI-only unless their tests themselves change. + +## Alternatives Rejected + +### Treat catalog `enabled` as only a default + +Rejected because it would let a chat apply a workspace-disabled instruction and invalidate existing catalog budget and safety expectations. + +### Remove unavailable IDs from chat selections + +Rejected because a temporary disable/scope mismatch is not deselection, and silent cleanup destroys the user's ordering intent. Deleted IDs are cheap bounded references and permanent tombstones make them safe. + +### Reuse deleted IDs after proving no current chat selects them + +Rejected because notices and external stale clients may still carry the identity, scanning every durable surface is brittle, and UUID plus bounded tombstones makes reuse unnecessary. + +### Store full preamble definitions per chat + +Rejected because bodies and paths would be duplicated into enumeration state, catalog edits would have ambiguous propagation, and privacy/storage boundaries would weaken. + +### Resolve selected entries by filtering catalog order + +Rejected because it ignores the chat's explicit order. + +### Apply immediately when Save occurs + +Rejected because it would steer, restart, or synthesize provider work during an active run and would not correspond to a user-authored ordinary input. + +### Append the update notice before registry persistence + +Rejected because the transcript could announce a selection that never became authoritative. + +### Compensate selection after notice failure + +Rejected because registry atomic-write outcome can be ambiguous and compensation can overwrite a committed selection or race later changes. + +### Add a recovery journal + +Rejected for this lean version. It would close the selection-without-notice crash window but introduces preparation, recovery, cleanup, power-ordering, and admission-fence machinery disproportionate to a presentation-only notice. Reconsider only if exact configuration history becomes a product invariant. + +### Reuse `preamble-application` for Save + +Rejected because application evidence requires an adjacent receipt-bearing input and participates in native sanitation. Update is a different fact. + +### Preserve update notices through native Reload + +Rejected because provider-native history has no authoritative position for Garcon-only configuration changes. Configuration survives in `chats.json`; application history remains receipt-backed. + +## Acceptance Criteria + +- Every chat has a normalized, revisioned, independently ordered preamble selection. +- New-chat defaults, explicit none, fork/continuation copying, scheduled creation, and agent-switch preservation follow the matrix above. +- Existing chats with no field remain empty and are not retroactively populated. +- Deleted preambles remain selected as `missing`, never fail application, and can never be resurrected under the same ID. +- All active and retired catalog IDs are canonical UUID v4 values; deletion permanently retires IDs under explicit lifetime/file bounds. +- Catalog enabled/scope constraints and chat order jointly determine the eligible application set. +- Live Save is allowed during a run, changes no current provider work, and applies only at the next ordinary input. +- Direct and queued admission cannot overtake the Save's update-notice attempt. +- Selection-change proof identity includes revision, enabling repeated Saves in one ownership epoch. +- `Preambles updated` and `Preambles applied` are distinct typed, title-only facts with correct read folds. +- Registry-first/notice-second failure is reported accurately, and the accepted missing-notice crash window is documented and tested. +- Selected-order separator and size safety are validated at Save and again at application. +- Bodies remain absent from all Garcon presentation and conversational surfaces. +- REST and WebSocket contracts are typed and tested on sender and receiver paths. +- New-chat and window-menu selectors target the intended chat through rapid switches and multi-window use. +- Browser coverage proves loading, focus, responsive layout, background cache delivery, composer stability, and privacy. +- `server-agents/**` remains unchanged. diff --git a/PREAMBLES_DESIGN.md b/PREAMBLES_DESIGN.md new file mode 100644 index 0000000000..b1c9bcb252 --- /dev/null +++ b/PREAMBLES_DESIGN.md @@ -0,0 +1,1573 @@ +# Preambles Design + +Status: execution-ready proposal + +Audience: Garcon server, transcript, provider-integration, and web engineers + +Governing design: `docs/transcript-ledger-v5-design.md`, revision 34 + +## Summary + +Preambles are reusable, ordered blocks of user-authored text that Garcon prepends to the first ordinary provider prompt at a chat boundary. A preamble is enabled or disabled and is either global or scoped to a set of canonical project paths. Each path rule independently chooses exact-only or exact-and-nested matching. Every enabled matching preamble applies once, and the catalog's visible list order is the only composition order. + +The boundary set is: + +- a new chat, including a scheduled new chat; +- the first prompt in a fork, including a prompt sent after a promptless fork was created; +- a continuation/self-handoff into a new chat; +- the first prompt after an in-place cross-agent handoff. + +Ordinary turns, steers, queued turns in an established binding, compaction, and server control inputs do not receive preambles. + +Preamble bodies remain absent from Garcon's rendered transcript. When at least one preamble applies, the ledger atomically stores a typed presentation-only notice immediately before the visible user input: + +```text +Preambles applied: [Repository conventions] [Security constraints] +``` + +The notice stores an immutable `{ id, title }` snapshot for each application. It never stores a preamble body, scope, or project path. At application time, core expands an active `{{chat_id}}` token in each matching body to the target chat's 16-digit ID; `\{{chat_id}}` produces the literal token. The stored body remains unchanged, and only its expanded form is present in the private outbound provider prompt. Native-history import removes the exact injected prefix and reconstructs the notice, so Reload and native-fidelity forks do not reveal the injected text as user-authored history. + +## Product Decisions + +The following are settled requirements, not implementation options: + +- A preamble has a stable ID, required title, required body, and either global scope or one or more project-path rules. +- New preambles are enabled by default. Disabled preambles stay in the catalog, retain their order, and remain searchable and editable, but never match or apply. +- Enabled state is editable both from the form and from a quick switch on each catalog row. Disabled rows are visually muted and labeled `Disabled`. +- A path-scoped preamble may contain several rules. The rules use OR semantics. +- Each rule has its own `includeNested` value. +- One preamble applies at most once even when several of its rules match. +- Several preambles may apply to one boundary. +- Catalog list order exclusively determines composition order. Scope specificity does not reorder entries. +- Global preambles may appear anywhere in the list, including both the beginning and end. +- Create, edit, delete, and reorder are supported. +- Enable and disable are ordinary revision-checked updates. Re-enabling reruns whole-catalog combined-budget validation and may fail without changing the row. +- The catalog filter matches title, body, and canonical project path. Matching is case-insensitive and client-side. +- Reordering is unavailable while a filter is active. This avoids inventing an ordering result for hidden entries. +- Forks resolve the current catalog when their first new prompt is accepted. They do not inherit a frozen copy of the source catalog. +- A promptless fork applies nothing at creation. Its first later user prompt consumes the pending fork boundary. +- Preamble bodies are not rendered as transcript rows. +- A typed, presentation-only notice is inserted immediately before the boundary user input only when at least one preamble applies. +- Notice titles are historical snapshots. Rendering never resolves current titles from the catalog. +- The version-one prefix frame contains only its format version. Exact receipt length and SHA-256 correlate native occurrences; no per-application identifier is sent to the provider or stored in the receipt. The digest covers the prefix's exact JavaScript UTF-16 code units serialized as unsigned 16-bit little-endian values, so lone surrogates cannot alias U+FFFD through UTF-8 replacement. +- Preamble bodies support the shared `{{chat_id}}` template token used by snippets and scheduled prompts. Expansion uses the target chat ID at boundary admission. An escaped `\{{chat_id}}` becomes the literal `{{chat_id}}`; unsupported template variables remain unchanged. +- Existing application notices remain when history is copied. A fork's first new prompt receives an additional notice for the catalog current at that new boundary. +- The notice appears in Garcon, share snapshots, and ordinary human-readable exports. It is excluded from search, previews, resend, carryover/model context, and provider input. +- Bodies are never silently truncated. Invalid over-budget catalog mutations are rejected. +- If a boundary has one or more enabled matching preambles and its first otherwise-unhandled input begins with `/` after leading whitespace, core rejects it before ledger admission with: `Matching preambles haven’t been sent yet. Start with a regular message before using provider slash commands.` The boundary remains armed. Garcon-owned commands are handled before this guard. If zero enabled preambles match, a provider slash command is allowed and consumes the zero-match boundary. +- The server owns canonical path resolution, matching, ordering, prompt composition, and the applied set. A client never submits resolved preamble IDs or content with a chat command. + +Duplicate titles are allowed. The stable ID disambiguates records, while the immutable title snapshot preserves historical display after rename or deletion. Preamble pills are non-interactive in the first release; retaining IDs supports diagnostics and a future editor-navigation action without changing stored history. + +## Goals + +- Give users one maintainable catalog of reusable startup instructions. +- Apply the correct ordered set at every supported boundary and nowhere else. +- Make promptless forks and crash/retry windows deterministic. +- Preserve ledger durable-before-dispatch and submission idempotency. +- Keep bodies out of every Garcon presentation and conversational fold. +- Sanitize stateful providers' native histories without provider-specific core logic. +- Reuse the scheduled-prompts list interaction, snippet prompt editor, directory browser, optimistic revision, and invalidation patterns. +- Keep server, shared, and browser contracts explicit and fully validated. +- Keep preamble delivery entirely in provider-neutral core; integrations continue receiving their existing single prompt string. +- Reuse the existing shared template-token grammar and make the chat-ID capability visible below the preamble text composer. + +## Non-goals + +- Replacing repository `AGENTS.md` discovery or changing any provider's native instruction-file behavior. +- Applying preambles to every turn in an existing chat. +- Applying preambles to steers, queued ordinary turns, permission replies, compaction controls, inter-agent control delivery, chat-ID discovery, or other server-originated controls. +- Teaching provider integrations about preamble catalogs, boundaries, receipts, or delivery policy. +- Showing, editing, or expanding a preamble body from the transcript notice. +- Freezing catalog content into a fork at fork-creation time. +- Scope by agent, model, tag, branch, workspace name, glob, or regular expression. +- Template variables other than `{{chat_id}}`; `{{arguments}}`, `{{project_path}}`, and unknown variables remain literal. +- Project-path exclusions or negative rules. +- Treating preamble content as secret. It is sent to the provider and may be retained or repeated by that provider. +- Providing a migration from another startup-prefix feature. No such persisted catalog exists. + +## Terminology + +- **Catalog**: the ordered workspace-level array in `preambles.json`. +- **Rule**: one canonical project path and its `includeNested` choice. +- **Boundary**: a durable pending condition on a new/current chat binding whose first accepted user input is eligible for preambles. +- **Application**: the ordered preamble set selected for one boundary input. +- **Prefix**: the private framed text prepended to the composed provider prompt. +- **Application notice**: the public presentation-only transcript row containing only applied IDs and historical titles. +- **Prefix receipt**: private ledger evidence used to remove the exact prefix from native imports. It contains no body. +- **Rendered body**: the transient body produced by substituting the target chat ID into active `{{chat_id}}` tokens and unescaping `\{{chat_id}}` tokens. + +## Current System + +### Catalog and editor precedents + +Scheduled prompts provide the closest ordered-list and multi-client mutation precedent: + +- `common/scheduled-prompts.ts` defines versioned contracts, count/body limits, snapshots, mutations, and invalidation reasons. +- `server/scheduled-prompts/store.ts` owns `scheduled-prompts.json`, mode `0600`, optimistic revisions, and reorder validation. +- `server/routes/scheduled-prompts.ts` exposes CRUD and `/reorder` routes. +- `web/src/lib/api/scheduled-prompts.ts`, `web/src/lib/scheduling/scheduled-prompts-store.svelte.ts`, and `web/src/lib/events/scheduled-prompts-router.svelte.ts` own browser transport and invalidation refresh. +- `web/src/lib/components/settings/ScheduledPromptsDialog.svelte`, `ScheduledPromptsSection.svelte`, `ScheduledPromptDialog.svelte`, `ScheduledPromptRow.svelte`, and `scheduled-prompt-form-state.svelte.ts` provide the list/dialog interaction. + +Snippets provide the better prompt-body editor and strict catalog precedent: + +- `common/snippets.ts` uses a 100-entry limit, 32,000-character templates, and 64,000-character expanded output. +- `server/snippets/store.ts` writes an atomic, versioned `snippets.json` with mode `0600` and optimistic revisions. +- `web/src/lib/components/snippets/SnippetFormDialog.svelte` composes `PromptTextField` with `PromptEditorDialog` and a rune-backed form state. + +`web/src/lib/components/chat/DirectoryBrowser.svelte` is the existing project directory selector. `server/lib/path-boundary.ts` is the server authority for real-path and project-base containment. `web/src/lib/utils/project-path.ts` and `web/src/lib/components/sidebar/sidebar-row-model.ts` show the separator-bounded ancestor relationship used to group nested paths in the sidebar. Scope matching must implement the same relationship on canonical native server paths rather than reusing a browser-normalized string as authority. + +### Input admission and provider composition + +The accepted direct-input path is: + +```text +command service + -> AcceptedInputHandler.#prepareDirect() + -> AgentRegistry.admitInput() + -> AgentRegistry.#commitInput() + -> TranscriptLedgerService.appendInputAndCompose() + -> TranscriptLedgerStore.appendInputAndCompose() + -> RuntimeRouter.#preparePrompt() + -> provider integration +``` + +Relevant files are: + +- `server/commands/start-commands.ts` for new chats; +- `server/commands/fork-commands.ts` and `server/chats/fork-chat.ts` for promptful and promptless forks; +- `server/commands/self-handoff-commands.ts` for continuation/self-handoff; +- `server/commands/session-commands.ts`, `server/agents/agent-handoff-command.ts`, and `server/agents/agent-handoff-service.ts` for in-place switches; +- `server/chat-execution/accepted-input-handler.ts`, especially `#prepareDirect()`; +- `server/agents/registry.ts`, especially `admitInput()` and `#commitInput()`; +- `server/ledger/service.ts` and `server/ledger/store.ts`; +- `server/agents/runtime-router.ts`, especially `#preparePrompt()`. + +`TranscriptLedgerStore.appendInputAndCompose()` currently commits one `user-input`, computes the backward resend scan in the same synchronous transaction, and returns `InputComposition`. `TranscriptLedgerService` broadcasts only that input when inserted. `RuntimeRouter.#preparePrompt()` joins composed visible user inputs with `\n\n`, resolves file mentions, and sends one string to an integration. + +The steer path already distinguishes visible `content` from `providerContent` in `AcceptedSteerInput`. Preambles need the same conceptual separation, but only for turn-starting boundary inputs. + +### Transcript behavior + +The V5 ledger requires: + +- durable-before-dispatch; +- one transaction for a core-originated row group; +- dense, monotonic ordinals; +- idempotency by `(chatId, transcriptViewId, clientMessageId)`; +- committed row fanout through the per-chat server-event queue; +- explicit read-fold membership; +- notices excluded from resend and model context. + +`server/ledger/projection.ts` currently preserves only the typed carryover-quarantine notice in frozen projections. `server/ledger/imported-drafts.ts` has the parallel presentation-message projection and also drops ordinary notices. A preamble application notice needs an explicit preservation rule in both paths. + +`common/transcript-notice-details.ts`, `server/ledger/presentation.ts`, `web/src/lib/components/chat/rows/TranscriptNoticeRow.svelte`, and `web/src/lib/components/chat/ConversationMessage.svelte` own typed notice contracts and rendering. + +Shares and exports are separate projections: + +- `server/chats/share-transcript.ts` creates self-contained share snapshots. +- `server/ledger/export-fold.ts` produces typed export entries. +- `server/chats/transcript-export/markdown.ts`, `xml.ts`, and `values.ts` render ordinary exports. + +### Native-history privacy problem + +Stateful providers persist the actual composed prompt. If Garcon merely prepends raw preamble text at dispatch, Reload or a native-fidelity fork imports that provider prompt as an ordinary user message and reveals the body. + +The existing carried-context mechanism is the relevant precedent: + +- `common/transcript-seed.ts` defines `NativeSeedReceipt` with placement, format, code-unit length, and SHA-256. +- `sanitizeRecordedCarriedContext()` strips only an exact recorded prefix and reports a mismatch rather than heuristically deleting content. +- `server/ledger/native-history-seed.ts` runs that sanitation after the provider integration returns normalized history and before `importedDrafts()` creates ledger rows. + +Provider history loaders also strip exact Garcon-added file mention context through `server-agents/common/src/shared/file-mention-context.ts`. That sanitation is provider-owned because integrations parse their own formats; preamble sanitation belongs in the provider-neutral normalized-history stage because the injected prefix format is core-owned. + +The three Direct integrations currently persist their composed requests in `server-agents/common/src/direct/session-store.ts` and expose them through `createDirectNativeHistoryImport()` in `server-agents/common/src/direct/native-session.ts`. They therefore require the same normalized-history sanitation on Reload even though they do not support native-fidelity fork. + +### External references + +The web design targets the repository's pinned Svelte 5.56.10 and follows the official Svelte 5 rune guidance for [`$props`](https://svelte.dev/docs/svelte/$props), [`$derived`](https://svelte.dev/docs/svelte/$derived), and [`$effect`](https://svelte.dev/docs/svelte/$effect), consulted 2026-09-03. Derived validation belongs in getters/`$derived`; effects are limited to dialog, focus, editor, and subscription lifecycles. Server path ancestry follows the platform behavior documented for [`node:path.relative`](https://nodejs.org/api/path.html#pathrelativefrom-to), with canonical real paths on both sides. + +## Proposed Architecture + +```text +preambles.json + | + | ordered, synchronous snapshot lookup by canonical project path + v +pending binding boundary + accepted visible input + | + | one synchronous ledger transaction + +--> notice row: IDs + immutable title snapshots only + +--> user row: visible body + private boundary proof + prefix receipt + | + | immutable in-memory InputComposition + v +resend fold -> file mention resolution -> private prefix prepend -> provider + | + v + native history + | + carried-context sanitation first + | + exact preamble receipt sanitation + | + reconstructed notice + visible user row only +``` + +There are four ownership boundaries: + +- `PreambleStore` owns the ordered persisted catalog, revision-first mutation ordering, and authoritative whole-catalog composition validation under its mutation lock. +- `PreambleService` owns request normalization, canonical path validation, synchronous matching, and invalidation publication. +- The chat registry owns whether the current binding still has a pending preamble boundary. +- The transcript ledger owns atomic consumption proof, the historical notice, and the private prefix receipt. + +The browser is never an authority for matching or applied content. + +## Data Model and Limits + +Add `common/preambles.ts`: + +```ts +export const PREAMBLE_MAX_COUNT = 100; +export const PREAMBLE_TITLE_MAX_CODE_POINTS = 120; +export const PREAMBLE_CONTENT_MAX_LENGTH = 32_000; +export const PREAMBLE_COMBINED_MAX_LENGTH = 64_000; +export const PREAMBLE_PATH_RULE_MAX_COUNT = 32; + +export interface PreambleProjectPathRule { + readonly projectPath: string; + readonly includeNested: boolean; +} + +export type PreambleScope = + | { readonly type: 'global' } + | { + readonly type: 'project-paths'; + readonly rules: readonly PreambleProjectPathRule[]; + }; + +export interface Preamble { + readonly id: string; + readonly enabled: boolean; + readonly title: string; + readonly content: string; + readonly scope: PreambleScope; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface PreambleDefinitionInput { + readonly enabled: boolean; + readonly title: string; + readonly content: string; + readonly scope: PreambleScope; +} + +export interface PreamblesSnapshot { + readonly revision: number; + readonly preambles: readonly Preamble[]; +} + +export interface CreatePreambleRequest { + readonly expectedRevision: number; + readonly preamble: PreambleDefinitionInput; +} + +export interface UpdatePreambleRequest extends CreatePreambleRequest { + readonly id: string; +} + +export interface RemovePreambleRequest { + readonly expectedRevision: number; + readonly id: string; +} + +export interface ReorderPreamblesRequest { + readonly expectedRevision: number; + readonly orderedPreambleIds: readonly string[]; +} + +export interface PreamblesMutationResponse { + readonly success: true; + readonly snapshot: PreamblesSnapshot; +} + +export const PREAMBLES_INVALIDATION_REASONS = [ + 'created', + 'updated', + 'removed', + 'reordered', +] as const; +``` + +Normalization rules are: + +- IDs are nonblank opaque strings generated with `crypto.randomUUID()` and unique within the file. +- `enabled` is a required boolean in persisted and API contracts. The create form supplies `true` by default. +- Titles are trimmed, one line, nonblank, and at most 120 Unicode code points. Duplicate titles are valid. +- Content is stored byte-for-byte as a JavaScript string. `content.trim()` must be nonblank and `content.length` must not exceed 32,000 UTF-16 code units. Validation does not trim the stored body. Version one rejects the exact reserved file-mention separator `\n\nReferenced file contents from @file mentions:\n\n`. Whole-catalog validation also rejects any enabled matching composition whose rendered frame reconstructs that separator across a body or join boundary. Provider history loaders remove the delimiter before core sanitation, so permitting it anywhere in a rendered prefix could truncate the prefix and make its receipt unverifiable. +- The stored body is a template with exactly one supported variable, `{{chat_id}}`. Core uses `common/template-tokens.ts`: an active token expands to the target chat's 16-digit ID, `\{{chat_id}}` becomes a literal `{{chat_id}}`, and unsupported tokens remain byte-for-byte unchanged. Expansion occurs only for the transient boundary application; the catalog, mutation responses, filtering, and editor retain the authored template. +- Global scope contains no path rules. +- Project-path scope contains 1 through 32 rules. +- Every stored path is absolute, server-canonical, inside the project base, and names an existing directory at create/update time. +- Two rules in one preamble may not resolve to the same canonical path. The client prevents obvious duplicates and the server rejects canonical duplicates, including symlink aliases. +- Unknown fields or malformed discriminants fail request parsing. A malformed persisted file fails initialization rather than silently dropping user preambles. +- A whole-catalog validation pass rejects any catalog whose applicable composition can exceed 64,000 code units including framing and separators. +- Disabled entries are excluded from matching and every combined-budget candidate. Re-enabling an entry validates the candidate enabled catalog before persistence. + +One hundred entries aligns with snippets and bounds list rendering and typed notice size. Thirty-two path rules permit a practical project set while bounding mutation validation. The 32,000/64,000 body limits align with existing prompt editing and expansion limits. No runtime truncation occurs. + +## Persistence + +Store the catalog in `/preambles.json`, separate from remote settings, `scheduled-prompts.json`, `snippets.json`, `chats.json`, and every chat ledger: + +```json +{ + "version": 1, + "revision": 3, + "preambles": [ + { + "id": "08ccdf80-1d8c-4bc6-b68f-357e503b9fe9", + "enabled": true, + "title": "Repository conventions", + "content": "Follow the repository's local conventions.", + "scope": { "type": "global" }, + "createdAt": "2026-09-03T10:00:00.000Z", + "updatedAt": "2026-09-03T10:00:00.000Z" + }, + { + "id": "4f20a535-f152-49af-b76c-28c60db6b2a3", + "enabled": false, + "title": "Garcon worktrees", + "content": "Read the governing transcript design before transcript work.", + "scope": { + "type": "project-paths", + "rules": [ + { + "projectPath": "/garcon", + "includeNested": true + }, + { + "projectPath": "/experiments/garcon-fixture", + "includeNested": false + } + ] + }, + "createdAt": "2026-09-03T10:05:00.000Z", + "updatedAt": "2026-09-03T10:05:00.000Z" + } + ] +} +``` + +`server/preambles/store.ts` follows `SnippetStore` and `ScheduledPromptStore`: + +- initialize once before command admission starts; +- keep one immutable in-memory snapshot; +- serialize mutations with `KeyedPromiseLock`; +- clone before returning values; +- compare `expectedRevision` before mutation; +- atomically write with mode `0600`; +- increment one safe-integer revision per successful mutation; +- replace the in-memory snapshot only after the atomic file write succeeds; +- preserve array order exactly; +- require reorder input to contain every current ID exactly once. + +Catalog mutation linearizes when the successfully written draft replaces the in-memory snapshot. Boundary resolution reads that snapshot synchronously. No `await` occurs between selecting the applicable entries and committing their application, so a mutation and an input have an unambiguous event-loop order. + +An absent file is an empty revision-zero catalog. No existing file or database migration is required. + +## Canonical Path Resolution and Matching + +`server/preambles/project-path-service.ts` should use `assertRealWithinProjectBase()` and `fs.stat()`, following `SnippetProjectPathService`. Create/update canonicalizes every rule before whole-catalog validation and persistence. The API returns canonical paths. + +Matching uses `node:path.relative`, not string prefixing: + +```ts +import path from 'node:path'; + +export function preambleRuleMatches( + rule: PreambleProjectPathRule, + canonicalProjectPath: string, +): boolean { + const relative = path.relative(rule.projectPath, canonicalProjectPath); + if (relative === '') return true; + return rule.includeNested + && relative !== '..' + && !relative.startsWith(`..${path.sep}`) + && !path.isAbsolute(relative); +} + +export function applicablePreambles( + preambles: readonly Preamble[], + canonicalProjectPath: string, +): Preamble[] { + return preambles.filter((preamble) => + preamble.enabled && (preamble.scope.type === 'global' + || preamble.scope.rules.some((rule) => + preambleRuleMatches(rule, canonicalProjectPath), + )), + ); +} +``` + +This yields the required behavior: + +| Rule | Chat path | Result | +| --- | --- | --- | +| `/work/a`, exact | `/work/a` | match | +| `/work/a`, exact | `/work/a/sub` | no match | +| `/work/a`, nested | `/work/a/sub` | match | +| `/work/a`, nested | `/work/ab` | no match | +| two matching rules in one preamble | any | one application | +| global between two path entries | any matching path | catalog order unchanged | + +Because scope paths and chat paths are canonical real paths, symlink aliases compare consistently. Scope matching does not touch the filesystem during admission. A directory may disappear after configuration without corrupting the catalog; the stored canonical path continues to match an already-canonical chat path. Editing the rule revalidates existence. + +### Whole-catalog combined budget + +The 64,000-character ceiling is enforced at catalog mutation instead of failing a later chat start. Disabled preambles do not participate. The candidate match points are: + +- every distinct configured canonical project path; and +- the global-only set. + +Any unlisted descendant matches the same nested-rule subset as one of its configured ancestors, minus exact-only rules, so these candidates cover the maximum possible applicable set. For each candidate, the shared catalog validator selects entries in catalog order, expands each body with a fixed 16-digit chat-ID sample, and renders the exact version-one prefix. Every valid chat ID has the same length, so this is exact for the combined length of every future application. `PreambleStore.#mutate()` runs the validator after checking `expectedRevision` and applying the requested change to its draft, but before incrementing the revision or writing, all under the mutation lock. Startup runs the same validator while normalizing the persisted file. Validation rejects a draft if the full expanded prefix exceeds `PREAMBLE_COMBINED_MAX_LENGTH` or contains the reserved file-mention separator, including when adjacent rendered bodies reconstruct the separator only after joining. + +The error is `PREAMBLE_COMBINED_LIMIT_EXCEEDED` and identifies the canonical path, or the global set, whose composition exceeds the limit. Reorder cannot alter length but still runs the same invariant validator to keep one mutation boundary. + +## API and Invalidation Contract + +Add these authenticated routes in `server/routes/preambles.ts` and register them in `server/routes/index.ts`: + +| Method | Route | Body | Success | +| --- | --- | --- | --- | +| `GET` | `/api/v1/preambles` | none | `PreamblesSnapshot` | +| `POST` | `/api/v1/preambles` | `CreatePreambleRequest` | `201 PreamblesMutationResponse` | +| `PUT` | `/api/v1/preambles` | `UpdatePreambleRequest` | `200 PreamblesMutationResponse` | +| `DELETE` | `/api/v1/preambles` | `RemovePreambleRequest` | `200 PreamblesMutationResponse` | +| `PUT` | `/api/v1/preambles/reorder` | `ReorderPreamblesRequest` | `200 PreamblesMutationResponse` | + +Representative mutation: + +```json +{ + "expectedRevision": 7, + "preamble": { + "enabled": true, + "title": "Monorepo conventions", + "content": "Prefer package-local validation before the repository gate.", + "scope": { + "type": "project-paths", + "rules": [ + { "projectPath": "/work/mono", "includeNested": true }, + { "projectPath": "/work/sandbox", "includeNested": false } + ] + } + } +} +``` + +Domain errors are explicit and routed through `jsonError`: + +- `PREAMBLE_VALIDATION_FAILED` (`400`); +- `PREAMBLE_PROJECT_PATH_NOT_DIRECTORY` (`400`); +- `PREAMBLE_PROJECT_PATH_OUTSIDE_BASE` (`403`); +- `PREAMBLE_PROJECT_PATH_INACCESSIBLE` (`403`); +- `PREAMBLE_PROJECT_PATH_NOT_FOUND` (`404`); +- `PREAMBLE_NOT_FOUND` (`404`); +- `PREAMBLE_REVISION_CONFLICT` (`409`, retryable); +- `PREAMBLE_LIMIT_REACHED` (`409`); +- `PREAMBLE_COMBINED_LIMIT_EXCEEDED` (`422`). +- `PREAMBLE_SLASH_COMMAND_BLOCKED` (`422`). + +Add the error codes to `common/error-codes.ts` where the generic API error contract requires enumeration. + +Add this WebSocket event to `common/ws-events.ts`: + +```ts +export class PreamblesInvalidatedMessage { + readonly type = 'preambles-invalidated' as const; + + constructor(public reason: PreamblesInvalidationReason) {} +} +``` + +`server/server-event-wiring.ts` broadcasts it after each successful mutation. It carries no catalog body. Other clients mark their local snapshot stale and refresh only if the preamble surface has been loaded. Application does not mutate the catalog and emits no invalidation. + +All sender, parser, receiver, and route payloads are typed and gain contract tests in the same change. + +## Durable Boundary State + +Boundary eligibility cannot be inferred from “no native session.” A native fork may already have a session, while an ordinary established chat may temporarily have none. A promptless fork also has copied user rows, so “no user input” is not sufficient. + +Add a fixed-size pending boundary to the current chat binding in `server/chats/store.ts` and the corresponding registry codec: + +```ts +export type PreambleBoundaryKind = + | 'new-chat' + | 'fork' + | 'continuation' + | 'agent-switch'; + +export interface PendingPreambleBoundary { + readonly kind: PreambleBoundaryKind; + readonly ownershipEpoch: string; +} + +export interface AgentChatEntry { + // existing fields + pendingPreambleBoundary?: PendingPreambleBoundary | null; +} +``` + +The boundary reuses the binding's existing `agentOwnershipEpoch`; it does not introduce a transcript row identity or submission identity. + +Boundary arming occurs when the binding becomes durable: + +| Operation | Arming point | Kind | +| --- | --- | --- | +| New chat | registry entry created in `StartCommands` preparation | `new-chat` | +| Promptful or promptless fork | target registry entry created in `forkChatFileCopy()` | `fork` | +| Continuation/self-handoff | target registry entry created in `SelfHandoffCommands.#createContinuation()` | `continuation` | +| In-place cross-agent handoff | target ownership roll-forward updates the binding | `agent-switch` | + +The first accepted user input for that binding stores a private consumption proof in its ordinary `user-input` detail: + +```ts +export interface LedgerPreambleBoundaryProof { + readonly kind: PreambleBoundaryKind; + readonly ownershipEpoch: string; +} + +export interface LedgerUserInputDetail { + // existing fields + readonly preambleBoundary: LedgerPreambleBoundaryProof | null; + readonly preamblePrefixReceipt: PreamblePrefixReceipt | null; +} +``` + +The proof is stored even when zero preambles match. This is what prevents a second prompt from applying preambles that were created after the empty first application. + +After the ledger commit, the registry clears `pendingPreambleBoundary` with a compare-and-clear on `ownershipEpoch` and schedules/persists its normal registry flush. The ledger proof is authoritative for recovery: + +- if the process crashes before the input commit, the pending boundary remains and the first retry still applies the then-current catalog; +- if it crashes after the input commit but before the registry flush, admission finds the matching proof, repairs/clears the stale pending field, and does not apply again; +- if an identical `clientMessageId` retries, the existing input row is returned and never redispatched or accompanied by a second notice; +- if ownership changes again, the new epoch cannot be satisfied by an old proof. + +The check and append are synchronous under the existing chat mutation/execution reservation. No registry clear is allowed before the ledger commit. + +Old `chats.json` entries have no pending field and receive no retroactive boundary. This is the intended migration behavior. + +## Application Notice and Private Receipt + +Add the public typed notice detail in `common/transcript-notice-details.ts`: + +```ts +export interface AppliedPreambleReference { + readonly id: string; + readonly title: string; +} + +export interface PreambleApplicationNoticeDetail { + readonly type: 'preamble-application'; + readonly preambles: readonly AppliedPreambleReference[]; +} +``` + +Validation requires 1 through 100 entries, unique nonblank bounded IDs, and valid title snapshots. The notice's ledger message is the fixed text `Preambles applied`. Its detail contains exactly `type` and `preambles`; it contains no body, scope, path, catalog revision, provider prompt, or prefix receipt. + +Store the sanitation receipt only in the private `LedgerUserInputDetail`: + +```ts +export interface PreamblePrefixReceipt { + readonly format: 'preamble-v1'; + readonly codeUnitLength: number; + readonly sha256: string; +} +``` + +The receipt proves the exact complete prefix without a per-application identifier. Its SHA-256 input is the prefix's exact JavaScript UTF-16 code-unit sequence serialized as unsigned 16-bit little-endian values. It is deliberately not a UTF-8 encoding: JavaScript UTF-8 conversion replaces lone surrogates with U+FFFD and would let distinct same-length strings share a receipt. Imported rows retain the receipt even though native imports continue to assign `clientMessageId: null`. + +The notice is immediately before its input, and the receipt plus boundary proof are on that input. This adjacency lets native import collect `{ receipt, boundary, preambles }` evidence without duplicating title snapshots into the input detail. + +## Prefix Format and Prompt Composition + +`common/preamble-prefix.ts` owns one frozen renderer and receipt validator. The version-one prefix is: + +```text + + + + + + + +``` + +The rendered bodies are joined in catalog order with exactly two line feeds. Before joining, core expands active `{{chat_id}}` tokens with the target chat ID through the same shared template helper used by snippets and scheduled prompts. No title, catalog ID, scope, or path is sent to the provider. Body bytes/code units are otherwise unchanged, including unsupported tokens. The closing tag and two line feeds precede a receipt-covered ASCII input-boundary comment ending in one space. That non-line-feed terminator prevents the prefix/input join from synthesizing the reserved file-context separator when an authored prompt begins with `Referenced file contents from @file mentions:` or with one leading line feed. Native sanitation strips the comment with the envelope. Preamble content is trusted user-authored instruction text; the XML-like frame is a recognizable sanitation envelope, not a security sandbox and is never parsed to recover bodies. + +Representative implementation: + +```ts +const PREAMBLE_OPEN_PREFIX = '', + input.contents.join('\n\n'), + '\n\n ', + ].join('\n'); + if (prefix.length > PREAMBLE_COMBINED_MAX_LENGTH) { + throw new PreambleCombinedLimitError(); + } + return { + prefix, + receipt: { + format: 'preamble-v1', + codeUnitLength: prefix.length, + sha256: preamblePrefixSha256(prefix), + }, + }; +} +``` + +`preamblePrefixSha256()` serializes every `charCodeAt()` value low byte first, then hashes the resulting bytes. Receipt creation and sanitation share that helper. Golden tests freeze both the envelope and digest encoding, including distinct lone-surrogate and U+FFFD inputs. Any future format uses a new receipt discriminant and keeps the old sanitizer until no stored receipts require it. + +Application passes rendered contents into the frozen prefix formatter: + +```ts +const application = boundary && preambles.length > 0 + ? createPreamblePrefix({ + contents: preambles.map((preamble) => + renderPreambleContent(preamble.content, chatId), + ), + }) + : null; +``` + +`renderPreambleContent()` delegates to `expandTemplate()` with only the shared `chat_id` variable enabled. The exact expanded prefix is hashed into the receipt; neither the authored template nor its expansion is persisted in the ledger. + +`InputComposition` becomes: + +```ts +export interface InputComposition { + readonly input: LedgerUserInputRow; + readonly committedRows: readonly LedgerRow[]; + readonly prompt: readonly LedgerUserInputRow[]; + readonly providerPrefix: string; + readonly inserted: boolean; +} +``` + +`TranscriptLedgerStore.appendInputAndCompose()` accepts the resolved preamble entries and boundary proof. Under the existing synchronous write fence it: + +1. checks for an existing submission before constructing a prefix; +2. returns the existing input with `inserted: false`, no committed rows, and no provider prefix on an identical retry; +3. creates the prefix and receipt only for a new boundary input with matching entries; +4. prepares zero or one notice draft followed by the user-input draft; +5. inserts both drafts in one SQLite transaction; +6. computes the resend fold using the new input as the current input; the preceding notice is naturally ignored; +7. advances the next ordinal by the complete row-group size; +8. returns the private prefix only in the in-memory composition. + +The submission unique index remains on the user-input row. The notice has no `client_message_id`. A duplicate lookup therefore finds the input and cannot create a second notice. + +`server/ledger/codec.ts::submissionFingerprint()` continues to cover only client-authored content, images, presentation, attachments, and steer state. It deliberately excludes `preambleBoundary` and `preamblePrefixReceipt`: those are server-derived consequences of the original accepted submission, not client payload. A retry after the registry boundary has been cleared must still compare equal to and return that original row. + +`TranscriptLedgerService.appendInputAndCompose()` sends one `rows` commit notification containing `[notice, input]`, or `[input]` when no notice exists. This preserves committed order for active and background transcript caches and satisfies the established `chat-messages` ordering rule. + +`RuntimeRouter.#preparePrompt()` keeps the visible and outbound forms separate inside core: + +```ts +const visiblePrompt = promptRows.length > 0 + ? promptRows.map((row) => row.detail.message.content).join('\n\n') + : fallbackPrompt; +const resolvedVisiblePrompt = await resolveFileMentionsInCommand( + visiblePrompt, + entry.projectPath, +); +return { + prompt: resolvedVisiblePrompt, + outboundPrompt: `${composition?.providerPrefix ?? ''}${resolvedVisiblePrompt}`, +}; +``` + +File mention resolution runs on visible authored input before prefixing. This prevents `@path` text inside a reusable preamble from unexpectedly expanding as a composer file mention. Carryover planning receives only `prompt`, so the preamble cannot leak into compaction. The unchanged integration request receives `outboundPrompt` in its existing `prompt` field. Stateful integrations already place carried context before that field, producing `carried context -> preamble prefix -> visible prompt`. No server-agent contract, runtime, or parser knows that the prefix exists. + +Before the ledger append, admission resolves the pending boundary's enabled matching set. If that set is nonempty and the current authored text begins with `/` after leading whitespace, admission throws `PREAMBLE_SLASH_COMMAND_BLOCKED`. It stores no input or notice, dispatches nothing, and leaves the boundary armed. This avoids changing provider-native slash-command semantics: the next regular message carries the preambles, and provider slash commands work normally afterward. Garcon-owned commands use their typed command paths before this check and do not consume the boundary. The same check runs when a queued entry reaches the head. A blocked queued entry is removed, publishes the ordinary user-visible turn failure with the same actionable message, leaves the boundary armed, and allows the drainer to continue; it never remains at the head to wedge later entries. + +The prefix applies to the entire turn-starting resend composition, not separately to each resent row. The notice remains adjacent to the newly accepted input that caused the dispatch. + +## Native-History Sanitation + +### Evidence collection + +Add `server/ledger/preamble-history.ts` with a pure collector over authoritative rows. Within the selected current binding and watermark, it recognizes only this exact pair: + +```text +notice(type = preamble-application, refs) +user-input(preamblePrefixReceipt != null) +``` + +It validates adjacency, receipt shape, and the input's boundary proof. A receipt without the preceding typed notice, or a notice not followed by a receipt-bearing boundary input, is ledger corruption for this operation and fails closed. The resulting ordered evidence record carries the receipt, boundary proof, and public references. + +Reload collects evidence from rows at or after the current view's `contentStartOrdinal`. Native-fidelity fork collects it from source rows in the selected current binding through the fork watermark and passes it to `readForkedNativeHistory`. Frozen earlier bindings do not need receipts because their visible conversation is carried separately and removed by the existing native seed receipt. + +### Exact sanitizer + +Extend `server/ledger/native-history-seed.ts` so sanitation order is: + +1. provider integration parses and normalizes its native format; +2. existing file-mention sanitation has already removed provider-recorded file context; +3. `sanitizeRecordedCarriedContext()` removes an exact carried-context seed; +4. `sanitizeRecordedPreamblePrefixes()` removes exact preamble prefixes and annotates reconstructed applications; +5. `importedDrafts()` emits a reconstructed notice immediately before each sanitized user row and restores its private receipt. + +The sanitizer never searches for body text. It recognizes only the exact leading version-one opening frame, tests unused evidence in ledger order by hashing exactly each receipt's `codeUnitLength` code units through the shared UTF-16LE code-unit helper, and strips only on an exact SHA-256 match. + +```ts +export type SanitizePreamblePrefixesResult = + | { + readonly kind: 'sanitized'; + readonly messages: readonly SanitizedImportedMessage[]; + } + | { + readonly kind: 'not-yet-persisted'; + readonly reason: string; + } + | { + readonly kind: 'mismatch'; + readonly reason: string; + }; + +export interface SanitizedImportedMessage { + readonly message: ChatMessage; + readonly application?: { + readonly preambles: readonly AppliedPreambleReference[]; + readonly receipt: PreamblePrefixReceipt; + readonly boundary: LedgerPreambleBoundaryProof; + }; +} +``` + +For each imported user message: + +- an empty authoritative evidence set leaves every message unchanged, including user-authored text beginning with the frame marker, because no Garcon preamble application can exist in that native binding; +- no leading Garcon preamble frame means the message is unchanged; +- exactly one distinct receipt length/hash signature must match; its earliest unused receipt removes the prefix and records its application annotation; +- more than one distinct matching signature fails closed instead of choosing a potentially shorter prefix; +- with authoritative evidence, any message beginning with the reserved open prefix but lacking the + exact version-one frame fails the import; this deliberately includes colliding user-authored text because + presenting a damaged private frame would leak its body; +- a framed prefix with no unused exact length/hash receipt fails the import; +- an evidence entry absent from native history is allowed only when no provider-origin `run-ended` follows its input before the next ordinary non-steer input, preserving the commit-before-dispatch crash window; +- a provider-origin `run-ended` in that interval requires one native occurrence for the receipt signature; intervening steers do not end the interval, and identical receipts are counted together because their prefixes are indistinguishable; +- one evidence entry may sanitize at most one imported message; +- identical prefixes are indistinguishable after the per-application identifier is removed, so repeated identical native frames consume identical evidence in ledger order. An absent earlier identical occurrence cannot be distinguished from an absent later one; this affects only which immutable title snapshot is reconstructed when two applications sent byte-identical bodies. + +A missing required native occurrence becomes retryable `HISTORY_LOAD_FAILED` (`409`) and aborts Reload cutover or native-fork target creation until native persistence catches up. A mismatch becomes `PREAMBLE_ENVELOPE_MISMATCH` (`422`) and aborts the same operations. Failing is preferable to rendering private injected text. The original current view remains untouched on Reload failure; native-fork cleanup follows existing behavior. + +Before Reload can cut over, core flushes the current in-memory chat registry even when `pendingPreambleBoundary` is already clear. Reload can remove the only zero-match boundary proof, so a stale debounced `chats.json` write must not survive the replacement view. + +After exact stripping, the imported visible user content may still be the provider's folded form of several resend inputs. That is existing Reload lossiness. The reconstructed notice belongs immediately before that provider-native user row because that row is what carried the prefix. + +Core conformance and black-box tests prove that every integration with a non-null `nativeHistoryImport` preserves an ordinary leading prompt prefix through its existing normalized-history contract. Preambles require no provider-specific parsing or production changes: integrations already receive and persist one opaque prompt string. A provider that cannot preserve an ordinary leading prompt must fail its general prompt/history contract rather than gain a preamble-specific carrier. Core never branches on provider ID. + +No mechanism can prevent a model from quoting instructions in its assistant output. Preambles are configuration privacy in Garcon's own user-message presentation, not a secret channel. + +## Read Folds and Historical Preservation + +The application notice follows this explicit matrix: + +| Surface | Notice IDs/titles | Body | +| --- | --- | --- | +| Live transcript and replay | yes | no | +| Current boundary provider prompt | no | yes, private prefix | +| Frozen projection | yes | no | +| Reload/native fork reconstruction | yes | stripped before import | +| Search | no | no | +| Chat preview/title generation | no | no | +| Resend scan | no | no | +| Model context/carryover | no | no | +| Share snapshot | yes | no | +| Ordinary export, default | yes | no | +| Ordinary export with diagnostics excluded | no | no | +| Support-only raw ledger export | private receipt may exist | no body is stored | + +Update both `server/ledger/projection.ts` and `server/ledger/imported-drafts.ts` to preserve `preamble-application` notices alongside carryover-quarantine notices. The input's private boundary proof and prefix receipt can remain in frozen ledger detail, but they are outside presentation and ignored when they belong to an older binding. + +No ledger schema migration is needed. The notice remains `kind: 'notice'`, and the receipt/boundary proof are additive keys in the schema-version-one user-input JSON codec. Decoders default both fields to `null` for existing rows. + +## Transcript Rendering, Shares, and Exports + +`server/ledger/presentation.ts` converts the typed ledger notice into `TranscriptNoticeMessage` with the public detail unchanged. `common/chat-types.ts` needs no new top-level message type. + +Add `web/src/lib/components/chat/rows/PreambleApplicationRow.svelte` and route it from `TranscriptNoticeRow.svelte` before generic notice rendering: + +```svelte + + +
+
+ {m.preambles_applied_label()} + {#each detail.preambles as preamble (preamble.id)} + + {preamble.title} + + {/each} +
+
+``` + +The production component uses the existing `ChatEventCard`/row spacing contract as appropriate, semantic tokens only, a `` around externally decoded repeated entries, and an accessible text sequence. Pills wrap rather than horizontally scroll. They render stored titles directly and never consult the preamble store. + +`server/chats/share-transcript.ts` adds a typed formatter that writes the fixed label plus title snapshots and never falls back to serializing private ledger detail. Share JSON contains the public detail with IDs/titles only. + +`server/ledger/export-fold.ts` categorizes the notice as `diagnostics`. It is included by default and removable only through the existing diagnostics exclusion. Renderers use structural handling: + +- Markdown: `Preambles applied: Repository conventions; Security constraints` +- XML: `` + +XML retains IDs and titles inside the rendered document. Markdown uses titles only for readability. The typed export response continues to expose only its rendered `document`, not structured entries. No renderer has access to a body because none is stored in the ledger notice or export entry. + +## Web Experience + +### Entry point and loading + +Add a Preambles action beside Scheduled prompts in the sidebar's More actions menu. Route it through `SidebarControlsRow.svelte`, `SidebarSearchDock.svelte`, `Sidebar.svelte`, and `AppShell.svelte`. `AppShell.svelte` owns mutually exclusive modal state and lazy-loads `PreamblesDialog.svelte`, matching `ScheduledPromptsDialog.svelte`. The catalog API is not called until the surface is opened. + +Create the domain at `web/src/lib/preambles/`: + +- `preambles-store.svelte.ts` owns the lazy snapshot, loading/error state, optimistic-revision mutations, and invalidation refresh; +- `preamble-filter.ts` owns the pure normalized substring filter; +- `__tests__/` owns DOM-free domain tests. + +Add a typed context factory in `$lib/context`; do not add a string-keyed Svelte context or a new flat `lib/stores/preambles` directory. + +### Catalog dialog + +`web/src/lib/components/preambles/PreamblesDialog.svelte` is a full-height responsive dialog modeled on scheduled prompts. Its header contains: + +- title and short explanation; +- a search input with a clear action; +- `Add preamble` as the primary action. + +The body contains ordered rows. Each row shows title, a body preview, and either a Global badge or path-rule summaries. It has a direct enabled switch plus Edit, Delete, Move up, and Move down actions. A disabled row remains in place, is visually muted, and shows a `Disabled` badge. Reordering uses accessible buttons as the baseline; drag-and-drop is optional only if it preserves the same keyboard operations. Move actions are disabled while the normalized filter is nonempty, with helper text to clear the filter before reordering. + +The filter is a case-insensitive substring over: + +```ts +export function matchesPreambleFilter(preamble: Preamble, query: string): boolean { + const needle = query.trim().toLowerCase(); + if (!needle) return true; + const haystacks = [ + preamble.title, + preamble.content, + ...(preamble.scope.type === 'project-paths' + ? preamble.scope.rules.map((rule) => rule.projectPath) + : []), + ]; + return haystacks.some((value) => value.toLowerCase().includes(needle)); +} +``` + +Filtering does not alter stored order. Empty results distinguish an empty catalog from no filter matches. Delete uses an in-app confirmation dialog and never `confirm()`. + +### Create/edit dialog + +`PreambleFormDialog.svelte` and `preamble-form-state.svelte.ts` follow the snippet prompt editor and scheduled prompt form patterns. Fields are: + +- Enabled switch, defaulting on for creation and reflecting the stored value for edit. +- Title. +- Scope radio cards: Global or Project paths. +- For Project paths, one or more path rows. +- Each path row contains the canonical/current path display, Browse, Remove, and `Apply to nested paths` checkbox. +- `Add project path` appends another row and opens or focuses its selector. +- Preamble text via `PromptTextField`, with `PromptEditorDialog` for the expanded editor. +- A help legend directly below the text composer: `Use {{chat_id}} for the chat receiving this preamble.` The legend participates in the text area's `aria-describedby` relationship. + +`DirectoryBrowser.svelte` is reused for each path row. Only one picker is open at a time and the form records which row receives the selection. The browser prevents obvious normalized duplicates; the server remains authoritative for canonical duplicates and containment. + +The form state uses Svelte 5 runes and getters: + +```ts +export class PreambleFormState { + enabled = $state(true); + title = $state(''); + content = $state(''); + scopeType = $state<'global' | 'project-paths'>('global'); + pathRules = $state([]); + saving = $state(false); + error = $state(null); + + get canSave(): boolean { + return this.titleError === null + && this.contentError === null + && this.scopeError === null + && !this.saving; + } + + buildDefinition(): PreambleDefinitionInput | null { + if (!this.canSave) return null; + return { + enabled: this.enabled, + title: this.title.trim(), + content: this.content, + scope: this.scopeType === 'global' + ? { type: 'global' } + : { type: 'project-paths', rules: structuredClone(this.pathRules) }, + }; + } +} +``` + +The `.svelte` shell uses `$props`, `$state`, `$derived`, and `$effect` only for dialog lifecycle, focus, and picker/editor side effects. It does not mirror derivable form state. It never declares a local named `state`, `derived`, or `effect`. All dialog controls compute to at least 16px font size on touch devices, use `focus-visible`, and use the global `transient-backdrop` through the shared dialog primitive. + +Switching to Global hides the path controls and excludes draft rules from the saved definition. The in-memory form may retain unsaved rows if the user switches back before saving. Reopening an existing global preamble starts with no path rules. + +All strings are Paraglide keys. Run `cd web && bun run i18n:compile` after adding them. + +## Boundary Matrix + +| Flow | Applies? | Resolution point | Notes | +| --- | --- | --- | --- | +| SPA new chat | yes | first input ledger admission | Uses server-canonical path from new registry entry. | +| Scheduled new chat | yes | same `StartCommands` path | Scheduling does not snapshot the catalog. | +| Promptful fork | yes | target first input admission after fork preparation | Uses catalog current after target creation. | +| Promptless fork creation | no | none | Target retains pending `fork` boundary. | +| First later prompt in promptless fork | yes | that input admission | Existing copied notices stay; new notice is appended. | +| Continuation `/handoff` | yes | target first input admission | Kind `continuation`. | +| Self-handoff | yes | target first input admission | Same continuation semantics. | +| In-place cross-agent switch | yes | first target-owner input admission | Kind `agent-switch`; target project path is authoritative. | +| Ordinary existing-chat turn | no | none | No pending boundary. | +| Steer/interrupt-and-send steer | no | none | Does not start a new binding. | +| Future-turn queued ordinary input | no | dequeue | Queue entries do not arm boundaries; boundary-producing operations require an idle target. | +| Compaction/goal/control input | no | none | Server/provider control stays undecorated. | +| Existing-chat scheduled prompt | only when a promptless fork boundary is still pending | input admission | Otherwise it is an ordinary existing-chat turn and does not apply. | +| Provider slash command while enabled matches are pending | no; rejected | before ledger admission | Direct input returns the typed error. A queued entry is removed with the same visible turn failure. Neither stores a user row or consumes the boundary. | +| Provider slash command with zero enabled matches | no prefix | input admission | Allowed and consumes the zero-match boundary. | + +If no preamble matches, the user input still consumes the pending boundary but no notice or prefix is created. + +## Failure Semantics + +| Failure window | Outcome | +| --- | --- | +| Catalog create/update path cannot be canonicalized | Mutation fails; old catalog remains authoritative. | +| Candidate catalog exceeds a per-entry, count, rule, or combined limit | Mutation fails; no truncation or partial write. | +| `{{chat_id}}` expansion would make a candidate composition exceed the combined limit | Mutation fails before persistence using the fixed 16-digit validation sample. | +| Disabling a preamble | Revision-checked update succeeds without changing its position; it stops matching immediately. | +| Re-enabling would exceed the combined budget | Update fails with `PREAMBLE_COMBINED_LIMIT_EXCEEDED`; the preamble remains disabled. | +| Catalog write fails | Revision and in-memory snapshot remain unchanged; no invalidation. | +| Boundary preparation fails before input admission | No input or application notice. New target compensation follows its existing flow. | +| Input commit fails | Neither notice nor input commits; chat follows the ledger write fence. Boundary remains pending. | +| Pending enabled matches plus a provider slash command | `PREAMBLE_SLASH_COMMAND_BLOCKED`; no row, notice, or dispatch; boundary remains pending. | +| Opening slash command rejected after new-chat preparation | The target remains as `New Session`; rejected text enters neither transcript nor title/preview metadata. An identical command retry replays the same typed `422` rejection. | +| A queued provider slash command reaches an armed boundary with enabled matches | Entry is removed, the same actionable failure is published, later queued entries may drain, and the boundary remains pending. | +| Crash after notice/input commit and before dispatch | Notice and input survive together. Same-ID retry never redispatches. Existing resend recovery remains unchanged. | +| Crash after commit and before pending-boundary registry flush | Ledger proof suppresses duplicate application and repairs the stale pending field. | +| Duplicate input with identical `clientMessageId` | Existing input returned; no second notice, prefix, broadcast, or dispatch. Catalog changes since the first commit are irrelevant. | +| Reused `clientMessageId` with changed visible content/attachments | Existing `IDEMPOTENCY_CONFLICT`; no row or dispatch. | +| Provider start rejects after commit | Notice and input remain, followed by ordinary failed-run presentation. “Preambles applied” remains accurate because application means accepted outbound composition, not successful provider completion. | +| Native import contains an exact receipt-matched prefix | Prefix stripped; notice and receipt reconstructed. | +| Native import contains a recognizable malformed, unmatched, or multi-signature prefix, or more occurrences than matching receipts | Reload/fork fails with `PREAMBLE_ENVELOPE_MISMATCH`; raw prefix is never presented. | +| Ledger evidence has no corresponding native prompt and no provider-origin terminal before the next ordinary input | Allowed; it may be the durable-before-dispatch crash window. | +| A provider-completed preamble turn has fewer native occurrences than its required receipt-signature count | Reload/fork fails retryably with `HISTORY_LOAD_FAILED`; Reload keeps the receipt-bearing current view until native persistence catches up. | +| Preamble renamed or deleted after application | Historical notice continues to show the stored title snapshot. | +| Project directory deleted after configuration | Existing canonical rule remains stored; new edits must revalidate. | + +The wording is `Preambles applied`, not `Preambles sent`, because ledger admission and provider dispatch are separate failure boundaries. + +## Security and Privacy + +- `preambles.json` is workspace-local and mode `0600`. +- API and WebSocket behavior uses existing authentication. Invalidation messages contain no bodies or paths. +- Scope paths are real-path canonicalized, constrained to the configured project base, and verified as directories on mutation. +- The server ignores any client attempt to name applied IDs/content in chat commands; application is recomputed from authoritative state. +- Notice, share, and export paths contain only stable IDs and historical titles. +- Prefix receipts contain a hash and length, never recoverable bodies. +- SHA-256 is collision resistance for exact sanitation, not encryption. +- Preamble bodies are sent to provider processes/services and can exist in provider-native storage. Users must not treat them as secrets. +- Models may quote or act on preamble content. The application does not claim to redact assistant output. +- Native sanitation fails closed when it recognizes a Garcon frame it cannot prove exact and the binding contains authoritative preamble evidence. With no evidence, marker-like authored text is preserved unchanged. +- Version one reserves the exact file-mention context separator and rejects both bodies containing it and rendered enabled compositions reconstructing it across frame/body joins, so provider-owned file-context stripping cannot alter a framed preamble before receipt verification. +- Logging must never include preamble content, the composed provider prompt, or a native mismatch excerpt. Logs may include chat ID, preamble IDs, boundary kind, count, lengths, and error codes. + +## Performance + +- Catalog count and rule count are bounded. +- Admission is an in-memory ordered scan of at most 100 entries and 3,200 rules, with no filesystem access and no `await`. +- Whole-catalog budget validation runs at catalog startup and inside each user mutation's store lock. It evaluates at most the configured distinct paths plus the global set, renders each enabled body once, and deduplicates identical match-set masks. +- The application adds at most one notice row and one SHA-256 over at most 64,000 code units per boundary. +- Filtering is client-side over at most 100 entries and requires no server query. +- Native sanitation is linear in imported messages times the small current-binding evidence set, with exact prefix hashes and no body search. +- No new dependency, worker, database, or provider-specific core branch is introduced. + +## Observability + +Add structured, content-free diagnostics: + +- catalog mutation reason, revision, entry count, and rule count; +- boundary kind, chat ID, canonical project path, applicable count, and composed length; +- stale pending-boundary repair by ownership epoch; +- native sanitation outcome counts: exact stripped, absent evidence, mismatch code; +- invalidation refresh failures in the browser's existing notification path. + +Do not log titles by default; IDs are sufficient for correlation and titles may themselves contain sensitive project terminology. + +No metric is required for correctness. If existing telemetry has an appropriate counter surface, add counts for applications and sanitation mismatches without labels containing paths, titles, or IDs. + +## Alternatives Rejected + +### Store only preamble IDs in the notice + +Rejected because catalog entries are mutable and deletable. Reloading an old transcript would rename a historical event or make it unrenderable. `{ id, title }` gives stable correlation plus immutable display. + +### Resolve titles dynamically like inter-agent message rows + +Rejected because chat IDs identify durable navigable entities and dynamic chat names are a useful navigation affordance. Preambles are deletable configuration records; their current title is not historical truth. The existing inter-agent row behavior is acceptable for its domain but is not the right pattern here. + +### Store body, scope, or path in the application notice + +Rejected because the notice is shared/exported presentation and explicitly exists to avoid displaying injected instructions. The historical display needs only title snapshots and IDs. + +### Let clients resolve the applied set + +Rejected because it creates stale multi-client behavior, lets clients forge or omit hidden provider input, duplicates canonical path semantics, and prevents the server from proving what it dispatched. + +### Let clients expand `{{chat_id}}` + +Rejected because every application surface, including scheduled starts, promptless forks, continuations, and queued admission, must use the authoritative target chat ID and produce one receipt-covered prefix. Server-side expansion keeps catalog templates stable across clients and makes combined-budget validation and native sanitation deterministic. + +### Support the complete snippet variable set + +Rejected because preambles have no invocation arguments and project scoping already uses server-authoritative canonical paths. Version one supports only the useful boundary value, `{{chat_id}}`; other variables remain literal rather than acquiring ambiguous semantics. + +### Sort by scope specificity + +Rejected because it makes composition order implicit and prevents users from placing global instructions at both ends. Visible catalog order is the sole rule. + +### Use a generic notice with rendered text only + +Rejected because generic notices are dropped by frozen projection and cannot be structurally rendered, shared, exported, or validated without parsing display strings. + +### Store the composed body in ledger-private notice detail + +Rejected because support exports, future debugging tools, or accidental presentation could expose it, and exact sanitation needs only a receipt. The authoritative body remains the catalog and provider-native request; the ledger stores no recoverable copy. + +### Strip native history by marker or body-text heuristic + +Rejected because authored user text can collide, providers can alter whitespace, and heuristic deletion can remove real conversation. Exact code-unit length and SHA-256 evidence are required. + +### Add preambles as a separate provider capability + +Rejected for version one. Every current execution facet accepts one prompt string, and not every provider offers a private system-instruction carrier with equivalent persistence/import semantics. Core-owned framing plus exact provider-neutral sanitation preserves one execution contract. A future provider facet is justified only if all affected providers can state and test stronger native-history guarantees. + +### Add a private prefix field to every provider execution request + +Rejected because it makes integrations responsible for a core-owned concatenation rule and creates provider-specific command parsing, persistence, and native-history ordering work. Core can send the same final prompt through the existing `prompt` field. The only collision is a provider-native slash command at the pending boundary, which is rarer and clearer to reject before admission. + +### Prefix a provider-native slash command anyway + +Rejected because native command recognition commonly requires `/` at the start of the submitted frame. Prefixing changes the command into ordinary model text. Core instead rejects only while enabled matching preambles remain unsent, leaves the boundary armed, and tells the user to begin with a regular message. Garcon-owned commands are already routed before this rule. + +### Persist promptless-fork state only in browser memory + +Rejected because a restart or a different client would lose the boundary and silently omit preambles. + +### Infer boundary state from session or transcript emptiness + +Rejected because native forks have sessions and copied histories, while ordinary bindings can lack a session. The current ownership binding must state the boundary explicitly. + +### Permit reordering a filtered subset + +Rejected because hidden entries make the resulting global order surprising. Clearing a filter before reorder is explicit and cheap. + +## Migration, Rollout, and Rollback + +### Migration + +- Missing `preambles.json` means an empty revision-zero catalog. +- Existing `chats.json` entries omit `pendingPreambleBoundary` and are not retroactively treated as new boundaries. +- Existing schema-version-one ledger user-input payloads decode new private fields as `null`. +- The notice uses the existing `notice` row kind, so no SQLite schema migration or `PRAGMA user_version` change is required. +- Shared server/client message changes deploy together; backward compatibility is not required by repository policy. + +### Rollout order + +Land the contract, persistence, and sanitation tests before enabling boundary arming. Do not ship prompt prefixing without native import sanitation and reconstructed-notice coverage in the same release. Server and client are distributed together, so no dual protocol period is needed. + +No feature flag is required. If staged deployment is operationally desirable, keep boundary arming disabled until all code is present; do not enable prefixing separately from notice/receipt persistence. + +### Rollback + +The safe rollback disables new boundary arming and application while retaining: + +- `preambles.json` untouched; +- parsers for preamble notices and receipts; +- native sanitation for already-recorded receipts; +- historical notice rendering/share/export. + +Do not delete historical notices, receipts, or catalog data. Removing the sanitizer while provider-native histories still contain framed prefixes would create a privacy regression. + +## Implementation Plan + +### Add shared catalog, notice, receipt, and boundary contracts + +Intent: establish one validation source before persistence or transport uses the shapes. + +Files: + +- add `common/preambles.ts`; +- add `common/preamble-prefix.ts`; +- update `common/transcript-notice-details.ts`; +- update `common/ws-events.ts` and `common/error-codes.ts`; +- update `common/package.json` exports when direct subpath imports are used; +- update `server/agents/session-types.ts` and `server/ledger/contracts.ts` for boundary proof and receipt types. + +Implementation shape: + +```ts +export function normalizePreambleDefinitionInput( + value: unknown, +): PreambleDefinitionInput | null; + +export function normalizePreamblesSnapshot( + value: unknown, +): PreamblesSnapshot | null; + +export function isPreambleApplicationNoticeDetail( + value: unknown, +): value is PreambleApplicationNoticeDetail; + +export function parsePreamblePrefixReceipt( + value: unknown, +): PreamblePrefixReceipt | null; + +export const PREAMBLE_CHAT_ID_TOKEN = '{{chat_id}}'; + +export function renderPreambleContent( + content: string, + chatId: string, +): string; +``` + +Parsers return cloned normalized values, validate code points for titles, reject duplicate IDs/rules, and reject unknown discriminants. The existing `parseTranscriptNoticeDetail()` union adds the new detail. + +Tests: + +- add `common/__tests__/preambles.test.js` for every limit, normalization, duplicate rule, snapshot, prefix golden case, active chat-ID expansion, escaped tokens, and unsupported-token preservation; +- update `common/__tests__/transcript-notice-contract.test.js` for valid/invalid references and round-trip; +- update `common/__tests__/ws-events.test.js` and `web/src/lib/api/__tests__/ws-message-contract.logic.test.ts` for the invalidation event. + +Validation: `bun run test common/__tests__/preambles.test.js common/__tests__/transcript-notice-contract.test.js` or the repository's supported focused-test equivalent. + +Risk/rollback: prefix format becomes durable once used. Freeze it with exact strings and version the receipt rather than editing version one. + +### Add catalog persistence, canonicalization, matching, and API + +Intent: provide one server authority with ordered snapshots and safe path semantics. + +Files: + +- add `server/preambles/store.ts`, `service.ts`, `errors.ts`, `project-path-service.ts`, and `matching.ts`; +- add `server/routes/preambles.ts`; +- update `server/routes/index.ts`, `server/server.ts`, and `server/server-event-wiring.ts`. + +Service outline: + +```ts +export class PreambleService extends EventEmitter { + snapshot(): PreamblesSnapshot; + resolve(canonicalProjectPath: string): readonly Preamble[]; + create(request: CreatePreambleRequest): Promise; + update(request: UpdatePreambleRequest): Promise; + remove(request: RemovePreambleRequest): Promise; + reorder(request: ReorderPreamblesRequest): Promise; +} +``` + +Create/update normalizes request shape, canonicalizes all paths concurrently, rejects canonical duplicates, then asks the store for one atomic mutation. Under its mutation lock, the store checks the expected revision first, applies the requested change to a draft, validates the draft's whole-catalog composition, writes it atomically, and only then publishes the new in-memory snapshot. `resolve()` is synchronous and returns cloned entries in stored order. + +Tests: + +- add `server/preambles/__tests__/store.test.js` for absent file, strict version, mode `0600`, restart, order, revision conflict, failed write, count, exact reorder set, and authoritative draft validation inside the mutation lock; +- add `server/preambles/__tests__/service.test.js` for exact/nested/sibling paths, symlink aliases, OR semantics, one application, list order, canonical duplicate rejection, combined budget, revision-first error precedence, future-revision mutations after serialized removals, and invalidation timing; +- add `server/routes/__tests__/preambles.test.js` for every method, response parser, error mapping, and authentication boundary. + +Validation: run the three focused suites, then `bun run check`. + +Risk/rollback: a malformed file must fail visibly instead of being rewritten. The absent-file path remains the only implicit empty state. + +### Add the lazy browser domain and settings experience + +Intent: deliver the scheduled-prompts-style catalog and the existing prompt-editor experience without putting business logic in components. + +Files: + +- add `web/src/lib/api/preambles.ts`; +- add `web/src/lib/preambles/preambles-store.svelte.ts` and `preamble-filter.ts`; +- add `web/src/lib/events/preambles-router.svelte.ts`; +- add `web/src/lib/components/preambles/PreamblesDialog.svelte`, `PreambleRow.svelte`, `PreambleFormDialog.svelte`, `PreambleRemoveDialog.svelte`, and `preamble-form-state.svelte.ts`; +- update the typed contexts, root layout construction and router lifecycle, app-shell modal state, `SidebarControlsRow.svelte`, `SidebarSearchDock.svelte`, `Sidebar.svelte`, and `AppShell.svelte`; +- add Paraglide keys and regenerate generated modules. + +Store mutation outline: + +```ts +async create(definition: PreambleDefinitionInput): Promise { + const response = await createPreamble({ + expectedRevision: this.revision, + preamble: definition, + }); + this.applySnapshot(response.snapshot); +} + +invalidate(): void { + this.stale = true; + if (this.loaded && this.visible) void this.refresh(); +} +``` + +The actual store follows the existing stale-refresh/coalescing pattern used by scheduled prompts and snippets; it must not allow an older GET to overwrite a newer mutation response. The form imports `PREAMBLE_CHAT_ID_TOKEN` from the shared contract and renders the localized chat-ID legend beneath the composer. + +Tests: + +- add `web/src/lib/api/__tests__/preambles-contract.test.ts`; +- add `web/src/lib/preambles/__tests__/preambles-store.test.ts` and `preamble-filter.logic.test.ts`; +- add `web/src/lib/events/__tests__/preambles-router.test.ts`; +- add `web/src/lib/components/preambles/__tests__/preamble-form-state.test.ts`; +- add component tests for add/edit/delete, row and form enable/disable, scope switching, multiple path rows, per-row nested flags, duplicate selection, filter matches including disabled rows, empty states, disabled filtered reorder, keyboard reorder, expanded editor, focus return, save conflicts, the chat-ID legend and `aria-describedby` relationship, and minimum touch font sizing. + +Validation: + +```sh +cd web && bun run i18n:compile +bun run check +bun run lint +``` + +Risk/rollback: keep the surface lazy so users who never open it pay no catalog fetch/component cost. Removing the entry point leaves persisted data intact. + +### Arm and durably consume binding boundaries + +Intent: make promptless forks and crash recovery correct without inferring from session state. + +Files: + +- update `server/chats/store.ts` registry shape/codec and tests; +- update `server/commands/start-commands.ts`; +- update `server/chats/fork-chat.ts`; +- update `server/commands/self-handoff-commands.ts`; +- update `server/agents/agent-handoff-service.ts` roll-forward/rollback; +- update `server/agents/registry.ts` admission. + +Admission outline: + +```ts +const pending = session.pendingPreambleBoundary ?? null; +const consumed = pending + ? this.#ledger.hasPreambleBoundaryProof(chatId, pending) + : true; +const boundary = pending && !consumed ? pending : null; +const preambles = boundary + ? this.#preambles.resolve(session.projectPath) + : []; + +if (boundary && preambles.length > 0 && message.content.trimStart().startsWith('/')) { + throw new DomainError( + 'PREAMBLE_SLASH_COMMAND_BLOCKED', + 'Matching preambles haven’t been sent yet. Start with a regular message before using provider slash commands.', + 422, + ); +} + +const composition = this.#ledger.appendInputAndCompose({ + // existing visible input fields + preambleBoundary: boundary, + preambles, +}); + +if (pending && (consumed || composition.inserted)) { + this.#registry.clearPendingPreambleBoundary(chatId, pending.ownershipEpoch); +} +``` + +The production code handles an identical existing input as consumed even if `composition.inserted` is false. Compare-and-clear never clears a newer ownership epoch. Catalog resolution, slash-command rejection, and ledger append remain synchronous after adoption and admission checks. Steers and typed Garcon-owned goal controls bypass boundary application. + +Tests: + +- update registry codec tests for absent/new field and malformed epochs; +- add unit cases for zero matches consuming once, stale persisted pending repair, new epoch after an old proof, duplicate same-ID input, admission failure retaining pending state, blocked provider slash commands on direct and queued paths, queued rejection removal plus continued draining, zero-match slash allowance, and Garcon-owned control bypass; +- update `server/chats/__tests__/fork-chat.test.js` for promptless and promptful arming; +- update handoff and continuation tests for exact kind and rollback behavior. + +Validation: run affected chat registry, fork, self-handoff, and agent-handoff suites. + +Risk/rollback: boundary arming is the feature enable point. It lands only after the ledger can atomically consume and sanitize applications. + +### Commit notice, input, proof, and receipt atomically + +Intent: preserve adjacency, one broadcast, and idempotency while keeping the body transient. + +Files: + +- update `server/ledger/contracts.ts`, `codec.ts`, `store.ts`, and `service.ts`; +- update `server/agents/registry.ts` to pass resolved entries; +- update `server/agents/runtime-router.ts` to prepend the transient `providerPrefix` after file resolution while keeping carryover planning on the visible prompt. + +Store transaction shape: + +```ts +const application = boundary && preambles.length > 0 + ? createPreamblePrefix({ + contents: preambles.map((preamble) => + renderPreambleContent(preamble.content, chatId), + ), + }) + : null; +const drafts = [ + ...(application ? [preambleNoticeDraft(request.at, preambles)] : []), + userInputDraft({ + ...request.detail, + preambleBoundary: boundary, + preamblePrefixReceipt: application?.receipt ?? null, + }), +]; +const rows = materializeRows(request.viewId, encodeDrafts(drafts), entry.nextOrdinal); +runTransaction(entry.db, () => insertEncodedRows(entry.db, request.viewId, encoded, entry.nextOrdinal)); +``` + +The duplicate submission lookup must occur before this block. `providerPrefix` is returned from `application.prefix` and is never encoded. + +Keep `submissionFingerprint()` unchanged in meaning by excluding both new private fields. Add an assertion that the same visible request fingerprints identically before and after a boundary is consumed. + +Tests: + +- extend `server/ledger/__tests__/store.test.js` with notice/input all-or-nothing fault injection, dense adjacent ordinals, no-notice zero-match proof, same-ID retry after catalog change, active and escaped chat-ID expansion in the transient prefix, and body/expanded-value absence from every stored payload; +- extend `server/ledger/__tests__/service.test.js` with one ordered batch notification; +- extend runtime router tests with the unchanged provider request contract, prefix-before-visible-prompt, carryover-planning exclusion, resend-once, file mentions excluded from preamble expansion, and no prefix for ordinary turns/steers. + +Validation: run ledger store/service and runtime-router suites. + +Risk/rollback: never broadcast the notice separately. Separate broadcasts could expose an intermediate state and violate adjacency under reconnect or terminal event ordering. + +### Preserve and sanitize applications across native operations + +Intent: prevent native Reload/fork from exposing bodies and retain historical notices. + +Files: + +- add `server/ledger/preamble-history.ts`; +- update `server/ledger/native-history-seed.ts`, `reload.ts`, `imported-drafts.ts`, and `projection.ts`; +- update `server/chats/fork-chat.ts` and the `readForkedNativeHistory` wiring in `server/server.ts` to pass source evidence. + +Native import outline: + +```ts +const carried = sanitizeRecordedCarriedContext(/* existing input */); +if (carried.kind === 'mismatch') throw contextMismatch(); + +const preambles = sanitizeRecordedPreamblePrefixes({ + messages: carried.messages, + evidence: preambleEvidence, +}); +if (preambles.kind === 'not-yet-persisted') throw historyNotYetPersisted(); +if (preambles.kind === 'mismatch') throw preambleMismatch(); + +return importedDrafts( + preambles.messages.map((entry, index) => ({ + message: entry.message, + providerMeta: imported[index]!.providerMeta, + preambleApplication: entry.application, + })), + now, +); +``` + +Because neither sanitation stage changes message count, provider metadata stays aligned by index. `importedDrafts()` emits notice then user draft when `preambleApplication` is present. + +Tests: + +- add `server/ledger/__tests__/preamble-history.test.js` for collector invariants, exact sanitizer behavior, provider-completion proof, and identical-signature occurrence counts; +- update `server/ledger/__tests__/reload.test.js` for notice survival, exact body absence, retryable persistence lag with no cutover, pre-dispatch absence, mismatch no-cutover, and application after catalog deletion; +- update `server/ledger/__tests__/native-history-seed.test.js` for carried-context-then-preamble order, metadata alignment, and retryable missing required occurrences; +- update `server/chats/__tests__/fork-chat.test.js` for native-fidelity notice reconstruction and selected-watermark evidence; +- add `server/ledger/__tests__/preamble-projection.test.js` for typed frozen-projection preservation and ordinary-notice exclusion; +- add provider-neutral core and black-box assertions that the existing opaque prompt and normalized-history contracts preserve exact leading content. No `server-agents` production or test file changes for preambles. + +Validation: run ledger reload/native seed/fork suites plus routine scripted provider suites; do not run credential-backed live suites. + +Risk/rollback: sanitation parsers are durable compatibility code. Keep version one after disabling new applications. + +### Add typed presentation, share, export, and read-fold coverage + +Intent: show only historical title snapshots on allowed surfaces and make exclusion from all conversational folds executable. + +Files: + +- update `server/ledger/presentation.ts`; +- add `PreambleApplicationRow.svelte` and update `TranscriptNoticeRow.svelte`; +- update `server/chats/share-transcript.ts`; +- update `server/ledger/export-fold.ts` and transcript export renderers; +- update read-fold/search/preview tests. + +Typed rendering branch: + +```svelte +{#if isPreambleApplicationNoticeDetail(message.detail)} + +{:else if interAgentDetail} + +{:else} + +{/if} +``` + +Tests: + +- update `server/ledger/__tests__/read-fold-matrix.test.js` to assert render/share/export yes and search/preview/context/resend no; +- add `web/src/lib/components/chat/__tests__/PreambleApplicationRow.test.ts` for title snapshots, wrapping classes, accessible text, and no catalog lookup; +- update `server/chats/__tests__/share-transcript.test.js` to assert only IDs/titles and no body/path; +- update `server/ledger/__tests__/export-fold.test.js`, `server/chats/transcript-export/__tests__/markdown.test.js`, and `server/chats/transcript-export/__tests__/xml.test.js` for default inclusion and diagnostics exclusion; +- add a repository-wide fixture assertion that a unique preamble body string is absent from serialized history, search input, share JSON, and ordinary export. + +Validation: run focused server and web presentation suites, then `bun run check` and `bun run lint`. + +Risk/rollback: the generic notice fallback must remain safe for a future unknown detail, but the preamble branch must execute before it so bodies are never inferred from `content`. + +### Add black-box integration and browser coverage + +Intent: prove the feature across command, persistence, provider, WebSocket, and SPA boundaries. + +Files: + +- add `integration-tests/tests/server/preambles.test.ts`; +- extend `integration-tests/tests/server/fork-run.test.ts`, `self-handoff.test.ts`, `repeated-agent-handoff.test.ts`, and `native-transcript-reload.test.ts` where their fixtures provide stronger boundary coverage; +- add `integration-tests/tests/e2e/preambles.test.ts`. + +Server cases: + +- many global/path matches compose once each in visible list order and expand every active `{{chat_id}}` with the target chat ID; +- exact, nested, sibling-prefix, and symlink-canonical path behavior; +- a global entry can occur before and after a path entry; +- scheduled new chat applies, scheduled existing chat does not; +- promptful fork resolves the current catalog after target preparation; +- promptless fork applies nothing until its first later prompt; +- editing the catalog between promptless fork creation and first input uses the edited value; +- fork history retains old notices while the new input gets a current notice; +- continuation, self-handoff, and in-place agent switch apply; +- an ordinary turn and steer do not apply; +- zero matches consume the boundary once; +- duplicate submit yields one notice and one user row with no redispatch; +- failure before admission yields neither row; failure after commit preserves both; +- restart between commit and registry clear does not reapply; +- Reload and native fork never display the unique body fixture; +- share and both exports contain titles/IDs only. + +Browser cases: + +- open Preambles from the sidebar's More actions menu and lazily load; +- add a global preamble; +- add a project-scoped preamble with two path rows and different nested flags; +- verify the composer legend documents `{{chat_id}}` and is associated with the text area; +- edit, delete, and reorder; +- disable and re-enable from a row, edit the same value in the form, and verify list position is stable; +- filter by title, body, and project path; +- verify reorder is unavailable while filtered; +- send a matching new chat prompt and verify a wrapping application row immediately precedes the user message; +- rename/delete the catalog entry and verify the historical pill stays unchanged after reload. + +Use deterministic synthetic titles, paths, and bodies in fixtures. Never commit real transcript or preamble content. + +Validation: run the new server integration test and Lightpanda E2E test through the repository's existing integration commands. Routine tests must use scripted providers and lowest supported reasoning effort. + +Risk/rollback: browser coverage is required because dialog layering, directory selection, focus restoration, and transcript adjacency are user-visible contracts that unit tests alone do not prove. + +### Run the complete quality gate and update governing documentation + +Intent: close typed contracts, architecture rules, and manual UX regressions together. + +Files: + +- update `docs/transcript-ledger-v5-design.md` with the new typed notice, boundary proof, read-fold row, frozen-projection exception, atomic input group, and native sanitation receipt; +- update `docs/transcript-ledger-v5-cts.md` with stable preamble case identifiers; +- update architecture manifests only if new domain ownership rules need to be recorded. + +Required commands after implementation: + +```sh +cd web && bun run i18n:compile +bun run check +bun run lint +bun run test +timeout 30s bun run start --port 0 +``` + +The startup command is required only after code changes and must bind the new server to `0.0.0.0` through the repository's normal start behavior. Do not stop any existing server. + +Manual checks: + +- create exact and nested rules through the directory picker; +- rapidly switch chats while the application notice arrives; verify no composer/dock movement, focus jump, or scroll jump; +- verify a background chat receives the notice and input in order; +- exercise click, Enter, and shortcut submission paths and confirm identical gating; +- resize to narrow width and confirm pills wrap without horizontal overflow; +- reload, fork, self-handoff, and switch agents using a synthetic body that is easy to search for, then confirm it appears only in the provider fixture and never in Garcon presentation/share/export; +- open the form on an iPhone-sized viewport and verify controls do not zoom on focus. + +No cleanup deletes `preambles.json`, notices, or receipts. The design document is not committed unless explicitly requested. + +## Acceptance Criteria + +- A user can create, edit, delete, reorder, and filter preambles in a scheduled-prompts-style settings experience. +- New preambles default enabled; row and form switches can disable or re-enable them without changing order, and disabled rows remain searchable with muted `Disabled` presentation. +- A preamble can be global or have 1–32 project rules, each with independent nested matching. +- The server canonicalizes paths and resolves many matching preambles once each in catalog order. +- Disabled preambles never match, apply, appear in notices, or count toward the combined budget; re-enabling validates that budget. +- All supported boundaries apply current catalog content exactly once; ordinary turns do not. +- Every active `{{chat_id}}` in an applied body expands to that target chat's 16-digit ID; escaped tokens remain literal and unsupported variables remain unchanged. +- Promptless forks durably retain eligibility until their first accepted prompt. +- A zero-match first prompt consumes eligibility. +- A provider slash command is rejected before storage while enabled matches remain pending, with actionable text and no boundary consumption; zero-match and Garcon-owned commands remain allowed. +- A rejected queued provider slash command is removed with a visible failure and cannot wedge the queue; the next regular queued message can consume the boundary. +- Notice and visible user input commit atomically and publish in one ordered batch. +- The transcript shows only `Preambles applied` and wrapping historical-title pills immediately before the triggering input. +- Rename/deletion never changes old pills. +- Preamble bodies are absent from ledger notice detail, presentation messages, search, preview, resend, carryover, shares, and ordinary exports. +- Exact native sanitation preserves notices across Reload and native-fidelity fork; completed-turn persistence lag retries without cutover, and mismatch fails closed. +- Same-ID retries cannot create a second notice or dispatch. +- Combined over-budget catalogs are rejected without truncation. +- Combined-budget validation accounts for chat-ID expansion before a catalog mutation commits. +- `git diff origin/main -- server-agents` is empty; provider integrations retain their pre-feature contract and implementation. +- Contracts are typed on both sides, Svelte code follows canonical runes patterns, all specified tests pass, and manual rapid-switch/focus/scroll checks show no regression. + +## Resolved Assumptions and Deferred Risks + +Resolved assumptions: + +- Catalog scope is workspace-local, matching the scheduled prompt and snippet stores. +- Duplicate titles are allowed; IDs are stable and snapshots are historical. +- Reorder is disabled under filtering. +- Preambles apply to image-only boundary messages as well as text messages. The visible image input consumes the boundary and receives the notice when entries match. +- Matching uses the target chat's canonical project path at admission, not the source chat path supplied by a client. +- Chat-ID expansion uses the target chat ID at admission. All current IDs are fixed at 16 digits, so a fixed-length validation sample exactly bounds every catalog composition. +- Combined size is guaranteed at catalog mutation, with a defensive assertion at composition. +- Application means accepted provider composition, not provider completion. +- Existing chats and forks created before this feature are not retroactively armed. +- Repeated byte-identical prefixes consume byte-identical evidence in ledger order. Without a per-application identifier, an absent earlier occurrence cannot be distinguished from an absent later one; only the reconstructed immutable title snapshot can differ, while prefix removal and body privacy remain exact. + +Deferred risks that do not block implementation: + +- A provider or model may repeat preamble text in assistant output; this feature cannot redact generated content. +- A provider-native format that rewrites a prompt so completely that it removes the recognizable frame cannot be sanitized provider-neutrally. Such a provider must disable native import/fidelity for affected sessions or add a safe provider-owned carrier before support. Scripted conformance prevents knowingly shipping that state for reference providers. +- Click-through from a historical pill to a current preamble editor is deferred. The stored ID preserves that option, while title rendering remains snapshot-based even if navigation is added. +- Agent/model/tag/glob/exclusion scopes are deferred until a separate product design establishes precedence and UI semantics. From 6aaaf5449b1259016670297cb87aca05fb583de9 Mon Sep 17 00:00:00 2001 From: cfal Date: Sun, 6 Sep 2026 21:44:57 +0000 Subject: [PATCH 5/5] test: stabilize preamble selection coverage --- server/preambles/__tests__/store-v2.test.js | 13 +++++++++++-- .../__tests__/ChatPreambleSelectionPanel.test.ts | 4 +++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/server/preambles/__tests__/store-v2.test.js b/server/preambles/__tests__/store-v2.test.js index f7ec8ed399..47b646ea97 100644 --- a/server/preambles/__tests__/store-v2.test.js +++ b/server/preambles/__tests__/store-v2.test.js @@ -218,17 +218,26 @@ describe('PreambleStore version two', () => { // form with the incremented revision and trailing newline included. // Stay within the 64,000-code-unit combined composition budget: scope // each entry to a distinct exact path so no matching set combines them. + const directory = await temporaryDirectory(); + const canonicalProjectBase = await fs.realpath( + process.env.GARCON_PROJECT_BASE_DIR ?? os.homedir(), + ); const definitions = []; for (let index = 0; index < 3; index += 1) { definitions.push(preamble(`00000000-0000-4000-8000-${String(index).padStart(12, '0')}`, { content: 'C'.repeat(30_000), scope: { type: 'project-paths', - rules: [{ projectPath: `/workspace/exact-${index}`, includeNested: false }], + rules: [{ + projectPath: path.join( + canonicalProjectBase, + `.garcon-preamble-size-${path.basename(directory)}-${index}`, + ), + includeNested: false, + }], }, })); } - const directory = await temporaryDirectory(); const store = new PreambleStore(directory); await store.init(); let revision = 0; diff --git a/web/src/lib/components/preambles/__tests__/ChatPreambleSelectionPanel.test.ts b/web/src/lib/components/preambles/__tests__/ChatPreambleSelectionPanel.test.ts index 39ffce93bc..f085361a3d 100644 --- a/web/src/lib/components/preambles/__tests__/ChatPreambleSelectionPanel.test.ts +++ b/web/src/lib/components/preambles/__tests__/ChatPreambleSelectionPanel.test.ts @@ -148,7 +148,9 @@ describe('NewChatPreamblePicker', () => { ); await fireEvent.click(slot('new-chat-preamble-manage-catalog')); expect(appShell.showPreambles).toBe(true); - expect(document.querySelector('[data-slot="new-chat-preamble-selection-dialog"]')).toBeNull(); + await waitFor(() => { + expect(document.querySelector('[data-slot="new-chat-preamble-selection-dialog"]')).toBeNull(); + }); expect(close).not.toHaveBeenCalled(); appShell.closePreambles();