Merge remote-tracking branch 'origin/main' into feature/markdown-table-column-controls

This commit is contained in:
jifeng.zjd 2026-07-07 19:33:06 +08:00
commit 91a414e702
3 changed files with 73 additions and 33 deletions

View file

@ -364,16 +364,14 @@ describe('ChatRecordingService - auto-title trigger', () => {
expect(svc.getCurrentCustomTitle()).toBe('Auto-generated title');
expect(svc.getCurrentTitleSource()).toBe('auto');
// finalize() was called by the constructor — drain the queued async
// write before inspecting the mock.
await svc.flush();
expect(findCustomTitleRecord()).toBeUndefined();
svc.recordUserMessage([{ text: 'resume work' }]);
await svc.flush();
// The re-appended record must carry titleSource: 'auto', not 'manual'.
const finalizeRecord = vi
.mocked(jsonl.writeLine)
.mock.calls.map((c) => c[1] as ChatRecord)
.find((r) => r.type === 'system' && r.subtype === 'custom_title');
expect(finalizeRecord?.systemPayload).toEqual({
const reanchoredRecord = findCustomTitleRecord();
expect(reanchoredRecord?.systemPayload).toEqual({
customTitle: 'Auto-generated title',
titleSource: 'auto',
});
@ -404,12 +402,13 @@ describe('ChatRecordingService - auto-title trigger', () => {
expect(svc.getCurrentCustomTitle()).toBe('User chose this');
expect(svc.getCurrentTitleSource()).toBe('manual');
await svc.flush();
expect(findCustomTitleRecord()).toBeUndefined();
const finalizeRecord = vi
.mocked(jsonl.writeLine)
.mock.calls.map((c) => c[1] as ChatRecord)
.find((r) => r.type === 'system' && r.subtype === 'custom_title');
expect(finalizeRecord?.systemPayload).toEqual({
svc.recordUserMessage([{ text: 'resume work' }]);
await svc.flush();
const reanchoredRecord = findCustomTitleRecord();
expect(reanchoredRecord?.systemPayload).toEqual({
customTitle: 'User chose this',
titleSource: 'manual',
});
@ -438,13 +437,14 @@ describe('ChatRecordingService - auto-title trigger', () => {
// `titleSource: 'manual'` we can't actually verify.
expect(svc.getCurrentTitleSource()).toBeUndefined();
await svc.flush();
expect(findCustomTitleRecord()).toBeUndefined();
const finalizeRecord = vi
.mocked(jsonl.writeLine)
.mock.calls.map((c) => c[1] as ChatRecord)
.find((r) => r.type === 'system' && r.subtype === 'custom_title');
svc.recordUserMessage([{ text: 'resume work' }]);
await svc.flush();
const reanchoredRecord = findCustomTitleRecord();
// Payload must NOT contain a titleSource field when source is unknown.
expect(finalizeRecord?.systemPayload).toEqual({
expect(reanchoredRecord?.systemPayload).toEqual({
customTitle: 'Legacy title',
});
});

View file

@ -135,8 +135,9 @@ describe('ChatRecordingService - recordCustomTitle', () => {
});
describe('finalize', () => {
it('should re-append cached custom title to EOF', async () => {
it('should re-append cached custom title to EOF after new content', async () => {
chatRecordingService.recordCustomTitle('my-feature');
chatRecordingService.recordUserMessage([{ text: 'new work' }]);
await chatRecordingService.flush();
vi.mocked(jsonl.writeLine).mockClear();
@ -153,6 +154,17 @@ describe('ChatRecordingService - recordCustomTitle', () => {
});
});
it('should not write anything when the title is already the latest record', async () => {
chatRecordingService.recordCustomTitle('my-feature');
await chatRecordingService.flush();
vi.mocked(jsonl.writeLine).mockClear();
chatRecordingService.finalize();
await chatRecordingService.flush();
expect(jsonl.writeLine).not.toHaveBeenCalled();
});
it('should not write anything when no custom title was set', async () => {
chatRecordingService.finalize();
await chatRecordingService.flush();
@ -160,9 +172,33 @@ describe('ChatRecordingService - recordCustomTitle', () => {
expect(jsonl.writeLine).not.toHaveBeenCalled();
});
it('should not re-append a resumed title without new content', async () => {
vi.mocked(mockConfig.getResumedSessionData).mockReturnValue({
lastCompletedUuid: null,
} as unknown as ReturnType<Config['getResumedSessionData']>);
const getSessionTitleInfo = vi.fn().mockReturnValue({
title: 'resumed-title',
source: 'manual',
});
(
mockConfig as unknown as {
getSessionService: () => {
getSessionTitleInfo: typeof getSessionTitleInfo;
};
}
).getSessionService = () => ({ getSessionTitleInfo });
const svc = new ChatRecordingService(mockConfig);
svc.finalize();
await svc.flush();
expect(jsonl.writeLine).not.toHaveBeenCalled();
});
it('should re-append the latest title after multiple renames', async () => {
chatRecordingService.recordCustomTitle('first-name');
chatRecordingService.recordCustomTitle('second-name');
chatRecordingService.recordUserMessage([{ text: 'new work' }]);
await chatRecordingService.flush();
vi.mocked(jsonl.writeLine).mockClear();
@ -288,10 +324,8 @@ describe('ChatRecordingService - recordCustomTitle', () => {
).getSessionService = () => ({ getSessionTitleInfo });
const svc = new ChatRecordingService(mockConfig);
// Constructor's finalize re-appends a custom_title record on resume
// — clear it out so we can isolate the threshold-triggered re-anchor.
await svc.flush();
vi.mocked(jsonl.writeLine).mockClear();
expect(jsonl.writeLine).not.toHaveBeenCalled();
const bulkText = 'x'.repeat(2000);
for (let i = 0; i < 20; i++) {

View file

@ -564,6 +564,7 @@ export class ChatRecordingService {
* returning undefined if the title is beyond both windows).
*/
private bytesSinceTitleAnchor = 0;
private hasNonTitleContentSinceTitleAnchor = false;
constructor(config: Config) {
this.config = config;
@ -577,16 +578,19 @@ export class ChatRecordingService {
// resumed. Legacy records (no `titleSource` field) stay `undefined` —
// treated as manual for safety without rewriting the JSONL.
//
// We then re-append a custom_title record to EOF so the title stays
// within the tail window that readers scan (guarding against a crash
// before the next finalize).
// Do not re-append during construction: loading/resuming a session is a
// read operation from the user's perspective, and touching the JSONL mtime
// would make session lists treat it as fresh activity.
if (config.getResumedSessionData()) {
try {
const sessionService = config.getSessionService();
const info = sessionService.getSessionTitleInfo(config.getSessionId());
this.currentCustomTitle = info.title;
this.currentTitleSource = info.source;
this.finalize();
if (info.title) {
// Prime the threshold so the first real content write re-anchors.
this.bytesSinceTitleAnchor = TITLE_REANCHOR_BYTES;
}
} catch {
// Best-effort — don't block construction
}
@ -793,9 +797,11 @@ export class ChatRecordingService {
private updateTitleAnchorTracking(record: ChatRecord): void {
if (record.type === 'system' && record.subtype === 'custom_title') {
this.bytesSinceTitleAnchor = 0;
this.hasNonTitleContentSinceTitleAnchor = false;
return;
}
if (!this.currentCustomTitle) return;
this.hasNonTitleContentSinceTitleAnchor = true;
// +1 for the trailing newline jsonl.writeLine appends.
this.bytesSinceTitleAnchor +=
Buffer.byteLength(JSON.stringify(record), 'utf8') + 1;
@ -1319,13 +1325,10 @@ export class ChatRecordingService {
}
/**
* Finalizes the current session by re-appending cached metadata to EOF.
*
* Call this whenever leaving the current session whether switching to
* another session, shutting down the process, or any other transition.
* This single entry point replaces scattered re-append calls and ensures
* the custom_title record stays within the last 64KB tail window that
* readSessionTitleFromFile() scans.
* Finalizes the current session by re-appending cached metadata to EOF, but
* only after this recorder has appended non-title content since the last
* title anchor. Pure load/resume must remain read-only so session lists do
* not treat restored sessions as newly active.
*
* Best-effort: errors are logged but never thrown.
*/
@ -1344,6 +1347,9 @@ export class ChatRecordingService {
if (!this.currentCustomTitle) {
return;
}
if (!this.hasNonTitleContentSinceTitleAnchor) {
return;
}
try {
const record: ChatRecord = {
...this.createBaseRecord('system'),