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
74 changes: 74 additions & 0 deletions src/screens/REPL.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1678,6 +1678,31 @@ export function REPL({
[setIsPromptInputActive, repinScroll, trySuggestBgPRIntercept],
);

// Pre-filled "按计划继续 Task #N …" instruction for a stalled long task, or
// null. Set when a turn ends with in_progress tasks (see the unfinished-task
// notice below), refreshed every turn so it can't go stale; consumed by Tab.
const [pendingContinuation, setPendingContinuation] = useState<string | null>(null);

// Tab on an empty prompt pre-fills the continuation instruction for the
// highest-priority unfinished task, letting the user re-anchor a stalled long
// task with one keystroke instead of blindly typing "继续". It only pre-fills —
// the user still presses Enter to send, so nothing is injected behind their
// back (unlike the removed silent <system-reminder> injection). The narrow
// isActive (empty input + a pending continuation, on the prompt screen) means
// normal Tab completion is never shadowed.
useInput(
(_input, key, event) => {
if (key.tab && !key.shift) {
setInputValue(pendingContinuation ?? '');
setPendingContinuation(null);
event.stopImmediatePropagation();
}
},
{
isActive: screen === 'prompt' && pendingContinuation !== null && inputValue === '',
},
);

// Schedule a timeout to stop suppressing dialogs after the user stops typing.
// Only manages the timeout — the immediate activation is handled by setInputValue above.
useEffect(() => {
Expand Down Expand Up @@ -3522,6 +3547,55 @@ export function REPL({
onQueryEvent(event);
}

// ── Unfinished-task notice ──
// When a turn completes naturally (not interrupted) but a task the model
// itself marked in_progress is still unfinished, surface a display-only
// local notice so the user doesn't have to blindly nudge "继续". This is a
// 'system' message: never sent to the model (0 token cost). Best-effort —
// a task-read failure must not affect turn teardown.
if (!abortController.signal.aborted) {
try {
const [{ listTasks, getTaskListId }, { buildUnfinishedTaskNotice, buildContinuationPrompt }] =
await Promise.all([import('../utils/tasks.js'), import('../services/api/taskAnchorReminder.js')]);
const tasks = await listTasks(getTaskListId());
const notice = buildUnfinishedTaskNotice(tasks);
if (notice) {
setMessages(prev => [...prev, createSystemMessage(notice, 'warning')]);
setPendingContinuation(buildContinuationPrompt(tasks));
} else {
setPendingContinuation(null);
}
} catch {
// Ignore — the notice is a convenience, not part of the turn contract.
}
}

// ── Context-bleed / degradation guard ──
// If the model regurgitated an internal <system-reminder> tag as its own
// output, that's an early signal of long-context inference-side degradation
// (2026-07-27: the model then hallucinated "file corrupted / tool not
// executed" while every tool had actually run). Surface a display-only local
// warning suggesting /rewind. 'system' message: never sent to the model
// (0 token cost). Read messagesRef.current — the closure's `messages` may be
// stale. Best-effort — a detection failure must not affect turn teardown.
if (!abortController.signal.aborted) {
try {
const [{ getLastAssistantMessage, getAssistantMessageText }, { detectContextBleed }] = await Promise.all([
import('../utils/messages.js'),
import('../services/api/degradationGuard.js'),
]);
const lastAssistant = getLastAssistantMessage(messagesRef.current);
const bleedNotice = lastAssistant
? detectContextBleed(getAssistantMessageText(lastAssistant), messagesRef.current.length)
: null;
if (bleedNotice) {
setMessages(prev => [...prev, createSystemMessage(bleedNotice, 'warning')]);
}
} catch {
// Ignore — the guard is a convenience, not part of the turn contract.
}
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (feature('BUDDY') && typeof (globalThis as Record<string, unknown>).fireCompanionObserver === 'function') {
const _fireCompanionObserver = (globalThis as Record<string, unknown>).fireCompanionObserver as (
msgs: unknown,
Expand Down
124 changes: 124 additions & 0 deletions src/services/api/__tests__/degradationGuard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, expect, test } from 'bun:test'
import { detectContextBleed } from '../degradationGuard'

describe('detectContextBleed', () => {
test('fires on a closed reminder block whose body matches a harness fingerprint', () => {
// Real sample from session 816c6bda (2026-07-27): a whole assistant text
// block that is just a regurgitated internal reminder.
const text =
'<system-reminder>\n' +
'Warning: this tool result is 5 hours old. ' +
'The user may have continued working since sending it.\n' +
'</system-reminder>'
const notice = detectContextBleed(text)
expect(notice).not.toBeNull()
expect(notice).toContain('/rewind')
})

test('does not fire when a fingerprinted block is wrapped in neutral prose (intentional quote)', () => {
// Tightened: a block embedded in substantial neutral explanation is a quote,
// not a bleed. (Previously this mid-text case fired; layer 3 now spares it.)
const text =
'这是 harness 注入的提醒:' +
'<system-reminder>' +
'This memory is 3 days old, verify before trusting.' +
'</system-reminder> 它只是用来标注记忆的年龄。'
expect(detectContextBleed(text)).toBeNull()
})

test('does not fire on normal prose that merely mentions "system reminder"', () => {
const text =
'The harness injects a system reminder into the user turn, not the assistant.'
expect(detectContextBleed(text)).toBeNull()
})

test('does not fire when the tag is named without a closed block', () => {
const text = '模型不应该在输出里回吐 <system-reminder> 这种内部标记。'
expect(detectContextBleed(text)).toBeNull()
})

test('does not fire on source code that references the tag literal', () => {
const text = "const SYSTEM_REMINDER_OPEN = '<system-reminder>'"
expect(detectContextBleed(text)).toBeNull()
})

test('does not fire when discussing the degradation, quoting its hallucination phrases', () => {
// This is exactly what a CCB dev session does: it names the tag and quotes
// the symptom phrases. The old naive `includes('<system-reminder>')` check
// false-fired here; the tightened one must not.
const text =
'收紧后,即使我在回复里提到 <system-reminder> 并引用"文件被污染/工具未执行"这些幻觉短语,也不该误报。'
expect(detectContextBleed(text)).toBeNull()
})

test('does not fire on a closed block whose body has no harness fingerprint', () => {
// e.g. quoting the language-restriction reminder verbatim for illustration.
const text =
'<system-reminder>语言限制:只允许中文或英文。</system-reminder>'
expect(detectContextBleed(text)).toBeNull()
})

test('returns null for null, empty, and whitespace-only input', () => {
expect(detectContextBleed(null)).toBeNull()
expect(detectContextBleed('')).toBeNull()
expect(detectContextBleed(' \n\t ')).toBeNull()
})

test('returns null for ordinary assistant output', () => {
expect(
detectContextBleed('Task 1 done. Now onemin_cut_process.py.'),
).toBeNull()
})

test('layer 1 (length gate): does not fire in a short session even on a bleed', () => {
const text =
'<system-reminder>Warning: this tool result is 5 hours old.</system-reminder>'
expect(detectContextBleed(text, 5)).toBeNull()
})

test('layer 1 (length gate): fires once the session is long enough', () => {
const text =
'<system-reminder>Warning: this tool result is 5 hours old.</system-reminder>'
expect(detectContextBleed(text, 80)).not.toBeNull()
})

test('layer 2 (meta-discussion): does not fire when outside prose discusses the guard', () => {
const text =
'<system-reminder>Warning: this tool result is 5 hours old.</system-reminder> —— 这正是 degradationGuard 要检测的回吐形态。'
expect(detectContextBleed(text, 80)).toBeNull()
})

test('layer 3 (quote): does not fire when a fingerprinted block is wrapped in neutral prose', () => {
const text =
'这是 harness 每轮开头附加的内容,用来标注工具结果已经存在多久,方便判断新鲜度:<system-reminder>Warning: this tool result is 5 hours old.</system-reminder> 它属于正常的会话机制,不影响结果。'
expect(detectContextBleed(text, 80)).toBeNull()
})

test('does not fire on a fingerprinted reminder quoted in a fenced code block', () => {
// A teaching example: the whole reminder lives inside a ``` fence, and the
// surrounding prose even carries symptom words. Without code-span exclusion
// this would false-fire; the guard must treat code as documentation.
const text =
'举个例子,模型不该虚构文件被污染。它长这样:\n' +
'```\n' +
'<system-reminder>Warning: this tool result is 5 hours old.' +
'</system-reminder>\n' +
'```'
expect(detectContextBleed(text, 80)).toBeNull()
})

test('does not fire on a fingerprinted reminder quoted in an inline code span', () => {
const text =
'内部提醒块 `<system-reminder>gentle reminder' +
'</system-reminder>` 只是被当作代码引用,文件被污染这类幻觉词也在。'
expect(detectContextBleed(text, 80)).toBeNull()
})

test('layer 3 (symptoms): fires when outside prose carries the hallucination symptoms', () => {
// The real 2026-07-27 signature: block regurgitated, then bogus claims that
// files were corrupted and tools never ran.
const text =
'<system-reminder>Warning: this tool result is 5 hours old.</system-reminder> 我发现文件被污染了,刚才的工具未执行。'
expect(detectContextBleed(text, 80)).not.toBeNull()
})
})
92 changes: 92 additions & 0 deletions src/services/api/__tests__/taskAnchorReminder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, test } from 'bun:test'
import type { Task } from '../../../utils/tasks.js'
import {
buildContinuationPrompt,
buildUnfinishedTaskNotice,
} from '../taskAnchorReminder.js'

function task(partial: Partial<Task> & Pick<Task, 'id' | 'status'>): Task {
return {
subject: `Task ${partial.id}`,
description: '',
blocks: [],
blockedBy: [],
...partial,
}
}

describe('buildContinuationPrompt', () => {
test('returns null when there are no unfinished tasks', () => {
expect(buildContinuationPrompt([])).toBeNull()
expect(
buildContinuationPrompt([
task({ id: '1', status: 'completed' }),
task({ id: '2', status: 'completed' }),
]),
).toBeNull()
})

test('picks the in_progress task over pending ones', () => {
const prompt = buildContinuationPrompt([
task({ id: '1', status: 'pending', subject: 'first' }),
task({ id: '2', status: 'in_progress', subject: 'second' }),
])
expect(prompt).toBe('按计划继续 Task #2 second')
})

test('among same status picks the lowest numeric id', () => {
const prompt = buildContinuationPrompt([
task({ id: '10', status: 'pending', subject: 'ten' }),
task({ id: '2', status: 'pending', subject: 'two' }),
])
expect(prompt).toBe('按计划继续 Task #2 two')
})

test('ignores completed tasks when picking', () => {
const prompt = buildContinuationPrompt([
task({ id: '1', status: 'completed', subject: 'done' }),
task({ id: '3', status: 'pending', subject: 'todo' }),
])
expect(prompt).toBe('按计划继续 Task #3 todo')
})
})

describe('buildUnfinishedTaskNotice', () => {
test('returns null when no task is in_progress', () => {
expect(buildUnfinishedTaskNotice([])).toBeNull()
expect(
buildUnfinishedTaskNotice([
task({ id: '1', status: 'pending' }),
task({ id: '2', status: 'completed' }),
]),
).toBeNull()
})

test('surfaces in_progress task names and pending count', () => {
const notice = buildUnfinishedTaskNotice([
task({ id: '1', status: 'in_progress', subject: '写模块' }),
task({ id: '2', status: 'pending' }),
task({ id: '3', status: 'pending' }),
task({ id: '4', status: 'completed' }),
])!
expect(notice).toContain('#1 写模块')
expect(notice).toContain('2 个待办')
})

test('collapses overflow when more than three in_progress tasks', () => {
const notice = buildUnfinishedTaskNotice(
Array.from({ length: 5 }, (_, i) =>
task({ id: String(i + 1), status: 'in_progress' }),
),
)!
expect(notice).toContain('等 5 个')
})

test('guides the user to press Tab instead of promising bare Enter', () => {
const notice = buildUnfinishedTaskNotice([
task({ id: '1', status: 'in_progress', subject: '写模块' }),
])!
expect(notice).toContain('按 Tab')
expect(notice).not.toContain('可直接回车继续')
})
})
Loading