fix(tabs): bound the transcript scan and requeue the newest title frame

Cross-referenced against community PR #12319 by @lafraia.

- Scan a 256 KB tail first and only widen to a full stream scan when the tail
  holds no rename record. Measured on real transcripts, Claude re-appends the
  record most turns (2.8 MB session, 37 records, newest 403 B from EOF), but a
  rename made once at the top of a long session still has to resolve.
- `findClaudeSessionRenameRecord` separates "no record in this range" from
  "the rename was cleared", so the widening pass cannot resurface a name the
  agent already removed.
- A title frame that arrives while a scan is open is now requeued instead of
  dropped. Claude stops auto-titling after a rename, so a dropped frame could
  leave the tab on the generated title with no later change to retry on.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-08-04 01:05:17 -07:00
co-authored by Orca
parent eb3a9265ee
commit 3ac40c2754
6 changed files with 123 additions and 18 deletions
@@ -1,4 +1,4 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
@@ -37,6 +37,19 @@ describe('readAgentSessionRenamedTitle', () => {
await expect(readAgentSessionRenamedTitle({ transcriptPath })).resolves.toBeNull()
})
it('widens past the tail for a rename that was never re-appended', async () => {
// Why: the scan reads a short tail first because Claude re-appends the
// rename most turns. A session renamed once at the top and never again must
// still resolve, so an empty tail has to widen instead of answering null.
const filler = { type: 'user', message: { role: 'user', content: 'x'.repeat(4096) } }
const transcriptPath = await writeTranscript([
{ type: 'custom-title', customTitle: 'billing-fix' },
...Array.from({ length: 80 }, () => filler)
])
expect((await stat(transcriptPath)).size).toBeGreaterThan(256 * 1024)
await expect(readAgentSessionRenamedTitle({ transcriptPath })).resolves.toBe('billing-fix')
})
it('resolves null instead of rejecting when the transcript is gone', async () => {
await expect(
readAgentSessionRenamedTitle({ transcriptPath: join(tmpdir(), 'orca-missing-session.jsonl') })
+25 -4
View File
@@ -3,10 +3,17 @@ import { stat } from 'node:fs/promises'
import { createInterface } from 'node:readline'
import {
CLAUDE_CUSTOM_TITLE_RECORD_MARKER,
readClaudeSessionRenamedTitle
findClaudeSessionRenameRecord,
readClaudeSessionRenamedTitle,
type ClaudeSessionRenameRecord
} from '../shared/agent-session-rename-title'
import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch'
// Why: Claude re-appends the rename record on most turns, so measured against
// real transcripts (2.8 MB / 37 records, newest 403 B from EOF) a short tail
// answers nearly every scan. A rename that is never re-appended still exists
// only once, near the top, so an empty tail widens to a full scan.
const TRANSCRIPT_TAIL_SCAN_BYTES = 256 * 1024
// Why: a rename record is one short line anywhere in the transcript, so the scan
// streams the file rather than buffering it. Very long sessions are tail-scanned
// from this offset; a rename that old is already superseded by the tab's
@@ -44,12 +51,26 @@ export async function readAgentSessionRenamedTitle(
async function readLocalRenamedTitle(transcriptPath: string): Promise<string | null> {
const { size } = await stat(transcriptPath)
const start = Math.max(0, size - MAX_TRANSCRIPT_SCAN_BYTES)
const tailStart = Math.max(0, size - TRANSCRIPT_TAIL_SCAN_BYTES)
const fromTail = await scanTranscriptFrom(transcriptPath, tailStart)
if (fromTail || tailStart === 0) {
return fromTail?.customTitle ?? null
}
const widened = await scanTranscriptFrom(
transcriptPath,
Math.max(0, size - MAX_TRANSCRIPT_SCAN_BYTES)
)
return widened?.customTitle ?? null
}
async function scanTranscriptFrom(
transcriptPath: string,
start: number
): Promise<ClaudeSessionRenameRecord | null> {
const stream = createReadStream(transcriptPath, { encoding: 'utf8', start })
try {
const lines = createInterface({ input: stream, crlfDelay: Infinity })
// A tail scan starts mid-line; JSON.parse rejects that fragment on its own.
return readClaudeSessionRenamedTitle(await collectLines(lines))
return findClaudeSessionRenameRecord(await collectLines(lines))
} finally {
stream.destroy()
}
@@ -3,8 +3,11 @@ import { isAgentRenamedTerminalTitle } from '../../../shared/agent-session-renam
// Why: a live OSC title change is the only immediate signal that a deliberate
// `/rename` may have happened — the agent fires no hook for it. One refresh per
// tab is in flight at a time so a burst of title frames cannot fan out into
// concurrent transcript scans.
// concurrent transcript scans; the newest frame of the burst is queued rather
// than dropped, or the tab would keep a rename verdict for a title it no longer
// shows until some later title change happens to arrive.
const inFlightTabIds = new Set<string>()
const queuedByTabId = new Map<string, AgentRenamedTabTitleRefreshArgs>()
export type AgentRenamedTabTitleRefreshArgs = {
tabId: string
@@ -21,7 +24,11 @@ export function scheduleAgentRenamedTabTitleRefresh(args: AgentRenamedTabTitleRe
// desktop preload bridge may be absent.
const getRenamedTitle =
typeof window === 'undefined' ? undefined : window.api?.agentSession?.getRenamedTitle
if (!getRenamedTitle || args.transcriptPaths.length === 0 || inFlightTabIds.has(args.tabId)) {
if (!getRenamedTitle || args.transcriptPaths.length === 0) {
return
}
if (inFlightTabIds.has(args.tabId)) {
queuedByTabId.set(args.tabId, args)
return
}
inFlightTabIds.add(args.tabId)
@@ -42,6 +49,11 @@ export function scheduleAgentRenamedTabTitleRefresh(args: AgentRenamedTabTitleRe
// Best-effort: an unreadable transcript leaves the generated title in place.
} finally {
inFlightTabIds.delete(args.tabId)
const queued = queuedByTabId.get(args.tabId)
if (queued) {
queuedByTabId.delete(args.tabId)
scheduleAgentRenamedTabTitleRefresh(queued)
}
}
})()
}
@@ -106,6 +106,30 @@ describe('deliberate in-agent rename vs generated tab title', () => {
)
})
it('rescans for the newest title of a burst instead of dropping it', async () => {
const store = createTestStore()
const tabId = seedAgentTab(store)
let releaseFirstScan = (): void => {}
const firstScan = new Promise<string | null>((resolve) => {
releaseFirstScan = () => resolve(null)
})
getRenamedTitle.mockReturnValueOnce(firstScan).mockResolvedValue('billing-fix')
// Why: the rename can land while the previous frame's scan is still open.
// Dropping it would leave the tab on the generated title with no later title
// change to trigger a retry — Claude stops auto-titling once renamed.
store.getState().updateTabTitle(tabId, '✳ Answer simple arithmetic question')
store.getState().updateTabTitle(tabId, '✳ billing-fix')
releaseFirstScan()
await vi.waitFor(() =>
expect(store.getState().tabsByWorktree[WORKTREE_ID][0].agentRenamedTitle).toBe('billing-fix')
)
expect(resolveTerminalTabTitle(store.getState().tabsByWorktree[WORKTREE_ID][0], true)).toBe(
'✳ billing-fix'
)
})
it('does not scan transcripts while generated titles are off', async () => {
const store = createTestStore()
const tabId = seedAgentTab(store)
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
findClaudeSessionRenameRecord,
isAgentRenamedTerminalTitle,
readClaudeSessionRenamedTitle
} from './agent-session-rename-title'
@@ -54,6 +55,23 @@ describe('readClaudeSessionRenamedTitle', () => {
})
})
describe('findClaudeSessionRenameRecord', () => {
it('tells a cleared rename apart from a range that holds no record', () => {
const cleared = JSON.stringify({ type: 'custom-title', customTitle: '' })
expect(findClaudeSessionRenameRecord([CUSTOM_TITLE_LINE, cleared])).toEqual({
customTitle: null
})
expect(findClaudeSessionRenameRecord([AI_TITLE_LINE])).toBeNull()
})
it('ignores neighbouring record types that also name the session', () => {
const agentName = JSON.stringify({ type: 'agent-name', agentName: 'billing-fix' })
const malformedTitle = JSON.stringify({ type: 'custom-title', customTitle: 42 })
expect(findClaudeSessionRenameRecord([agentName])).toBeNull()
expect(findClaudeSessionRenameRecord([malformedTitle])).toEqual({ customTitle: null })
})
})
describe('isAgentRenamedTerminalTitle', () => {
it('matches the live OSC title through the agent status glyph', () => {
expect(isAgentRenamedTerminalTitle('✳ billing-fix', 'billing-fix')).toBe(true)
+28 -11
View File
@@ -9,35 +9,52 @@ import { stripLeadingAgentTitleDecorationOrEmpty } from './agent-title-decoratio
/** Cheap prefilter for transcript readers before they buffer or parse a line. */
export const CLAUDE_CUSTOM_TITLE_RECORD_MARKER = '"custom-title"'
/** The newest rename record in a range of transcript lines. `customTitle` is
* null when the agent cleared the name — still a record, so callers must not
* keep searching earlier ranges for an older one. */
export type ClaudeSessionRenameRecord = { customTitle: string | null }
/**
* The session's current deliberate rename from Claude transcript lines, or null
* when none survives. Later records win; an empty `customTitle` is Claude
* clearing the name, which must fall back to Orca's generated title.
* The newest `custom-title` record among transcript lines, or null when the
* range holds none. Separating "no record here" from "the name was cleared"
* lets a reader scan a cheap tail first and only widen when nothing was found.
*/
export function readClaudeSessionRenamedTitle(lines: Iterable<string>): string | null {
let renamedTitle: string | null = null
export function findClaudeSessionRenameRecord(
lines: Iterable<string>
): ClaudeSessionRenameRecord | null {
let record: ClaudeSessionRenameRecord | null = null
for (const line of lines) {
// Why: transcripts are mostly large message records; skip the JSON parse
// unless the line can carry the marker.
if (!line.includes(CLAUDE_CUSTOM_TITLE_RECORD_MARKER)) {
continue
}
let record: unknown
let parsed: unknown
try {
record = JSON.parse(line)
// A tail scan starts mid-line; JSON.parse rejects that fragment on its own.
parsed = JSON.parse(line)
} catch {
continue
}
if (!record || typeof record !== 'object') {
if (!parsed || typeof parsed !== 'object') {
continue
}
const { type, customTitle } = record as { type?: unknown; customTitle?: unknown }
const { type, customTitle } = parsed as { type?: unknown; customTitle?: unknown }
if (type !== 'custom-title') {
continue
}
renamedTitle = typeof customTitle === 'string' ? customTitle.trim() || null : null
record = { customTitle: typeof customTitle === 'string' ? customTitle.trim() || null : null }
}
return renamedTitle
return record
}
/**
* The session's current deliberate rename from Claude transcript lines, or null
* when none survives. Later records win; an empty `customTitle` is Claude
* clearing the name, which must fall back to Orca's generated title.
*/
export function readClaudeSessionRenamedTitle(lines: Iterable<string>): string | null {
return findClaudeSessionRenameRecord(lines)?.customTitle ?? null
}
/**