Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions extensions/background-terminals/src/watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,9 @@ export function createChunkMatcher(pattern: RegExp): ChunkMatcher {
};
}

/** Cap on the reported match line, so a newline-free stream cannot smear. */
/** Cap in code points on the reported match line, so a newline-free stream
* cannot smear. Counted per code point so a bound can never split a surrogate
* pair into a lone surrogate. */
export const WATCH_LINE_MAX_CHARS = 500;

/**
Expand All @@ -154,8 +156,11 @@ export const WATCH_LINE_MAX_CHARS = 500;
*/
function sanitizeLine(raw: string) {
const stripped = sanitizeTerminalText(raw).trim();
return stripped.length > WATCH_LINE_MAX_CHARS
? `${stripped.slice(0, WATCH_LINE_MAX_CHARS)}\u2026`
// Count and cut on code points so a surrogate pair is never split into a lone
// surrogate in the transcript.
const characters = [...stripped];
return characters.length > WATCH_LINE_MAX_CHARS
? `${characters.slice(0, WATCH_LINE_MAX_CHARS).join("")}\u2026`
: stripped;
}

Expand Down
7 changes: 5 additions & 2 deletions extensions/sessions/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,12 @@ const normalizeSnippet = (text: string, maxLength: number): string => {
const cleaned = cleanDisplayLine(text);
const fallback = cleaned.length > 0 ? cleaned : "No messages";
if (maxLength < 1) return "";
if (fallback.length <= maxLength) return fallback;
// Bound on code points so a truncation boundary cannot split a surrogate pair
// into a lone surrogate in the session list.
const characters = [...fallback];
if (characters.length <= maxLength) return fallback;
if (maxLength === 1) return "…";
return `${fallback.slice(0, maxLength - 1)}…`;
return `${characters.slice(0, maxLength - 1).join("")}…`;
};

export function buildSessionDescription(
Expand Down
7 changes: 5 additions & 2 deletions extensions/shared/web-observer-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,11 +166,14 @@ interface BoundedActivityText {
}

function boundedActivityText(value: string): BoundedActivityText {
if (value.length <= WEB_MAX_ACTIVITY_TEXT) {
// Cut on code points: a UTF-16 unit cut can split a surrogate pair and emit a
// lone surrogate, which the JSON capability snapshot cannot represent.
const characters = [...value];
if (characters.length <= WEB_MAX_ACTIVITY_TEXT) {
return { value, truncated: false };
}
return {
value: `${value.slice(0, WEB_MAX_ACTIVITY_TEXT - 1)}…`,
value: `${characters.slice(0, WEB_MAX_ACTIVITY_TEXT - 1).join("")}…`,
truncated: true,
};
}
Expand Down
4 changes: 3 additions & 1 deletion extensions/subagents/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ function cleanLine(value: string) {

/** Normalize every title before it enters snapshots, artifacts, or the TUI. */
export function normalizeSubagentTitle(value: string, fallback = "subagent") {
return cleanLine(value).slice(0, 160) || fallback;
// Bound on code points: `slice` measures UTF-16 units and can end on the high
// half of a surrogate pair, which then reaches snapshots and the TUI broken.
return [...cleanLine(value)].slice(0, 160).join("") || fallback;
}

/** Prefer the newest running child, then the newest unread settled child. */
Expand Down
13 changes: 13 additions & 0 deletions tests/extensions/background-terminals/watch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,16 @@ test("bounds the reported line when the stream has no newline", () => {
assert.ok(hit);
assert.ok(hit.line.length <= WATCH_LINE_MAX_CHARS + 1);
});

test("bounds the reported line on code points instead of splitting a surrogate pair", () => {
const emoji = "\u{1F680}";
const m = createChunkMatcher(compileWatchPattern("MATCH"));
const hit = m.push(`${"x".repeat(499)}${emoji}MATCH`, "stdout");
assert.ok(hit);

// The complete emoji survives the bound; a UTF-16 unit cut would have left a
// lone high surrogate in the transcript. The cap counts code points, so an
// astral character costs one position rather than two UTF-16 units.
assert.equal(hit.line, `${"x".repeat(499)}${emoji}\u2026`);
assert.ok([...hit.line].length <= WATCH_LINE_MAX_CHARS + 1);
});
15 changes: 15 additions & 0 deletions tests/extensions/sessions/sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,18 @@ test("session search matches formatted dates and years", () => {
assert.equal(filterSessionEntries(entries, "2026").length, 1);
assert.equal(filterSessionEntries(entries, "09-06").length, 1);
});

test("session snippets bound on code points instead of splitting a surrogate pair", () => {
const emoji = "\u{1F680}";
const bounded = buildSessionDescription(
{ ...session, firstMessage: `${"a".repeat(18)}${emoji}tail` },
20,
);

// The full surrogate pair survives the bound; a UTF-16 unit cut would have
// left a lone high surrogate followed by the ellipsis.
assert.equal(
bounded.endsWith(`${"a".repeat(18)}${emoji}… — /tmp/project`),
true,
);
});
10 changes: 10 additions & 0 deletions tests/extensions/subagents/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ test("subagent titles are sanitized and bounded at ingress", () => {
assert.equal(normalizeSubagentTitle("x".repeat(200)).length, 160);
});

test("subagent titles bound on code points instead of splitting a surrogate pair", () => {
const emoji = "\u{1F680}";
// The complete emoji survives the bound; a UTF-16 unit cut would have left a
// lone high surrogate in the snapshot and the strip.
assert.equal(
normalizeSubagentTitle(`${"x".repeat(159)}${emoji}tail`),
`${"x".repeat(159)}${emoji}`,
);
});

test("strip selection prefers newest running, then newest unread settled", () => {
const entries = [
snapshot("done", "done", 1, 5),
Expand Down
17 changes: 17 additions & 0 deletions tests/web/observer-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -614,3 +614,20 @@ test("detail lookup is Session-scoped, exact, and fail-closed", () => {
unregister();
}
});

test("projects bounded activity text on code points instead of splitting a surrogate pair", () => {
const emoji = "\u{1F680}";
const subagents = projectSubagentCapability([
{
id: "sa-emoji",
title: `${"x".repeat(158)}${emoji}tail`,
status: "running",
createdAt: 1,
},
]);

// The complete emoji survives the bound; a UTF-16 unit cut would have left a
// lone high surrogate in the bounded capability snapshot.
assert.equal(subagents.items[0]?.title, `${"x".repeat(158)}${emoji}…`);
assert.equal(subagents.truncated, true);
});
Loading