From 05a340d315ee5b2a4ec61401d69d58dff6674b57 Mon Sep 17 00:00:00 2001 From: eshurakov <54751+eshurakov@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:19:53 +0000 Subject: [PATCH] fix(session-ingest): treat creation default title as placeholder for agent-generated titles PR #4852 gated agent-generated title writes on `title IS NULL`, but cloud-agent-next creates cli_sessions_v2 rows with a non-null default title (`New session - ` via createSessionForCloudAgent), so the auto-generated title from the ingest pipeline was always dropped for those sessions. Reuse the existing isDefaultSessionTitle semantics (NULL or the CLI default-title pattern) already used by POST /session/:sessionId/title, extracted into a shared module, so applyMetadataChanges promotes the title away from either placeholder form while still never overwriting a user-chosen title. Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .../src/ingest/default-session-title.ts | 15 +++++++++++++++ .../session-ingest/src/ingest/metadata.test.ts | 18 ++++++++++++++++++ services/session-ingest/src/ingest/metadata.ts | 12 +++++++----- services/session-ingest/src/routes/api.ts | 11 +---------- 4 files changed, 41 insertions(+), 15 deletions(-) create mode 100644 services/session-ingest/src/ingest/default-session-title.ts diff --git a/services/session-ingest/src/ingest/default-session-title.ts b/services/session-ingest/src/ingest/default-session-title.ts new file mode 100644 index 0000000000..c1aeab6bcd --- /dev/null +++ b/services/session-ingest/src/ingest/default-session-title.ts @@ -0,0 +1,15 @@ +// Duplicated from kilocode packages/opencode/src/session/session.ts:55-62 +// (isDefaultTitle). Keep in sync when the CLI default-title pattern changes. +export const DEFAULT_SESSION_TITLE_PATTERN = + /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +/** + * A session title counts as the creation placeholder when it is still NULL or when it + * holds the default title stamped at creation time (e.g. cloud-agent-next inserts + * `New session - ` via createSessionForCloudAgent). Agent-generated + * titles may promote a placeholder title but must never overwrite a user-chosen one. + */ +export function isDefaultSessionTitle(title: string | null | undefined): boolean { + if (title == null) return true; + return DEFAULT_SESSION_TITLE_PATTERN.test(title); +} diff --git a/services/session-ingest/src/ingest/metadata.test.ts b/services/session-ingest/src/ingest/metadata.test.ts index 7879ec6b22..bb7395b8bb 100644 --- a/services/session-ingest/src/ingest/metadata.test.ts +++ b/services/session-ingest/src/ingest/metadata.test.ts @@ -685,6 +685,24 @@ describe('applyMetadataChanges', () => { ); }); + it('applies the agent-generated title over the default title stamped at creation (cloud-agent-next)', async () => { + // cloud-agent-next creates rows with a non-null default title + // (`New session - `) via createSessionForCloudAgent; that title is + // still the creation placeholder and must not block the agent-generated title. + const db = createApplyMetadataDb({ initialTitle: 'New session - 2026-08-04T10:00:00.000Z' }); + vi.mocked(getWorkerDb).mockReturnValue(db as never); + + await applyMetadataChanges(env, 'usr_1', 'ses_1', new Map([['title', 'Agent title']])); + + expect(db.updateSets).toEqual([expect.objectContaining({ title: 'Agent title' })]); + expect(notifyUserSessionEvent).toHaveBeenCalledWith( + env, + 'usr_1', + expect.objectContaining({ type: 'session.updated' }), + undefined + ); + }); + it('skips the agent-generated title write when the user already renamed the session', async () => { const db = createApplyMetadataDb({ initialTitle: 'User chosen title' }); vi.mocked(getWorkerDb).mockReturnValue(db as never); diff --git a/services/session-ingest/src/ingest/metadata.ts b/services/session-ingest/src/ingest/metadata.ts index 9a24c85454..67c04d1f92 100644 --- a/services/session-ingest/src/ingest/metadata.ts +++ b/services/session-ingest/src/ingest/metadata.ts @@ -8,6 +8,7 @@ import { getSessionAccessCacheDO } from '../dos/SessionAccessCacheDO'; import { isNeedsInputStatus } from '../dos/session-ingest-attention'; import { mapSessionEventRow, notifyUserSessionEvent } from '../session-events'; import { SessionStatusSchema } from '../types/user-connection-protocol'; +import { isDefaultSessionTitle } from './default-session-title'; /** Stored status written when a CLI disconnects while the session is waiting on input. */ export const CLI_DISCONNECT_ATTENTION_RESET_STATUS = 'retry' as const; @@ -131,12 +132,13 @@ export async function applyMetadataChanges( })(); // Agent-generated titles arrive asynchronously and can race a user rename. A session's - // title starts out NULL (the placeholder set at row creation); only promote it from that - // placeholder here. Once the title is non-null — whether from a user rename or an earlier - // agent-generated write — leave it alone so a later user rename can never be clobbered by - // an in-flight ingest. + // title starts out as a creation placeholder — NULL, or the default title stamped at + // creation (e.g. cloud-agent-next inserts `New session - `); only promote + // it from that placeholder here. Once the title holds anything else — whether from a user + // rename or an earlier agent-generated write — leave it alone so a later user rename can + // never be clobbered by an in-flight ingest. if (mergedChanges.has('title')) { - if (currentRow.title === null) { + if (isDefaultSessionTitle(currentRow.title)) { titleWriteApplied = true; } else { console.warn('Skipping agent-generated title write; title is no longer the placeholder', { diff --git a/services/session-ingest/src/routes/api.ts b/services/session-ingest/src/routes/api.ts index 552b667696..9fce023974 100644 --- a/services/session-ingest/src/routes/api.ts +++ b/services/session-ingest/src/routes/api.ts @@ -18,6 +18,7 @@ import { getUserConnectionDO } from '../dos/UserConnectionDO'; import { getSessionExport } from '../services/session-export'; import { mapSessionEventRow, notifyUserSessionEvent } from '../session-events'; import { handleDirectIngestRequest } from '../ingest/direct-ingest'; +import { isDefaultSessionTitle } from '../ingest/default-session-title'; import { resolveAccessibleKiloSession } from '../services/session-access'; export type ApiContext = { @@ -525,16 +526,6 @@ api.post('/session/:sessionId/unshare', async c => { return c.json({ success: true }, 200); }); -// Duplicated from kilocode packages/opencode/src/session/session.ts:55-62 -// (isDefaultTitle). Keep in sync when the CLI default-title pattern changes. -const DEFAULT_SESSION_TITLE_PATTERN = - /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; - -function isDefaultSessionTitle(title: string | null | undefined): boolean { - if (title == null) return true; - return DEFAULT_SESSION_TITLE_PATTERN.test(title); -} - const reportSessionTitleSchema = z.object({ title: z.string().trim().min(1).max(200), generated: z.boolean(),