From 20f83123b2e5a53f37bb76abaf57e7c7aad741f3 Mon Sep 17 00:00:00 2001 From: sunrioa <178722768+sunrioa@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:53:34 +0800 Subject: [PATCH 1/2] fix(runtime-host): add numbered suffixes to branch session titles Assign collision-aware numbered names to ordinary branches, preserve literal manual titles, and cover concurrent creation and durable retries. Generated-by: OpenAI Codex --- .../src/__tests__/session-branch-name.test.ts | 186 ++++++++++++++++++ .../session-revision-two-client-uds.test.ts | 3 + .../server/session-revision-coordinator.ts | 40 +++- 3 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 packages/runtime-host/src/__tests__/session-branch-name.test.ts diff --git a/packages/runtime-host/src/__tests__/session-branch-name.test.ts b/packages/runtime-host/src/__tests__/session-branch-name.test.ts new file mode 100644 index 0000000000..2e4ed4b9a6 --- /dev/null +++ b/packages/runtime-host/src/__tests__/session-branch-name.test.ts @@ -0,0 +1,186 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { test } from 'node:test'; +import { SESSION_NAME_MAX_CODE_POINTS } from '@maka/core/session-name'; +import type { RuntimeHostConnection } from '../client/index.js'; +import type { SessionCatalogItem } from '../protocol/index.js'; +import { + connectClient, + requireStartedTurn, + waitForTerminalTurn, + withExecutionRoot, +} from './fixtures/execution-host-suite.js'; + +const SOURCE_TURN_ID = 'branch-name-source-turn'; + +function session(projection: SessionCatalogItem) { + assert.ok(!('reason' in projection), 'Expected a wire-representable Session'); + return projection; +} + +async function read(client: RuntimeHostConnection, sessionId: string) { + const result = await client.request('session.catalog.query', { kind: 'get', sessionId }); + assert.ok(result.kind === 'session' && result.session); + return session(result.session); +} + +async function rename(client: RuntimeHostConnection, sessionId: string, name: string) { + const source = await read(client, sessionId); + const result = await client.request('session.metadata.update', { + sessionId, + expectedRevision: source.revision, + patch: { name }, + }); + assert.equal(result.kind, 'committed'); +} + +async function branch( + client: RuntimeHostConnection, + sourceSessionId: string, + targetSessionId: string = randomUUID(), + sourceTurnId = SOURCE_TURN_ID, +) { + const source = await read(client, sourceSessionId); + const result = await client.request('session.branch.create', { + sourceSessionId, + targetSessionId, + sourceTurnId, + expectedSourceRevision: source.revision, + }); + assert.ok(result.kind === 'committed'); + return session(result.session); +} + +async function settleSource( + client: RuntimeHostConnection, + sessionId: string, + turnId = SOURCE_TURN_ID, +) { + const turn = requireStartedTurn( + await client.request('turn.start', { + sessionId, + turnId, + content: { text: 'Answer briefly.' }, + }), + ); + await waitForTerminalTurn(client, sessionId, turn.turnId); +} + +test('ordinary branches keep distinct durable names without stacking suffixes', async () => { + await withExecutionRoot(async (fixture) => { + const collisionId = await fixture.seedSession(); + const host = await fixture.startHost(); + const client = await connectClient(fixture.root); + try { + await rename(client, fixture.sessionId, 'Review project'); + await settleSource(client, fixture.sessionId); + await rename(client, collisionId, 'Review project (2)'); + await client.request('session.lifecycle.set', { sessionId: collisionId, state: 'archived' }); + const first = await branch(client, fixture.sessionId); + const second = await branch(client, fixture.sessionId); + const nested = await branch(client, first.id); + assert.deepEqual( + [first.name, second.name, nested.name], + ['Review project (1)', 'Review project (3)', 'Review project (4)'], + ); + assert.equal((await read(client, fixture.sessionId)).name, 'Review project'); + assert.equal(nested.parentSessionId, first.id); + assert.deepEqual(await branch(client, fixture.sessionId, first.id), first); + + await settleSource(client, first.id, 'branch-second-turn'); + const edit = await client.request('session.revision.create', { + sourceSessionId: first.id, + targetSessionId: randomUUID(), + sourceTurnId: 'branch-second-turn', + expectedSourceRevision: (await read(client, first.id)).revision, + }); + assert.ok(edit.kind === 'committed'); + const edited = session(edit.session); + assert.equal(edited.name, first.name); + assert.equal((await branch(client, edited.id)).name, 'Review project (5)'); + + await rename(client, first.id, 'New focus (2026)'); + assert.equal((await branch(client, first.id)).name, 'New focus (2026) (1)'); + assert.equal((await branch(client, fixture.sessionId, first.id)).name, 'New focus (2026)'); + + await rename(client, fixture.sessionId, 'Sprint (2026)'); + const numbered = await branch(client, fixture.sessionId); + assert.equal(numbered.name, 'Sprint (2026) (1)'); + assert.equal((await branch(client, numbered.id)).name, 'Sprint (2026) (2)'); + + await fixture.stopHost(host); + await fixture.startHost(); + const restarted = await connectClient(fixture.root); + try { + assert.equal((await read(restarted, second.id)).name, second.name); + assert.equal((await branch(restarted, numbered.id)).name, 'Sprint (2026) (3)'); + } finally { + await restarted.close(); + } + } finally { + await client.close(); + } + }); +}); + +test('concurrent branches from different sources reserve unique code-point-bounded names', async () => { + await withExecutionRoot(async (fixture) => { + const otherSourceId = await fixture.seedSession(); + await fixture.startHost(); + const desktop = await connectClient(fixture.root); + const tui = await connectClient(fixture.root); + try { + const name = '😀'.repeat(SESSION_NAME_MAX_CODE_POINTS); + await rename(desktop, fixture.sessionId, name); + await rename(tui, otherSourceId, name); + await settleSource(desktop, fixture.sessionId); + await settleSource(tui, otherSourceId, 'other-source-turn'); + const branches = await Promise.all( + Array.from({ length: 12 }, (_, index) => + branch( + index % 2 === 0 ? desktop : tui, + index % 2 === 0 ? fixture.sessionId : otherSourceId, + undefined, + index % 2 === 0 ? SOURCE_TURN_ID : 'other-source-turn', + ), + ), + ); + assert.equal(new Set(branches.map(({ name }) => name)).size, branches.length); + for (let index = 1; index <= branches.length; index += 1) { + const suffix = ` (${index})`; + const expected = '😀'.repeat(SESSION_NAME_MAX_CODE_POINTS - suffix.length) + suffix; + assert.ok( + branches.some(({ name }) => name === expected), + `Missing ${suffix}`, + ); + } + for (const created of branches) { + assert.equal(Array.from(created.name).length, SESSION_NAME_MAX_CODE_POINTS); + assert.equal((await read(tui, created.id)).name, created.name); + } + assert.equal((await read(desktop, fixture.sessionId)).name, name); + assert.equal((await read(tui, otherSourceId)).name, name); + } finally { + await Promise.all([desktop.close(), tui.close()]); + } + }); +}); diff --git a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts index 8e039340ca..fb49eb619b 100644 --- a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts @@ -403,6 +403,7 @@ async function verifyConcurrentRevisionAuthority( assert.equal(desktopBranch.kind, 'committed'); if (desktopBranch.kind !== 'committed') assert.fail('Branch must commit'); const branch = requireSessionProjection(desktopBranch.session); + assert.equal(branch.name, 'Source Session (1)'); assert.equal(branch.parentSessionId, sourceSessionId); assert.equal(branch.branchOfTurnId, 'turn-1'); assert.equal(branch.isFlagged, true); @@ -463,6 +464,7 @@ async function verifyConcurrentRevisionAuthority( assert.equal(revised.kind, 'committed'); if (revised.kind !== 'committed') assert.fail('Revision must commit'); const revision = requireSessionProjection(revised.session); + assert.equal(revision.name, renamedSource.name); assert.equal(revision.revisionRootSessionId, sourceSessionId); assert.equal(revision.revisionParentSessionId, sourceSessionId); assert.equal(revision.revisionOfTurnId, 'turn-2'); @@ -591,6 +593,7 @@ async function verifyConcurrentRevisionAuthority( if (sideConversation.kind !== 'committed') { assert.fail('Side Conversation must fork a settled Turn while the source keeps running'); } + assert.equal(requireSessionProjection(sideConversation.session).name, activeSource.name); assert.ok( requireSessionProjection(sideConversation.session).labels.includes( 'mode:side_conversation', diff --git a/packages/runtime-host/src/server/session-revision-coordinator.ts b/packages/runtime-host/src/server/session-revision-coordinator.ts index ceb9994da2..3383a01036 100644 --- a/packages/runtime-host/src/server/session-revision-coordinator.ts +++ b/packages/runtime-host/src/server/session-revision-coordinator.ts @@ -20,6 +20,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { isDeepResearchSession } from '@maka/core/deep-research'; import { SIDE_CONVERSATION_SESSION_LABEL } from '@maka/core/side-conversation'; +import { SESSION_NAME_MAX_CODE_POINTS } from '@maka/core/session-name'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { CreateSessionInput } from '@maka/core/runtime-inputs'; import { @@ -135,6 +136,7 @@ export class HostSessionRevisionCoordinator { readonly #stores: ExecutionStoresWriter<'interactive'>; readonly #artifacts: InteractiveArtifactStoreWriter; readonly #sessionTodo: InteractiveSessionTodoWriter; + #branchCreation: Promise = Promise.resolve(); constructor(private readonly options: HostSessionRevisionCoordinatorOptions) { this.#stores = authenticateExecutionStoresWriter(options.stores, 'interactive'); @@ -483,16 +485,27 @@ export class HostSessionRevisionCoordinator { return copyFailure('persistence_failed', 'Source execution boundary is unavailable'); } - const created = await this.#stores.sessionStore - .createStableSession( + const create = async () => { + if (kind === 'branch') { + const headers = await this.#stores.sessionStore.listHeaders(); + createInput.name = nextBranchName(sourceHeader, headers); + } + return this.#stores.sessionStore.createStableSession( { sessionId: input.targetSessionId, requestFingerprint, input: createInput, }, boundary, - ) - .catch(() => null); + ); + }; + // Different source lanes can choose the same title. Serialize only the + // name lookup and durable reservation, not the transcript/artifact copy. + const creation = (kind === 'branch' ? this.#branchCreation.then(create) : create()).catch( + () => null, + ); + if (kind === 'branch') this.#branchCreation = creation; + const created = await creation; if (!created) { return this.#unknownAfterCommitAttempt( kind, @@ -621,7 +634,7 @@ export class HostSessionRevisionCoordinator { state: 'committed', }, isFlagged: sourceHeader.isFlagged, - titleIsManual: sourceHeader.titleIsManual, + titleIsManual: kind === 'branch' ? false : sourceHeader.titleIsManual, connectionLocked: sourceHeader.connectionLocked || copiedMessages.some((message) => message.type === 'user'), @@ -920,6 +933,23 @@ export class HostSessionRevisionCoordinator { } } +function nextBranchName(source: SessionHeader, headers: readonly SessionHeader[]): string { + // Revisions retain their branch lineage; manual names and side conversations + // are literal titles, even when they happen to end in a number. + const base = + source.parentSessionId && !source.titleIsManual && !source.conversationCopy?.intent + ? source.name.replace(/ \([1-9]\d*\)$/u, '') + : source.name; + const codePoints = Array.from(base); + const names = new Set(headers.map((header) => header.name)); + for (let index = 1; ; index += 1) { + const suffix = ` (${index})`; + const limit = SESSION_NAME_MAX_CODE_POINTS - suffix.length; + const name = codePoints.slice(0, limit).join('').trimEnd() + suffix; + if (!names.has(name)) return name; + } +} + function isConversationRuntimeFactRewriteUnsupported(error: unknown): boolean { return ( error instanceof Error && From 307ac61f4a5f8c055c088bd0ba51eb8d791b271a Mon Sep 17 00:00:00 2001 From: sunrioa <178722768+sunrioa@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:55:50 +0800 Subject: [PATCH 2/2] fix: preserve branch title provenance and full base --- packages/core/src/session.ts | 2 + .../src/__tests__/session-branch-name.test.ts | 76 +++++++++++++++++++ .../server/session-revision-coordinator.ts | 23 ++++-- .../src/__tests__/session-store.test.ts | 43 +++++++++++ packages/storage/src/session-store.ts | 14 +++- 5 files changed, 150 insertions(+), 8 deletions(-) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 342ff47eaf..8122ca2537 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -243,6 +243,8 @@ export interface SessionHeader { // User metadata name: string; titleIsManual: boolean; + /** Host-generated branch title and its untruncated base; absent on legacy Sessions. */ + branchNameOrigin?: { readonly base: string; readonly name: string }; isFlagged: boolean; labels: string[]; diff --git a/packages/runtime-host/src/__tests__/session-branch-name.test.ts b/packages/runtime-host/src/__tests__/session-branch-name.test.ts index 2e4ed4b9a6..89d9ec5a65 100644 --- a/packages/runtime-host/src/__tests__/session-branch-name.test.ts +++ b/packages/runtime-host/src/__tests__/session-branch-name.test.ts @@ -21,6 +21,9 @@ import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; import { test } from 'node:test'; import { SESSION_NAME_MAX_CODE_POINTS } from '@maka/core/session-name'; +import type { SessionHeaderPatch } from '@maka/core/session'; +import { openInteractiveExecutionStoresForWrite } from '@maka/storage/execution-stores'; +import { tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority'; import type { RuntimeHostConnection } from '../client/index.js'; import type { SessionCatalogItem } from '../protocol/index.js'; import { @@ -28,10 +31,68 @@ import { requireStartedTurn, waitForTerminalTurn, withExecutionRoot, + type ExecutionFixture, } from './fixtures/execution-host-suite.js'; const SOURCE_TURN_ID = 'branch-name-source-turn'; +// Mutate only while the Host is stopped, to model valid pre-feature metadata. +async function seedHeader(fixture: ExecutionFixture, sessionId: string, patch: SessionHeaderPatch) { + const owner = await tryAcquireInteractiveRootOwner(fixture.capability); + assert.ok(owner); + try { + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + try { + await stores.sessionStore.updateHeader(sessionId, patch); + } finally { + await stores.sessionStore.close?.(); + } + } finally { + await owner.close(); + } +} + +test('legacy auto-titled branches preserve literal numeric endings across restart', async () => { + await withExecutionRoot(async (fixture) => { + const host = await fixture.startHost(); + const client = await connectClient(fixture.root); + let legacyId: string; + let renamedAutoId: string; + try { + await settleSource(client, fixture.sessionId); + legacyId = (await branch(client, fixture.sessionId)).id; + renamedAutoId = (await branch(client, fixture.sessionId)).id; + } finally { + await client.close(); + await fixture.stopHost(host); + } + await seedHeader(fixture, fixture.sessionId, { + name: 'Review project (2026)', + titleIsManual: false, + }); + await seedHeader(fixture, legacyId, { + name: 'Review project (2026)', + titleIsManual: false, + branchNameOrigin: undefined, + }); + await seedHeader(fixture, renamedAutoId, { name: 'Automatic (2030)', titleIsManual: false }); + await fixture.startHost(); + const restarted = await connectClient(fixture.root); + try { + const first = await branch(restarted, legacyId); + assert.equal(first.name, 'Review project (2026) (1)'); + assert.equal((await branch(restarted, first.id)).name, 'Review project (2026) (2)'); + assert.equal((await read(restarted, legacyId)).name, 'Review project (2026)'); + assert.equal((await branch(restarted, renamedAutoId)).name, 'Automatic (2030) (1)'); + // Even renaming to the recorded generated name makes the title literal. + await rename(restarted, first.id, first.name); + assert.equal((await branch(restarted, first.id)).name, 'Review project (2026) (1) (1)'); + } finally { + await restarted.close(); + } + }); +}); + function session(projection: SessionCatalogItem) { assert.ok(!('reason' in projection), 'Expected a wire-representable Session'); return projection; @@ -177,6 +238,21 @@ test('concurrent branches from different sources reserve unique code-point-bound assert.equal(Array.from(created.name).length, SESSION_NAME_MAX_CODE_POINTS); assert.equal((await read(tui, created.id)).name, created.name); } + // Crossing a suffix-width boundary must recover the original base, + // not reuse an already truncated title from a two-digit branch. + const twoDigit = branches.find(({ name }) => name.endsWith(' (10)'))!; + await rename(desktop, branches.find(({ name }) => name.endsWith(' (1)'))!.id, 'Released'); + assert.equal( + ( + await branch( + desktop, + twoDigit.id, + undefined, + twoDigit.parentSessionId === fixture.sessionId ? SOURCE_TURN_ID : 'other-source-turn', + ) + ).name, + '😀'.repeat(SESSION_NAME_MAX_CODE_POINTS - ' (1)'.length) + ' (1)', + ); assert.equal((await read(desktop, fixture.sessionId)).name, name); assert.equal((await read(tui, otherSourceId)).name, name); } finally { diff --git a/packages/runtime-host/src/server/session-revision-coordinator.ts b/packages/runtime-host/src/server/session-revision-coordinator.ts index 3383a01036..d3795551ba 100644 --- a/packages/runtime-host/src/server/session-revision-coordinator.ts +++ b/packages/runtime-host/src/server/session-revision-coordinator.ts @@ -103,6 +103,7 @@ interface ConversationCopyAdmissionRetry { readonly sessionIds: readonly string[]; } type ConversationCopyCreateInput = CreateSessionInput & { + branchNameOrigin?: SessionHeader['branchNameOrigin']; readonly conversationCopy: SessionConversationCopy; }; @@ -488,7 +489,8 @@ export class HostSessionRevisionCoordinator { const create = async () => { if (kind === 'branch') { const headers = await this.#stores.sessionStore.listHeaders(); - createInput.name = nextBranchName(sourceHeader, headers); + createInput.branchNameOrigin = nextBranchName(sourceHeader, headers); + createInput.name = createInput.branchNameOrigin.name; } return this.#stores.sessionStore.createStableSession( { @@ -737,6 +739,9 @@ export class HostSessionRevisionCoordinator { collaborationMode: source.collaborationMode ?? 'agent', orchestrationMode: source.orchestrationMode ?? 'default', name: source.name, + ...(kind === 'revision' && source.branchNameOrigin + ? { branchNameOrigin: source.branchNameOrigin } + : {}), labels: kind === 'side_conversation' ? [...new Set([...source.labels, SIDE_CONVERSATION_SESSION_LABEL])] @@ -933,12 +938,16 @@ export class HostSessionRevisionCoordinator { } } -function nextBranchName(source: SessionHeader, headers: readonly SessionHeader[]): string { - // Revisions retain their branch lineage; manual names and side conversations - // are literal titles, even when they happen to end in a number. +function nextBranchName( + source: SessionHeader, + headers: readonly SessionHeader[], +): NonNullable { + // Only persisted provenance establishes that a suffix was generated here. + // Legacy auto-titled branches may have literal numeric endings. A rename + // invalidates the recorded name, while revisions retain its provenance. const base = - source.parentSessionId && !source.titleIsManual && !source.conversationCopy?.intent - ? source.name.replace(/ \([1-9]\d*\)$/u, '') + !source.titleIsManual && source.branchNameOrigin?.name === source.name + ? source.branchNameOrigin.base : source.name; const codePoints = Array.from(base); const names = new Set(headers.map((header) => header.name)); @@ -946,7 +955,7 @@ function nextBranchName(source: SessionHeader, headers: readonly SessionHeader[] const suffix = ` (${index})`; const limit = SESSION_NAME_MAX_CODE_POINTS - suffix.length; const name = codePoints.slice(0, limit).join('').trimEnd() + suffix; - if (!names.has(name)) return name; + if (!names.has(name)) return { base, name }; } } diff --git a/packages/storage/src/__tests__/session-store.test.ts b/packages/storage/src/__tests__/session-store.test.ts index adb741d19f..e4309a15ee 100644 --- a/packages/storage/src/__tests__/session-store.test.ts +++ b/packages/storage/src/__tests__/session-store.test.ts @@ -43,6 +43,49 @@ import { OPERATIONAL_STATE_DATABASE_NAME } from '../operational-state-store.js'; import { createSqliteSessionMetadataStore } from '../sqlite-session-metadata-store.js'; describe('SQLite SessionStore', () => { + test('stable branch title provenance survives reopening without requiring it on legacy headers', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-branch-title-origin-')); + const store = createSessionStore(root); + const origin = { base: 'Review (2026)', name: 'Review (2026) (1)' }; + try { + const legacy = await store.create(makeInput({ cwd: root, name: 'Review (2026)' })); + assert.equal(normalizeSessionHeader(legacy).branchNameOrigin, undefined); + const created = await store.createStableSession({ + sessionId: 'numbered-branch', + requestFingerprint: `sha256:${'c'.repeat(64)}`, + input: { ...makeInput({ cwd: root, name: origin.name }), branchNameOrigin: origin }, + }); + assert.equal(created.kind, 'created'); + assert.deepEqual( + (await store.readHeaderSnapshot('numbered-branch')).branchNameOrigin, + origin, + ); + for (const invalid of [ + null, + {}, + { base: 7, name: origin.name }, + { base: '', name: origin.name }, + ]) { + assert.throws( + () => normalizeSessionHeader({ ...legacy, branchNameOrigin: invalid } as SessionHeader), + /malformed fields/, + ); + } + } finally { + await store.close?.(); + } + const reopened = createSessionStore(root); + try { + assert.deepEqual( + (await reopened.readHeaderSnapshot('numbered-branch')).branchNameOrigin, + origin, + ); + } finally { + await reopened.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + test('requires the reserved WorkHub Coordination identity and role together', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-workhub-coordination-identity-role-')); const store = createSessionStore(root); diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index cdfb7c5951..18d007183d 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -204,6 +204,7 @@ export interface WorkHubMessageAssignmentResult { } export type StableSessionCreateInput = CreateSessionInput & { + readonly branchNameOrigin?: SessionHeader['branchNameOrigin']; readonly conversationCopy?: SessionConversationCopy; readonly role?: SessionRole; }; @@ -1343,7 +1344,7 @@ function assertCoordinationIdentityPairing(sessionId: string, role: SessionRole function buildSessionHeader( workspaceRoot: string, - input: CreateSessionInput & { readonly role?: SessionRole }, + input: StableSessionCreateInput, sessionId: string = randomUUID(), conversationCopy?: SessionConversationCopy, ): SessionHeader { @@ -1368,6 +1369,7 @@ function buildSessionHeader( createdAt: now, name, titleIsManual: false, + ...(input.branchNameOrigin ? { branchNameOrigin: input.branchNameOrigin } : {}), isFlagged: false, labels: input.labels ?? [], isArchived: false, @@ -1414,6 +1416,11 @@ function normalizeRequiredSessionName(name: string): string { return normalized.value; } +function isCanonicalSessionName(value: unknown): value is string { + const normalized = normalizeUserSessionName(value); + return normalized.ok && normalized.value === value; +} + /** Validate and normalize a current SessionHeader before canonical persistence. */ export function normalizeSessionHeader( header: SessionHeader, @@ -1431,6 +1438,11 @@ export function normalizeSessionHeader( (header.lastMessageAt === undefined || isFiniteNumber(header.lastMessageAt)) && typeof header.name === 'string' && typeof header.titleIsManual === 'boolean' && + (header.branchNameOrigin === undefined || + (header.branchNameOrigin !== null && + typeof header.branchNameOrigin === 'object' && + isCanonicalSessionName(header.branchNameOrigin.base) && + isCanonicalSessionName(header.branchNameOrigin.name))) && typeof header.isFlagged === 'boolean' && Array.isArray(header.labels) && header.labels.every((label) => typeof label === 'string') &&