Keep the Main turn state when a reminder or skill notice arrives

The live chat handler treats any frame without a task_id, other than a
terminal_incident, as the end of the unscoped Main turn and clears its
direct activities and pending submissions, so "Thinking..." or
"Sending..." vanished until the next /api/state snapshot (up to 20 s).
Reminder and /notify rows are exactly such frames (role system, empty
task_id) and arrive in the middle of a conversation.

Decide by the frame's typed kind: chat_activity.unkeyedFrameEndsTurn says
an unkeyed terminal_incident, reminder or skill_notice row concludes
nothing; every other unkeyed final still clears the turn. No text
matching. chat.js only swaps its inline check for the predicate, so the
module stays under its byte cap (199,325 of 200,000).

Tests: through the real header reducer, a reminder, skill notice or
incident frame keeps "Thinking..." while an ordinary unkeyed reply or
another System row still ends it, and a reply quoting a reminder's words
ends it too; the source pin now names the predicate in the cleanup branch.
This commit is contained in:
Ouroboros 2026-10-03 21:35:09 +03:00
parent a35a7790c7
commit 1f25016db5
3 changed files with 29 additions and 4 deletions

View file

@ -126,6 +126,7 @@ import {
partitionLocalEchoJournal,
projectCollapsedActivity,
positiveTaskTerminalFact,
unkeyedFrameEndsTurn,
projectIdFromTask,
rawTimestampEpoch,
stampNodeTimestamp,
@ -3714,8 +3715,8 @@ export function createChatInstance({
if (finished?.clientMessageId) {
pendingSubmissions.delete(finished.clientMessageId);
}
} else if (msg.system_type !== 'terminal_incident') {
// Unkeyed finals clear unscoped state; incidents are informational.
} else if (unkeyedFrameEndsTurn(msg)) {
// Unkeyed finals clear unscoped state; incidents, notes and skill notices do not.
activeDirectActivities.clear();
pendingSubmissions.clear();
}

View file

@ -1056,6 +1056,15 @@ export function positiveTaskTerminalFact(row) {
&& TERMINAL_SUBAGENT_EVENTS.has(String(row.subagent_event || '').toLowerCase());
}
/**
* Whether an unkeyed live row ends the unscoped Main turn, by its typed kind. An
* incident report, a note Ouroboros left for this moment and a skill's notice arrive
* in the middle of a conversation and conclude nothing.
*/
export function unkeyedFrameEndsTurn(row) {
return !['terminal_incident', 'reminder', 'skill_notice'].includes(String(row?.system_type || ''));
}
/**
* Single status reducer for the chat header (owner decisions 2A/5A; managed
* activities added by the project-continuity contract). Priority: disconnected

View file

@ -11,6 +11,7 @@ import {
createStateSnapshotSequencer,
isForegroundLiveCard,
routingAnnotationText,
unkeyedFrameEndsTurn,
} from '../modules/chat_activity.js';
const chatSource = readFileSync(new URL('../modules/chat.js', import.meta.url), 'utf8');
@ -26,7 +27,7 @@ test('review-only owner anchors do not advertise foreground task activity', () =
test('unkeyed terminal incidents do not clear unrelated live turns', () => {
// The cleanup guard also requires positive terminal evidence. Keep this
// source contract resilient to the guard's explicit conjunction while
// still checking the incident carve-out below it.
// still checking the carve-out below it (the typed kinds that end nothing).
const cleanupStart = chatSource.indexOf('if (!finalizing && (!explicitTaskId || typedTerminal))');
const finalCleanup = chatSource.slice(
cleanupStart,
@ -34,13 +35,27 @@ test('unkeyed terminal incidents do not clear unrelated live turns', () => {
);
assert.match(
finalCleanup,
/} else if \(msg\.system_type !== 'terminal_incident'\) \{[^}]*?activeDirectActivities\.clear\(\);[^}]*?pendingSubmissions\.clear\(\);\s*}\s*}/,
/} else if \(unkeyedFrameEndsTurn\(msg\)\) \{[^}]*?activeDirectActivities\.clear\(\);[^}]*?pendingSubmissions\.clear\(\);\s*}\s*}/,
);
// Ordinary unkeyed finals retain their existing global cleanup semantics.
assert.match(finalCleanup, /activeDirectActivities\.clear\(\);/);
assert.match(finalCleanup, /pendingSubmissions\.clear\(\);/);
});
test('a note or skill notice arriving mid-turn keeps Thinking/Sending; an ordinary unkeyed final still ends it', () => {
// What the live handler does with the unscoped turn for each frame (the branch pinned above).
const turn = { isConnected: true, hasActiveLiveCard: false, activeDirectCount: 1, pendingSubmissionsCount: 1 };
const after = (frame) => computeDerivedChatStatus(unkeyedFrameEndsTurn(frame)
? { ...turn, activeDirectCount: 0, pendingSubmissionsCount: 0 } : turn).text;
for (const kind of ['reminder', 'skill_notice', 'terminal_incident']) {
assert.equal(after({ role: 'system', system_type: kind, task_id: '', content: 'Call mother' }), 'Thinking...', kind);
}
assert.equal(after({ role: 'assistant', task_id: '', content: 'Done.' }), 'Online');
assert.equal(after({ role: 'system', system_type: 'startup_notice', task_id: '' }), 'Online');
// The kind decides, never the words: a reply that quotes a reminder still ends the turn.
assert.equal(after({ role: 'assistant', task_id: '', content: 'Reminder · Ouroboros · written Oct 3 14:05' }), 'Online');
});
test('routing receipts display the event-time label while keeping raw target metadata', () => {
const annotation = {
action: 'steer_task',