mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
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:
@@ -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') })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user