Skip to content
Merged
59 changes: 57 additions & 2 deletions lib/project-user-message.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,57 @@ 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
// 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];
}
// 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"
? truncateCodePointSafe(source.replace(/\s+/g, " ").trim(), 50)
: "";
return collapsed || "Image";
}

function attachUserMessage(ctx) {
var cwd = ctx.cwd;
var slug = ctx.slug;
Expand Down Expand Up @@ -365,7 +416,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
Expand Down Expand Up @@ -672,5 +727,5 @@ function attachUserMessage(ctx) {
};
}

module.exports = { attachUserMessage: attachUserMessage };
module.exports = { attachUserMessage: attachUserMessage, _test_deriveProvisionalTitle: deriveProvisionalTitle };

24 changes: 22 additions & 2 deletions lib/sdk-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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,
};
}

Expand Down
10 changes: 8 additions & 2 deletions lib/sdk-message-processor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions lib/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
}
}
Expand Down
139 changes: 139 additions & 0 deletions test/project-user-message-paste-title-lr-62157d.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"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");
});
});

// 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()");
});

// 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));
}
});
Loading
Loading