From 7276663cd6d30e17ca86f7368fa1c8ea751c5713 Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" Date: Mon, 24 Aug 2026 21:20:36 -0400 Subject: [PATCH 1/7] fix(sessions): persist auto-title flags and turnCount across restart (lr-62157d) buildMetaLine never wrote titleAutoGenerated/titleManuallySet/turnCount, and loadSessions never hydrated them, so every daemon restart silently reset all three to false/0. The guards at sdk-message-processor.js and sdk-bridge.js that depend on this state were guarding against state that could never survive a restart -- the keystone cause of auto-title never having fired on this machine (MILLER fnd-d21956). Follows the existing write-when-true convention used by bookmarked/pendingAutoResume. Also persists titleProvisional (used by the follow-up commit's guard fix). --- lib/sessions.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/lib/sessions.js b/lib/sessions.js index 60b88865..25b34f10 100644 --- a/lib/sessions.js +++ b/lib/sessions.js @@ -150,6 +150,22 @@ function createSessionManager(opts) { metaObj.pendingAutoResume = true; if (session.pendingAutoResumeReason) metaObj.pendingAutoResumeReason = session.pendingAutoResumeReason; } + // lr-62157d: durable auto-title state. Without this, titleAutoGenerated + // and titleManuallySet lived in memory only — every daemon restart reset + // both to false, so the "already auto-titled" / "user renamed it" guards + // at sdk-message-processor.js and sdk-bridge.js were guarding against + // state that could never actually be true after a restart. turnCount is + // persisted alongside them because the turn-threshold gate (see the + // '>=' fix at sdk-message-processor.js) depends on it surviving too — + // a restarted session that lost its turn count would silently miss the + // auto-title window forever instead of catching up on a later turn. + // titleProvisional marks a title set from the raw first-message + // truncation (project-user-message.js) so autoGenerateTitle knows it is + // safe to overwrite even though the flags above look "not yet set". + if (session.titleAutoGenerated) metaObj.titleAutoGenerated = true; + if (session.titleManuallySet) metaObj.titleManuallySet = true; + if (session.titleProvisional) metaObj.titleProvisional = true; + if (typeof session.turnCount === "number" && session.turnCount > 0) metaObj.turnCount = session.turnCount; return JSON.stringify(metaObj); } @@ -743,6 +759,14 @@ function createSessionManager(opts) { session.pendingAutoResume = true; session.pendingAutoResumeReason = m.pendingAutoResumeReason || null; } + // lr-62157d: restore auto-title state across a daemon restart — see + // buildMetaLine's write side for why this must persist. Without this, + // the guards at sdk-message-processor.js/sdk-bridge.js always saw + // false/0 after a restart no matter what actually happened before it. + session.titleAutoGenerated = !!m.titleAutoGenerated; + session.titleManuallySet = !!m.titleManuallySet; + session.titleProvisional = !!m.titleProvisional; + session.turnCount = typeof m.turnCount === "number" ? m.turnCount : 0; sessions.set(localId, session); } } From f91f7e6f19596d4caacdf52a82e3f7ba0da19f07 Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" Date: Mon, 24 Aug 2026 21:20:39 -0400 Subject: [PATCH 2/7] fix(sdk): make auto-title turn threshold gate recoverable (lr-62157d) '===' meant any turn incrementing past the threshold without evaluating the gate (early return, error path, non-result terminal event) permanently disqualified the session from auto-title. '>=' lets a missed window self-heal on a later turn; idempotence still comes from !session.titleAutoGenerated (now durable, prior commit). --- lib/sdk-message-processor.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/sdk-message-processor.js b/lib/sdk-message-processor.js index d996b243..34f4b2db 100644 --- a/lib/sdk-message-processor.js +++ b/lib/sdk-message-processor.js @@ -782,8 +782,14 @@ function attachMessageProcessor(ctx) { session.streamedText = false; sm.broadcastSessionList(); - // Auto-generate title after N turns (skip if loop or already auto-generated) - if (session.turnCount === AUTO_TITLE_TURN_THRESHOLD + // Auto-generate title after N turns (skip if loop or already auto-generated). + // lr-62157d: '===' -> '>=' so a session that skips past the exact + // threshold turn (early return, error path, non-result terminal event, + // or a turnCount that was never persisted before this fix) still gets + // titled on a later turn instead of being permanently disqualified. + // Idempotence comes from !session.titleAutoGenerated, which is now + // durable (see sessions.js buildMetaLine/loadSessions). + if (session.turnCount >= AUTO_TITLE_TURN_THRESHOLD && !session.titleAutoGenerated && !session.titleManuallySet && !session.loop From e102046f7c1fe86079ac947c8d3c588ff1262bb4 Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" Date: Mon, 24 Aug 2026 21:20:46 -0400 Subject: [PATCH 3/7] fix(project): source provisional title from paste, mark as provisional (lr-62157d) Two related defects in the first-message provisional title (project-user-message.js): 1. The provisional title looked exactly like a real title, so autoGenerateTitle's guard (next commit) had no signal telling it the raw 50-char truncation was safe to overwrite. session.title is now tagged with session.titleProvisional=true when set here. 2. A >=500-char paste with no typed text was titled the literal 'Image' -- the client diverts such pastes into msg.pastes[] with msg.text empty (input.js), so msg.text alone always fell through to the 'Image' fallback meant for the genuinely image-only case. Now falls back to msg.pastes[0], with whitespace collapsed before truncating (pastes are commonly multi-line). Extracted as deriveProvisionalTitle(), a pure function, and exported as _test_deriveProvisionalTitle following the _test_-prefixed exposure convention already used by yoke/adapters/codex.js's _test_resolveTitleModel. --- lib/project-user-message.js | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/lib/project-user-message.js b/lib/project-user-message.js index 18905e64..e1cc266f 100644 --- a/lib/project-user-message.js +++ b/lib/project-user-message.js @@ -25,6 +25,28 @@ var fs = require("fs"); * digestDmTurn, * adapter - YOKE adapter instance */ +// lr-62157d: pure helper — derives the FIRST-message provisional title +// (before any turn has completed / before autoGenerateTitle can run). +// Prefers real message text; falls back to the first paste when text is +// empty (a >=500-char paste is diverted server-side into msg.pastes[] with +// msg.text empty by the client's paste-chip handling — see input.js — so +// msg.text alone previously always fell through to the "Image" literal for +// a text paste, not just a genuine image-only message). "Image" is reserved +// for the case where neither text nor a paste is present. Whitespace is +// collapsed before truncating since pastes are commonly multi-line and an +// un-normalized 50-char slice renders as a broken sidebar entry. +// Exported as _test_deriveProvisionalTitle (see codex.js's +// _test_resolveTitleModel for the established convention) so this is +// directly unit-testable without constructing the full WS message handler. +function deriveProvisionalTitle(msg) { + var source = msg && msg.text; + if (!source && msg && msg.pastes && msg.pastes.length > 0) { + source = msg.pastes[0]; + } + var collapsed = source ? source.replace(/\s+/g, " ").trim().substring(0, 50) : ""; + return collapsed || "Image"; +} + function attachUserMessage(ctx) { var cwd = ctx.cwd; var slug = ctx.slug; @@ -365,7 +387,11 @@ function attachUserMessage(ctx) { sendToSessionOthers(ws, session.localId, hydrateImageRefs(userMsg2)); if (!session.title) { - session.title = (msg.text || "Image").substring(0, 50); + session.title = deriveProvisionalTitle(msg); + // This is only a provisional title — autoGenerateTitle (sdk-bridge.js) + // is allowed to overwrite it once a real LLM-generated title is ready, + // as long as the user hasn't manually renamed the session since. + session.titleProvisional = true; sm.saveSessionFile(session); sm.broadcastSessionList(); // Sync auto-title to SDK @@ -672,5 +698,5 @@ function attachUserMessage(ctx) { }; } -module.exports = { attachUserMessage: attachUserMessage }; +module.exports = { attachUserMessage: attachUserMessage, _test_deriveProvisionalTitle: deriveProvisionalTitle }; From 8b8b96fbc48ffb4e13f4906c2b2b9e48338703c9 Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" Date: Mon, 24 Aug 2026 21:20:51 -0400 Subject: [PATCH 4/7] fix(sdk): overwrite provisional title in autoGenerateTitle guard (lr-62157d) The guard used to be just !titleManuallySet, which could not tell 'already properly titled' apart from 'still carrying the raw first-message truncation' -- this is the third of three independently correct subsystems whose composition made auto-title dead (MILLER loop_class: double). Now overwrites when (titleProvisional || !title) && !titleManuallySet. Also returns the generateTitle().then() promise chain (previously fire-and-forget) so this is directly awaitable in tests; exposed as _test_autoGenerateTitle following the _test_-prefixed exposure convention. --- lib/sdk-bridge.js | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/lib/sdk-bridge.js b/lib/sdk-bridge.js index 86a0daee..4c6a61e8 100644 --- a/lib/sdk-bridge.js +++ b/lib/sdk-bridge.js @@ -649,12 +649,26 @@ function createSDKBridge(opts) { } console.log("[auto-title] Calling adapter.generateTitle with " + userMessages.length + " messages for session " + session.localId); - sessionAdapter.generateTitle(userMessages, { cwd: cwd }).then(function(title) { + // lr-62157d: returns the promise chain (previously fire-and-forget) so + // this is directly awaitable in tests via _test_autoGenerateTitle; the + // real onAutoTitle callers (sdk-message-processor.js) call this + // fire-and-forget already and are unaffected by a function now + // returning a value they don't use. + return sessionAdapter.generateTitle(userMessages, { cwd: cwd }).then(function(title) { if (!title || title.length < 2) return; title = title.substring(0, 100); - if (!session.titleManuallySet) { + // lr-62157d: overwrite whenever the current title is provisional (the + // raw first-message truncation from project-user-message.js, or no + // title at all) and the user hasn't manually renamed the session. + // Before titleProvisional existed, the first-message fallback looked + // exactly like a real title, so this guard had nothing that let it + // tell "already properly titled" apart from "still carrying the raw + // 50-char truncation" — this is what actually lets a real LLM title + // replace that truncation. + if ((session.titleProvisional || !session.title) && !session.titleManuallySet) { session.title = title; session.titleAutoGenerated = true; + session.titleProvisional = false; sm.saveSessionFile(session); sm.broadcastSessionList(); if (session.cliSessionId && typeof adapter.renameSession === "function") { @@ -2499,6 +2513,12 @@ function createSDKBridge(opts) { startIdleReaper: startIdleReaper, stopIdleReaper: stopIdleReaper, getMemoryStats: getMemoryStats, + // lr-62157d: exposed for direct unit testing of the provisional-title + // overwrite guard, following the _test_-prefixed exposure convention + // already used by lib/yoke/adapters/codex.js's resolveTitleModel. Not + // part of the bridge's real call surface (project.js never calls it by + // this name) — it is the same closure onAutoTitle wraps internally. + _test_autoGenerateTitle: autoGenerateTitle, }; } From f59ef92e4a24c3a84bf22221a81ecf617f764adf Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" Date: Mon, 24 Aug 2026 21:21:00 -0400 Subject: [PATCH 5/7] test(auto-title): cover durability, gate-reachability and provisional-overwrite (lr-62157d) Round-trip, gate-reachability and provisional-overwrite tests for the auto-title fix, per MILLER's diagnosis (fnd-d21956/fnd-3291e4): - session-auto-title-persist: buildMetaLine -> loadSessions round trip for titleAutoGenerated/titleManuallySet/titleProvisional/turnCount. - sdk-message-processor-auto-title-gate: turn-threshold gate fires when skipped past (not just landed on exactly), and titleAutoGenerated correctly suppresses a later fire. - sdk-bridge-auto-title-provisional-overwrite: autoGenerateTitle replaces a provisional title, never a manually-set one. - project-user-message-paste-title: a >=500-char paste with empty msg.text derives a title from the paste (whitespace-collapsed), not the literal 'Image'; image-only still yields 'Image'. All four files verified failing against pre-fix code with the predicted symptom (missing seam / gate never firing / flags reset to undefined on reload) before this fix landed -- 15 failures across the 4 new files plus 1 pre-existing unrelated flake (daemon EX_CONFIG test), none masking a false green. --- ...user-message-paste-title-lr-62157d.test.js | 74 +++++++ ...le-provisional-overwrite-lr-62157d.test.js | 146 ++++++++++++++ ...rocessor-auto-title-gate-lr-62157d.test.js | 173 +++++++++++++++++ ...ssion-auto-title-persist-lr-62157d.test.js | 180 ++++++++++++++++++ 4 files changed, 573 insertions(+) create mode 100644 test/project-user-message-paste-title-lr-62157d.test.js create mode 100644 test/sdk-bridge-auto-title-provisional-overwrite-lr-62157d.test.js create mode 100644 test/sdk-message-processor-auto-title-gate-lr-62157d.test.js create mode 100644 test/session-auto-title-persist-lr-62157d.test.js diff --git a/test/project-user-message-paste-title-lr-62157d.test.js b/test/project-user-message-paste-title-lr-62157d.test.js new file mode 100644 index 00000000..cc4cc9b8 --- /dev/null +++ b/test/project-user-message-paste-title-lr-62157d.test.js @@ -0,0 +1,74 @@ +"use strict"; +/** + * Regression test for lr-62157d (MILLER fnd-3291e4): a >=500-char paste + * with no typed text was titled the literal "Image", because + * project-user-message.js:368 only ever consulted msg.text (falling back + * to "Image") even though the paste text was fully in hand 17 lines below + * (concatenated into fullText). Chain: the client's paste-chip handling + * (input.js) diverts any paste >=500 chars into msg.pastes[] with msg.text + * left empty, so every such message hit the || "Image" fallback meant only + * for the genuinely image-only case. + * + * Fix: deriveProvisionalTitle() (lib/project-user-message.js) sources the + * title from msg.pastes[0] when msg.text is empty, collapses whitespace + * before truncating (pastes are commonly multi-line), and reserves "Image" + * for when neither text nor a paste is present. + * + * Drives the real exported helper (_test_deriveProvisionalTitle) — no + * reimplementation. + */ + +var test = require("node:test"); +var assert = require("node:assert/strict"); + +var { _test_deriveProvisionalTitle: deriveProvisionalTitle } = require("../lib/project-user-message"); + +test("lr-62157d: a >=500-char paste with empty msg.text produces a title derived from the paste, not the literal 'Image'", function () { + var pasteText = "function longFunction() {\n return 'this is a long pasted block of text that would previously be lost';\n}\n".repeat(6); + assert.ok(pasteText.length >= 500, "sanity: paste must actually be >=500 chars, matching the client's diversion threshold"); + + var msg = { type: "message", text: "", pastes: [pasteText] }; + var title = deriveProvisionalTitle(msg); + + assert.notEqual(title, "Image", "a paste-only message must not degrade to the literal 'Image'"); + assert.ok(title.length > 0 && title.length <= 50, "title must be truncated to the 50-char provisional-title bound"); +}); + +test("lr-62157d: paste-derived title collapses whitespace before truncating (multi-line paste does not render as a broken sidebar entry)", function () { + var pasteText = "line one\n\n line two with extra spaces\nline three\nline four\nline five"; + var msg = { type: "message", text: "", pastes: [pasteText] }; + + var title = deriveProvisionalTitle(msg); + + assert.ok(!/\n/.test(title), "title must not contain raw newlines from the paste"); + assert.ok(!/ {2,}/.test(title), "title must not contain runs of multiple spaces from the paste"); + assert.equal(title, "line one line two with extra spaces line three lin".substring(0, 50)); +}); + +test("lr-62157d: an image-only message (no text, no pastes) still yields the literal 'Image'", function () { + var msg = { type: "message", text: "", images: [{ mediaType: "image/png", data: "..." }] }; + + var title = deriveProvisionalTitle(msg); + + assert.equal(title, "Image", "the genuinely image-only case must keep the 'Image' fallback"); +}); + +test("lr-62157d: real typed text is still preferred over any paste when both are present", function () { + var msg = { type: "message", text: "my actual typed question", pastes: ["some pasted context that should not be used for the title"] }; + + var title = deriveProvisionalTitle(msg); + + assert.equal(title, "my actual typed question"); +}); + +test("lr-62157d: an empty-string paste array entry does not crash and still falls back sanely", function () { + var msg = { type: "message", text: "", pastes: [""] }; + + assert.doesNotThrow(function () { + var title = deriveProvisionalTitle(msg); + // An empty first paste has no text to derive a title from — falling + // back to "Image" here is acceptable (there is nothing else to show), + // the important behavior this test pins is "does not throw". + assert.equal(typeof title, "string"); + }); +}); diff --git a/test/sdk-bridge-auto-title-provisional-overwrite-lr-62157d.test.js b/test/sdk-bridge-auto-title-provisional-overwrite-lr-62157d.test.js new file mode 100644 index 00000000..c1c0efd7 --- /dev/null +++ b/test/sdk-bridge-auto-title-provisional-overwrite-lr-62157d.test.js @@ -0,0 +1,146 @@ +"use strict"; +/** + * Regression test for lr-62157d (MILLER fnd-d21956, keystone cause #3): + * the first-message provisional title (project-user-message.js) looked + * exactly like a real title — nothing distinguished "already properly + * titled" from "still carrying the raw 50-char truncation" — so + * autoGenerateTitle's guard in sdk-bridge.js never had a signal telling it + * it was safe to overwrite. Every session kept its provisional truncation + * forever, even once autoGenerateTitle actually ran. + * + * Fix: session.titleProvisional marks a title set by the first-message + * fallback; autoGenerateTitle now overwrites when + * (titleProvisional || !title) && !titleManuallySet. + * + * Drives the real autoGenerateTitle() closure from lib/sdk-bridge.js + * (exposed as _test_autoGenerateTitle for this purpose — see codex.js's + * _test_resolveTitleModel for the established precedent) — no + * reimplementation of the guard logic. + */ + +var test = require("node:test"); +var assert = require("node:assert/strict"); + +function freshSdkBridge() { + var modPath = require.resolve("../lib/sdk-bridge"); + delete require.cache[modPath]; + return require("../lib/sdk-bridge"); +} + +function makeSm(overrides) { + var sm = { + defaultVendor: "claude", + saveSessionFile: function () {}, + broadcastSessionList: function () {}, + loadFullSessionHistory: function () {}, + retrimHistory: function () {}, + }; + return Object.assign(sm, overrides || {}); +} + +function makeAdapter(generatedTitle) { + return { + vendor: "claude", + generateTitle: function () { return Promise.resolve(generatedTitle); }, + renameSession: function () { return Promise.resolve(); }, + }; +} + +function makeSession(overrides) { + var base = { + localId: 1, + cliSessionId: null, + vendor: "claude", + history: [ + { type: "user_message", text: "hello there, this is my first message" }, + ], + _historyBaseIndex: 0, + title: "", + titleProvisional: false, + titleAutoGenerated: false, + titleManuallySet: false, + }; + return Object.assign(base, overrides || {}); +} + +function makeBridge(sm, adapter) { + var { createSDKBridge } = freshSdkBridge(); + return createSDKBridge({ + cwd: "/tmp/test-project-lr62157d", + sessionManager: sm, + send: function () {}, + adapter: adapter, + adapters: { claude: adapter }, + onProcessingChanged: function () {}, + }); +} + +test("lr-62157d: autoGenerateTitle REPLACES a provisional title (titleProvisional=true) with the LLM-generated title", function () { + var sm = makeSm(); + var adapter = makeAdapter("A Real Generated Title"); + var bridge = makeBridge(sm, adapter); + var session = makeSession({ + title: "hello there, this is my first mess", // raw 50-char truncation, the pre-fix signature + titleProvisional: true, + }); + + return bridge._test_autoGenerateTitle(session).then(function () { + assert.equal(session.title, "A Real Generated Title", "provisional title must be replaced by the real generated title"); + assert.equal(session.titleAutoGenerated, true); + assert.equal(session.titleProvisional, false, "titleProvisional must be cleared once a real title lands"); + }); +}); + +test("lr-62157d: autoGenerateTitle does NOT replace a user-set title (titleManuallySet=true), even if titleProvisional is also true", function () { + var sm = makeSm(); + var adapter = makeAdapter("A Real Generated Title"); + var bridge = makeBridge(sm, adapter); + var session = makeSession({ + title: "My Custom Renamed Title", + titleManuallySet: true, + titleProvisional: false, + }); + + return bridge._test_autoGenerateTitle(session).then(function () { + assert.equal(session.title, "My Custom Renamed Title", "a manually-renamed title must never be overwritten by auto-title"); + assert.equal(session.titleAutoGenerated, false); + }); +}); + +test("lr-62157d: autoGenerateTitle fills in a title when none exists yet (no title, titleProvisional false) as long as not manually set", function () { + var sm = makeSm(); + var adapter = makeAdapter("Freshly Generated Title"); + var bridge = makeBridge(sm, adapter); + var session = makeSession({ + title: "", + titleProvisional: false, + titleManuallySet: false, + }); + + return bridge._test_autoGenerateTitle(session).then(function () { + assert.equal(session.title, "Freshly Generated Title"); + assert.equal(session.titleAutoGenerated, true); + }); +}); + +test("lr-62157d: a second autoGenerateTitle call does NOT clobber an already-real (non-provisional) title", function () { + var sm = makeSm(); + var adapter = makeAdapter("Second Call Title"); + var bridge = makeBridge(sm, adapter); + var session = makeSession({ + title: "First Generated Title", + titleAutoGenerated: true, + titleProvisional: false, + titleManuallySet: false, + }); + + // autoGenerateTitle's own guard is (titleProvisional || !title) && + // !titleManuallySet -- it does NOT re-check titleAutoGenerated itself + // (that re-fire prevention lives in sdk-message-processor.js's trigger + // gate). What DOES stop a second call from clobbering a real title here + // is that the title is present and no longer provisional -- this test + // pins that half of the guard directly, independent of the trigger gate. + return bridge._test_autoGenerateTitle(session).then(function () { + assert.equal(session.title, "First Generated Title", "a real, non-provisional title must not be overwritten by a later autoGenerateTitle call"); + }); +}); diff --git a/test/sdk-message-processor-auto-title-gate-lr-62157d.test.js b/test/sdk-message-processor-auto-title-gate-lr-62157d.test.js new file mode 100644 index 00000000..af19af4c --- /dev/null +++ b/test/sdk-message-processor-auto-title-gate-lr-62157d.test.js @@ -0,0 +1,173 @@ +"use strict"; +/** + * Regression test for lr-62157d (MILLER fnd-d21956): the auto-title trigger + * in sdk-message-processor.js's 'result' handler used exact equality + * (`session.turnCount === AUTO_TITLE_TURN_THRESHOLD`), so any turn that + * incremented past the threshold turn without evaluating the gate (early + * return, error path, non-result terminal event, or — before this task's + * durability fix — a turnCount that reset to 0 on a daemon restart) + * permanently disqualified the session from ever being auto-titled again. + * + * Fix: '===' -> '>=', so a missed window self-heals on a later turn instead + * of disqualifying the session forever. Idempotence still comes from + * !session.titleAutoGenerated. + * + * Drives the real attachMessageProcessor()/processSDKMessage() code path + * from lib/sdk-message-processor.js with a synthetic 'result' event — same + * pattern as test/permission-request-index-sweep-lr-f940.test.js. + */ + +var test = require("node:test"); +var assert = require("node:assert/strict"); + +var { attachMessageProcessor } = require("../lib/sdk-message-processor"); + +function makeSm() { + return { + skillMeta: [], + workflowMeta: [], + skillNames: [], + slashCommands: null, + currentModel: null, + _savedDefaultModel: null, + permissionRequestIndex: {}, + sendAndRecord: function (session, obj) { + if (!session.history) session.history = []; + session.history.push(obj); + }, + sendToSession: function () {}, + saveSessionFile: function () {}, + broadcastSessionList: function () {}, + modelsByVendor: {}, + availableModels: [], + availableVendors: [], + installedVendors: [], + }; +} + +function makeProcessor(sm, onAutoTitle) { + return attachMessageProcessor({ + sm: sm, + send: function () {}, + slug: "test-slug", + cwd: "/tmp", + pushModule: null, + getNotificationsModule: function () { return null; }, + adapter: { vendor: "claude" }, + onProcessingChanged: function () {}, + onTurnDone: null, + onAutoTitle: onAutoTitle || null, + opts: {}, + discoverSkillDirs: function () { return []; }, + mergeSkills: function () { return []; }, + discoverWorkflows: function () { return []; }, + discoverSkillsWithMeta: function () { return []; }, + mergeSkillsWithMeta: function () { return []; }, + getSDK: null, + }); +} + +function makeSession(overrides) { + var base = { + localId: 1, + cliSessionId: null, + vendor: "claude", + history: [], + messageUUIDs: [], + blocks: {}, + sentToolResults: {}, + pendingPermissions: {}, + pendingElicitations: {}, + pendingAskUser: {}, + activeTaskToolIds: {}, + taskIdMap: {}, + streamedText: false, + responsePreview: "", + isProcessing: true, + loop: null, + titleAutoGenerated: false, + titleManuallySet: false, + }; + return Object.assign(base, overrides || {}); +} + +function fireResult(processor, session, cost) { + processor.processSDKMessage(session, { + yokeType: "result", + cost: cost === undefined ? 0.1 : cost, + duration: 500, + sessionId: "cli-session-lr62157d", + }); +} + +test("lr-62157d: a session that SKIPS PAST the exact turn-2 window (turnCount jumps 1 -> 3) still fires auto-title on that later turn", function () { + var sm = makeSm(); + var firedForSessions = []; + var processor = makeProcessor(sm, function (session) { firedForSessions.push(session.localId); }); + var session = makeSession({ localId: 101, turnCount: 2 }); // one turn already elapsed pre-jump + + // Simulate a turn boundary that jumps the counter past 2 directly to 3 — + // e.g. a resumed/rehydrated session whose turnCount was not incremented + // through the normal 1 -> 2 -> 3 path. The 'result' handler itself does + // session.turnCount = (session.turnCount || 0) + 1, so starting turnCount + // at 2 here means this fireResult call lands on turnCount === 3. + fireResult(processor, session); + + assert.equal(session.turnCount, 3, "sanity: turnCount incremented past the exact threshold"); + assert.deepEqual(firedForSessions, [101], "onAutoTitle must still fire when turnCount lands past (not exactly on) the threshold — this is the '>=' fix; it fails against '===' pre-fix code"); +}); + +test("lr-62157d: a session that lands EXACTLY on the turn-2 threshold still fires (no regression on the original case)", function () { + var sm = makeSm(); + var firedForSessions = []; + var processor = makeProcessor(sm, function (session) { firedForSessions.push(session.localId); }); + var session = makeSession({ localId: 102, turnCount: 1 }); + + fireResult(processor, session); + + assert.equal(session.turnCount, 2); + assert.deepEqual(firedForSessions, [102], "the original exact-threshold case must keep working"); +}); + +test("lr-62157d: titleAutoGenerated correctly suppresses a second fire on a later turn (idempotence)", function () { + var sm = makeSm(); + var fireCount = 0; + var processor = makeProcessor(sm, function () { fireCount++; }); + var session = makeSession({ localId: 103, turnCount: 1 }); + + fireResult(processor, session); // turnCount -> 2, fires once + assert.equal(fireCount, 1); + + // Simulate autoGenerateTitle's own effect (sdk-bridge.js) having landed + // between turns, as it would in production once the async generateTitle() + // promise resolves. + session.titleAutoGenerated = true; + + fireResult(processor, session); // turnCount -> 3, must NOT fire again + assert.equal(fireCount, 1, "titleAutoGenerated=true must suppress every subsequent turn's gate, not just the immediate next one"); + + fireResult(processor, session); // turnCount -> 4, still must not fire + assert.equal(fireCount, 1); +}); + +test("lr-62157d: a loop session never fires auto-title regardless of turnCount (pre-existing exclusion, unaffected by the >= change)", function () { + var sm = makeSm(); + var fireCount = 0; + var processor = makeProcessor(sm, function () { fireCount++; }); + var session = makeSession({ localId: 104, turnCount: 5, loop: { loopId: "loop-1" } }); + + fireResult(processor, session); + + assert.equal(fireCount, 0, "session.loop must still exclude auto-title firing"); +}); + +test("lr-62157d: a session with titleManuallySet=true never fires auto-title even past the threshold", function () { + var sm = makeSm(); + var fireCount = 0; + var processor = makeProcessor(sm, function () { fireCount++; }); + var session = makeSession({ localId: 105, turnCount: 4, titleManuallySet: true }); + + fireResult(processor, session); + + assert.equal(fireCount, 0, "a manually-renamed session must never be auto-titled"); +}); diff --git a/test/session-auto-title-persist-lr-62157d.test.js b/test/session-auto-title-persist-lr-62157d.test.js new file mode 100644 index 00000000..9e56da84 --- /dev/null +++ b/test/session-auto-title-persist-lr-62157d.test.js @@ -0,0 +1,180 @@ +"use strict"; +/** + * Regression test for lr-62157d (MILLER fnd-d21956/fnd-3291e4, keystone + * cause): titleAutoGenerated, titleManuallySet and turnCount were never + * written by buildMetaLine() and never hydrated by loadSessions() — every + * daemon restart silently reset all three to false/0, so the "already + * auto-titled" / "user renamed it" guards in sdk-message-processor.js and + * sdk-bridge.js were guarding against state that could never survive a + * restart. Across ~160 real session files on this machine, zero meta lines + * ever contained titleAutoGenerated or titleManuallySet — this is the live + * evidence that motivated the fix. + * + * Drives real production code (buildMetaLine via saveSessionFile, and + * loadSessions via a fresh createSessionManager pointed at the same + * CLAGENTIC_HOME) — no reimplementation. Modeled on the existing + * test/session-meta-rewrite-lr-79c6.test.js round-trip pattern. + */ + +var test = require("node:test"); +var assert = require("node:assert/strict"); +var fs = require("fs"); +var path = require("path"); +var os = require("os"); + +function makeTempHome() { + return fs.mkdtempSync(path.join(os.tmpdir(), "clagentic-test-lr62157d-")); +} + +function makeSessionManager(tmpHome) { + ["../lib/config", "../lib/sessions", "../lib/utils"].forEach(function (m) { + try { delete require.cache[require.resolve(m)]; } catch (_) {} + }); + var origHome = process.env.CLAGENTIC_HOME; + process.env.CLAGENTIC_HOME = tmpHome; + var sessions; + try { + sessions = require("../lib/sessions"); + } finally { + if (origHome === undefined) delete process.env.CLAGENTIC_HOME; + else process.env.CLAGENTIC_HOME = origHome; + } + return sessions.createSessionManager({ + cwd: tmpHome, + send: function () {}, + sendTo: function () {}, + sendEach: function () {}, + }); +} + +test("lr-62157d: titleAutoGenerated, titleManuallySet and turnCount survive a daemon restart (buildMetaLine -> loadSessions round trip)", function () { + var tmpHome = makeTempHome(); + try { + var sm1 = makeSessionManager(tmpHome); + var sess = sm1.createSessionRaw({}); + sess.cliSessionId = "sess-62157d-flags"; + sess.title = "A real LLM-generated title"; + sess.titleAutoGenerated = true; + sess.titleManuallySet = false; + sess.titleProvisional = false; + sess.turnCount = 7; + sm1.saveSessionFile(sess); + + // Simulate a full daemon restart: brand-new SessionManager, same on-disk + // home, so this exercises loadSessions()'s hydration path exactly as a + // real restart would. + var sm2 = makeSessionManager(tmpHome); + var reloaded = null; + sm2.sessions.forEach(function (s) { + if (s.cliSessionId === "sess-62157d-flags") reloaded = s; + }); + + assert.ok(reloaded, "reloaded session must be found after restart"); + assert.equal(reloaded.titleAutoGenerated, true, "titleAutoGenerated must survive a restart"); + assert.equal(reloaded.titleManuallySet, false, "titleManuallySet must survive a restart (as false here)"); + assert.equal(reloaded.turnCount, 7, "turnCount must survive a restart"); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } +}); + +test("lr-62157d: titleManuallySet=true survives a daemon restart (durable suppression of auto-title)", function () { + var tmpHome = makeTempHome(); + try { + var sm1 = makeSessionManager(tmpHome); + var sess = sm1.createSessionRaw({}); + sess.cliSessionId = "sess-62157d-manual"; + sess.title = "User Renamed This"; + sess.titleManuallySet = true; + sess.titleAutoGenerated = false; + sm1.saveSessionFile(sess); + + var sm2 = makeSessionManager(tmpHome); + var reloaded = null; + sm2.sessions.forEach(function (s) { + if (s.cliSessionId === "sess-62157d-manual") reloaded = s; + }); + + assert.ok(reloaded); + assert.equal(reloaded.titleManuallySet, true, "a user-set title's flag must remain true after restart"); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } +}); + +test("lr-62157d: titleProvisional survives a daemon restart", function () { + var tmpHome = makeTempHome(); + try { + var sm1 = makeSessionManager(tmpHome); + var sess = sm1.createSessionRaw({}); + sess.cliSessionId = "sess-62157d-provisional"; + sess.title = "raw first message trunc"; + sess.titleProvisional = true; + sm1.saveSessionFile(sess); + + var sm2 = makeSessionManager(tmpHome); + var reloaded = null; + sm2.sessions.forEach(function (s) { + if (s.cliSessionId === "sess-62157d-provisional") reloaded = s; + }); + + assert.ok(reloaded); + assert.equal(reloaded.titleProvisional, true, "titleProvisional must survive a restart"); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } +}); + +test("lr-62157d: a session that never set the flags reloads with false/0, not undefined (no crash on later boolean/arithmetic use)", function () { + var tmpHome = makeTempHome(); + try { + var sm1 = makeSessionManager(tmpHome); + var sess = sm1.createSessionRaw({}); + sess.cliSessionId = "sess-62157d-defaults"; + sess.title = ""; + sm1.saveSessionFile(sess); + + var sm2 = makeSessionManager(tmpHome); + var reloaded = null; + sm2.sessions.forEach(function (s) { + if (s.cliSessionId === "sess-62157d-defaults") reloaded = s; + }); + + assert.ok(reloaded); + assert.equal(reloaded.titleAutoGenerated, false); + assert.equal(reloaded.titleManuallySet, false); + assert.equal(reloaded.titleProvisional, false); + assert.equal(reloaded.turnCount, 0); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } +}); + +test("lr-62157d: buildMetaLine (via saveSessionFile) writes titleAutoGenerated/titleManuallySet/turnCount to the on-disk meta line when true/nonzero", function () { + var tmpHome = makeTempHome(); + try { + var sm = makeSessionManager(tmpHome); + var sess = sm.createSessionRaw({}); + sess.cliSessionId = "sess-62157d-ondisk"; + sess.title = "Some Title"; + sess.titleAutoGenerated = true; + sess.turnCount = 3; + sm.saveSessionFile(sess); + + var sessionsBase = path.join(tmpHome, "console", "sessions"); + var sessionFile = null; + fs.readdirSync(sessionsBase).forEach(function (dir) { + var candidate = path.join(sessionsBase, dir, "sess-62157d-ondisk.jsonl"); + if (fs.existsSync(candidate)) sessionFile = candidate; + }); + assert.ok(sessionFile, "session file must exist on disk"); + + var firstLine = fs.readFileSync(sessionFile, "utf8").split("\n")[0]; + var meta = JSON.parse(firstLine); + assert.equal(meta.titleAutoGenerated, true, "meta line must carry titleAutoGenerated:true"); + assert.equal(meta.turnCount, 3, "meta line must carry turnCount"); + assert.equal(meta.titleManuallySet, undefined, "false-valued flags stay absent, matching the bookmarked/pendingAutoResume write-when-true convention"); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } +}); From a27a6ca8588905b2c1c7c1ed3b409de0397185f4 Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" Date: Mon, 24 Aug 2026 21:33:22 -0400 Subject: [PATCH 6/7] fix(project): guard deriveProvisionalTitle against non-string pastes[0] (lr-62157d) PEACHES fnd-439007 (PR #406 review, head_sha f59ef92): msg is raw client-controlled WS JSON with no server-side schema validation (project-connection.js's bare JSON.parse). The bundled browser client only ever sends strings in pastes[] (input.js pushes p.text, always a string, only for a paste >=500 chars), but nothing enforces that for an arbitrary WS client. pastes[0] was never previously consulted for title derivation before this task -- a non-string entry there (e.g. an image-paste-shaped object) is a genuinely new reachable path, not a hypothetical, and would throw a TypeError out of .replace(). Fix: typeof-guard falls through to the existing 'Image' literal for a non-string source -- exactly the case that literal was originally written for. Picking pastes[0] (not scanning for the first string entry) is still correct: the array is homogeneous by client contract even though not server-enforced, so 'first entry' and 'first string entry' coincide for every real client; a defensive type guard on that one entry is the right-sized fix, not a rewrite of the selection logic for a shape no real caller produces. Test added and negative-controlled: reverted this guard alone (keeping the new test) and reran -- the new test failed with the predicted 'source.replace is not a function' TypeError (plus the same unrelated pre-existing daemon-bootstrap-guard.test.js flake seen in the original PR's negative control, confirmed flaky by an immediate clean rerun with no code changes). With the guard restored: 1495/1495 (excluding the same flake) / 1496/1496 on a clean rerun. --- lib/project-user-message.js | 12 +++++++++++- ...user-message-paste-title-lr-62157d.test.js | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/lib/project-user-message.js b/lib/project-user-message.js index e1cc266f..fdaa644c 100644 --- a/lib/project-user-message.js +++ b/lib/project-user-message.js @@ -43,7 +43,17 @@ function deriveProvisionalTitle(msg) { if (!source && msg && msg.pastes && msg.pastes.length > 0) { source = msg.pastes[0]; } - var collapsed = source ? source.replace(/\s+/g, " ").trim().substring(0, 50) : ""; + // PEACHES fnd-439007: msg is raw client-controlled WS JSON with no + // server-side schema validation (see project-connection.js's bare + // JSON.parse) — the bundled browser client only ever sends strings in + // pastes[] (input.js's paste handler pushes p.text, always a string), but + // nothing enforces that for an arbitrary WS client. A non-string entry + // (e.g. an object) must not reach .replace(), which only exists on + // strings — fall through to the "Image" literal instead, exactly the case + // it was originally written for. + var collapsed = typeof source === "string" + ? source.replace(/\s+/g, " ").trim().substring(0, 50) + : ""; return collapsed || "Image"; } diff --git a/test/project-user-message-paste-title-lr-62157d.test.js b/test/project-user-message-paste-title-lr-62157d.test.js index cc4cc9b8..936e97cf 100644 --- a/test/project-user-message-paste-title-lr-62157d.test.js +++ b/test/project-user-message-paste-title-lr-62157d.test.js @@ -72,3 +72,22 @@ test("lr-62157d: an empty-string paste array entry does not crash and still fall assert.equal(typeof title, "string"); }); }); + +// PEACHES fnd-439007 (PR #406 review, head_sha f59ef92e): msg is raw +// client-controlled WS JSON with no server-side schema validation +// (project-connection.js's bare JSON.parse) — the bundled browser client +// only ever sends strings in pastes[] (input.js pushes p.text, always a +// string), but nothing enforces that for an arbitrary WS client. pastes[0] +// was never previously consulted for title derivation before this task, so +// a non-string entry there is a genuinely new reachable path, not a +// hypothetical — the irony being that the operator's ORIGINAL report was a +// paste getting mis-titled "Image"; a naive fix must not trade that bug for +// a crash on a malformed/adversarial payload. +test("lr-62157d / fnd-439007: a non-string pastes[0] (e.g. an image-paste-shaped object) does not throw and falls back to 'Image'", function () { + var msg = { type: "message", text: "", pastes: [{ type: "image", mediaType: "image/png", data: "..." }] }; + + assert.doesNotThrow(function () { + var title = deriveProvisionalTitle(msg); + assert.equal(title, "Image", "a non-string paste entry must fall through to the 'Image' literal, not throw"); + }, "a non-string pastes[0] must not throw a TypeError out of .replace()"); +}); From 72fdf3b974dabfefbc0c4e5caea3cd339b82a456 Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" Date: Mon, 24 Aug 2026 21:41:08 -0400 Subject: [PATCH 7/7] fix(project): truncate provisional title on code-point boundaries (lr-62157d) BOBBIE (PR #406 review, comment 5403852401, non-blocking code-craft polish): plain substring(0, 50) indexes by UTF-16 code unit, so a cut landing inside a surrogate pair (most emoji, many CJK supplementary-plane characters) produces a lone unpaired surrogate -- renders as a broken/replacement glyph in the sidebar. Newly reachable in a way it wasn't before this task: pasted content is now a title source, and pasted text is far more likely to contain emoji/CJK than a typed first line. No existing safe-truncation helper in the codebase (checked -- every other truncation call site uses plain substring; out of scope to fix those here, this is a minimal fix for the newly-reachable path only). Array.from(str) iterates by Unicode code point (Node >=20 floor supports this natively, no dependency needed), so slicing the resulting array and rejoining never splits a surrogate pair -- extracted as truncateCodePointSafe(), used only by deriveProvisionalTitle. Test added and negative-controlled: reverted this fix alone (keeping the new test) and reran -- failed with the predicted lone unpaired high surrogate (U+D83D) in the title. With the fix restored: full suite passes (excluding the same unrelated pre-existing daemon-bootstrap-guard.test.js timing flake noted in the prior commit, confirmed flaky again by an immediate clean rerun). --- lib/project-user-message.js | 21 ++++++++- ...user-message-paste-title-lr-62157d.test.js | 46 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/lib/project-user-message.js b/lib/project-user-message.js index fdaa644c..4f3fe2d3 100644 --- a/lib/project-user-message.js +++ b/lib/project-user-message.js @@ -25,6 +25,25 @@ var fs = require("fs"); * digestDmTurn, * adapter - YOKE adapter instance */ +// lr-62157d: code-point-aware truncation to a max length. Plain +// String#substring/slice indexes by UTF-16 code unit, so a cut that lands +// inside a surrogate pair (e.g. most emoji, many CJK supplementary-plane +// characters) produces a lone unpaired surrogate — renders as a broken/ +// replacement glyph. Array.from(str) iterates a string by Unicode code +// point (correctly reassembling surrogate pairs), so slicing the resulting +// array and rejoining never splits one. BOBBIE fnd (PR #406 review): +// pasted content is now a title source (see deriveProvisionalTitle below), +// and pasted text is far more likely to contain emoji/CJK than a typed +// first line, making this newly reachable in a way it wasn't before. No +// existing helper for this in the codebase (every other truncation call +// site uses plain substring — out of scope here, this is a minimal fix +// for the newly-reachable path only, not a codebase-wide truncation sweep). +function truncateCodePointSafe(str, maxLen) { + var codePoints = Array.from(str); + if (codePoints.length <= maxLen) return str; + return codePoints.slice(0, maxLen).join(""); +} + // lr-62157d: pure helper — derives the FIRST-message provisional title // (before any turn has completed / before autoGenerateTitle can run). // Prefers real message text; falls back to the first paste when text is @@ -52,7 +71,7 @@ function deriveProvisionalTitle(msg) { // strings — fall through to the "Image" literal instead, exactly the case // it was originally written for. var collapsed = typeof source === "string" - ? source.replace(/\s+/g, " ").trim().substring(0, 50) + ? truncateCodePointSafe(source.replace(/\s+/g, " ").trim(), 50) : ""; return collapsed || "Image"; } diff --git a/test/project-user-message-paste-title-lr-62157d.test.js b/test/project-user-message-paste-title-lr-62157d.test.js index 936e97cf..61092c11 100644 --- a/test/project-user-message-paste-title-lr-62157d.test.js +++ b/test/project-user-message-paste-title-lr-62157d.test.js @@ -91,3 +91,49 @@ test("lr-62157d / fnd-439007: a non-string pastes[0] (e.g. an image-paste-shaped assert.equal(title, "Image", "a non-string paste entry must fall through to the 'Image' literal, not throw"); }, "a non-string pastes[0] must not throw a TypeError out of .replace()"); }); + +// BOBBIE (PR #406 review, comment 5403852401): plain substring(0, 50) +// indexes by UTF-16 code unit, so a cut landing inside a surrogate pair +// (most emoji, many CJK supplementary-plane characters) produces a lone +// unpaired surrogate -- renders as a broken/replacement glyph in the +// sidebar. Newly reachable in a way it wasn't before this task: pasted +// content is now a title source, and pasted text is far more likely to +// contain emoji/CJK than a typed first line. Cosmetic (not a crash/security +// issue), but a real, newly-introduced regression path worth pinning. +test("lr-62157d / BOBBIE: a paste whose 50th-character boundary falls inside a surrogate pair does not split it (no broken glyph)", function () { + // 49 plain ASCII chars, then an emoji (astral code point, 2 UTF-16 code + // units) starting exactly at code-unit index 49 -- substring(0, 50) would + // land squarely inside that surrogate pair. + var prefix = "a".repeat(49); + var emoji = "\u{1F600}"; // grinning face, U+1F600 -- surrogate pair in UTF-16 + var pasteText = prefix + emoji + " trailing content that pushes this paste well past the 500-char client diversion threshold, repeated for length. ".repeat(4); + assert.ok(pasteText.length >= 500, "sanity: paste must actually be >=500 chars, matching the client's diversion threshold"); + + var msg = { type: "message", text: "", pastes: [pasteText] }; + var title = deriveProvisionalTitle(msg); + + // A broken surrogate pair produces the Unicode replacement/"unknown" + // rendering; the concrete, checkable symptom is a lone unpaired surrogate + // code unit somewhere in the raw UTF-16 string. Array.from() on the title + // splits it back into code points (a length-1 array element for a proper + // astral code point, since Array.from correctly reassembles a surrogate + // pair into one iteration step) -- but each element is still a JS string + // of 1 OR 2 UTF-16 code units, so codePointAt(0) (not charCodeAt(0), which + // always reports only the first UTF-16 code unit even for a 2-unit + // element) is what correctly reports "is this a single combined astral + // code point" vs. "is this a lone surrogate masquerading as its own + // element" -- the latter only happens if Array.from itself received an + // already-broken (lone-surrogate) string, which is exactly the bug this + // test exists to catch. + var codePoints = Array.from(title); + codePoints.forEach(function (cp) { + var code = cp.codePointAt(0); + var isLoneSurrogate = code >= 0xD800 && code <= 0xDFFF; + assert.ok(!isLoneSurrogate, "title must not contain a lone (unpaired) surrogate code unit: " + JSON.stringify(title)); + }); + // The emoji itself, if present in the truncated title at all, must survive + // intact as a single code point (not split into two lone surrogates). + if (title.indexOf("\uD83D") !== -1 || title.indexOf("\uDE00") !== -1) { + assert.ok(title.indexOf(emoji) !== -1, "if either half of the emoji surrogate pair appears, the whole emoji must be present intact: " + JSON.stringify(title)); + } +});