Skip to content

Commit a1bcddf

Browse files
committed
fix: 长会话任务跑偏护栏——Tab 预填续跑指令 + 上下文回吐退化守卫
1 parent 34b3dc9 commit a1bcddf

5 files changed

Lines changed: 360 additions & 0 deletions

File tree

src/screens/REPL.tsx

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1678,6 +1678,31 @@ export function REPL({
16781678
[setIsPromptInputActive, repinScroll, trySuggestBgPRIntercept],
16791679
);
16801680

1681+
// Pre-filled "按计划继续 Task #N …" instruction for a stalled long task, or
1682+
// null. Set when a turn ends with in_progress tasks (see the unfinished-task
1683+
// notice below), refreshed every turn so it can't go stale; consumed by Tab.
1684+
const [pendingContinuation, setPendingContinuation] = useState<string | null>(null);
1685+
1686+
// Tab on an empty prompt pre-fills the continuation instruction for the
1687+
// highest-priority unfinished task, letting the user re-anchor a stalled long
1688+
// task with one keystroke instead of blindly typing "继续". It only pre-fills —
1689+
// the user still presses Enter to send, so nothing is injected behind their
1690+
// back (unlike the removed silent <system-reminder> injection). The narrow
1691+
// isActive (empty input + a pending continuation, on the prompt screen) means
1692+
// normal Tab completion is never shadowed.
1693+
useInput(
1694+
(_input, key, event) => {
1695+
if (key.tab && !key.shift) {
1696+
setInputValue(pendingContinuation ?? '');
1697+
setPendingContinuation(null);
1698+
event.stopImmediatePropagation();
1699+
}
1700+
},
1701+
{
1702+
isActive: screen === 'prompt' && pendingContinuation !== null && inputValue === '',
1703+
},
1704+
);
1705+
16811706
// Schedule a timeout to stop suppressing dialogs after the user stops typing.
16821707
// Only manages the timeout — the immediate activation is handled by setInputValue above.
16831708
useEffect(() => {
@@ -3522,6 +3547,53 @@ export function REPL({
35223547
onQueryEvent(event);
35233548
}
35243549

3550+
// ── Unfinished-task notice ──
3551+
// When a turn completes naturally (not interrupted) but a task the model
3552+
// itself marked in_progress is still unfinished, surface a display-only
3553+
// local notice so the user doesn't have to blindly nudge "继续". This is a
3554+
// 'system' message: never sent to the model (0 token cost). Best-effort —
3555+
// a task-read failure must not affect turn teardown.
3556+
if (!abortController.signal.aborted) {
3557+
try {
3558+
const [{ listTasks, getTaskListId }, { buildUnfinishedTaskNotice, buildContinuationPrompt }] =
3559+
await Promise.all([import('src/utils/tasks.js'), import('src/services/api/taskAnchorReminder.js')]);
3560+
const tasks = await listTasks(getTaskListId());
3561+
const notice = buildUnfinishedTaskNotice(tasks);
3562+
if (notice) {
3563+
setMessages(prev => [...prev, createSystemMessage(notice, 'warning')]);
3564+
setPendingContinuation(buildContinuationPrompt(tasks));
3565+
} else {
3566+
setPendingContinuation(null);
3567+
}
3568+
} catch {
3569+
// Ignore — the notice is a convenience, not part of the turn contract.
3570+
}
3571+
}
3572+
3573+
// ── Context-bleed / degradation guard ──
3574+
// If the model regurgitated an internal <system-reminder> tag as its own
3575+
// output, that's an early signal of long-context inference-side degradation
3576+
// (2026-07-27: the model then hallucinated "file corrupted / tool not
3577+
// executed" while every tool had actually run). Surface a display-only local
3578+
// warning suggesting /rewind. 'system' message: never sent to the model
3579+
// (0 token cost). Read messagesRef.current — the closure's `messages` may be
3580+
// stale. Best-effort — a detection failure must not affect turn teardown.
3581+
if (!abortController.signal.aborted) {
3582+
try {
3583+
const [{ getLastAssistantMessage, getAssistantMessageText }, { detectContextBleed }] = await Promise.all([
3584+
import('src/utils/messages.js'),
3585+
import('src/services/api/degradationGuard.js'),
3586+
]);
3587+
const lastAssistant = getLastAssistantMessage(messagesRef.current);
3588+
const bleedNotice = lastAssistant ? detectContextBleed(getAssistantMessageText(lastAssistant)) : null;
3589+
if (bleedNotice) {
3590+
setMessages(prev => [...prev, createSystemMessage(bleedNotice, 'warning')]);
3591+
}
3592+
} catch {
3593+
// Ignore — the guard is a convenience, not part of the turn contract.
3594+
}
3595+
}
3596+
35253597
if (feature('BUDDY') && typeof (globalThis as Record<string, unknown>).fireCompanionObserver === 'function') {
35263598
const _fireCompanionObserver = (globalThis as Record<string, unknown>).fireCompanionObserver as (
35273599
msgs: unknown,
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { detectContextBleed } from '../degradationGuard'
3+
4+
describe('detectContextBleed', () => {
5+
test('fires on a closed reminder block whose body matches a harness fingerprint', () => {
6+
// Real sample from session 816c6bda (2026-07-27): a whole assistant text
7+
// block that is just a regurgitated internal reminder.
8+
const text =
9+
'<system-reminder>\nWarning: this tool result is 5 hours old. The user may have continued working since sending it.\n</system-reminder>'
10+
const notice = detectContextBleed(text)
11+
expect(notice).not.toBeNull()
12+
expect(notice).toContain('/rewind')
13+
})
14+
15+
test('fires when the block appears mid-text', () => {
16+
const text =
17+
'我发现问题了。<system-reminder>This memory is 3 days old, verify before trusting.</system-reminder> 继续核对。'
18+
expect(detectContextBleed(text)).not.toBeNull()
19+
})
20+
21+
test('does not fire on normal prose that merely mentions "system reminder"', () => {
22+
const text =
23+
'The harness injects a system reminder into the user turn, not the assistant.'
24+
expect(detectContextBleed(text)).toBeNull()
25+
})
26+
27+
test('does not fire when the tag is named without a closed block', () => {
28+
const text = '模型不应该在输出里回吐 <system-reminder> 这种内部标记。'
29+
expect(detectContextBleed(text)).toBeNull()
30+
})
31+
32+
test('does not fire on source code that references the tag literal', () => {
33+
const text = "const SYSTEM_REMINDER_OPEN = '<system-reminder>'"
34+
expect(detectContextBleed(text)).toBeNull()
35+
})
36+
37+
test('does not fire when discussing the degradation, quoting its hallucination phrases', () => {
38+
// This is exactly what a CCB dev session does: it names the tag and quotes
39+
// the symptom phrases. The old naive `includes('<system-reminder>')` check
40+
// false-fired here; the tightened one must not.
41+
const text =
42+
'收紧后,即使我在回复里提到 <system-reminder> 并引用"文件被污染/工具未执行"这些幻觉短语,也不该误报。'
43+
expect(detectContextBleed(text)).toBeNull()
44+
})
45+
46+
test('does not fire on a closed block whose body has no harness fingerprint', () => {
47+
// e.g. quoting the language-restriction reminder verbatim for illustration.
48+
const text =
49+
'<system-reminder>语言限制:只允许中文或英文。</system-reminder>'
50+
expect(detectContextBleed(text)).toBeNull()
51+
})
52+
53+
test('returns null for null, empty, and whitespace-only input', () => {
54+
expect(detectContextBleed(null)).toBeNull()
55+
expect(detectContextBleed('')).toBeNull()
56+
expect(detectContextBleed(' \n\t ')).toBeNull()
57+
})
58+
59+
test('returns null for ordinary assistant output', () => {
60+
expect(
61+
detectContextBleed('Task 1 done. Now onemin_cut_process.py.'),
62+
).toBeNull()
63+
})
64+
})
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import type { Task } from '../../../utils/tasks.js'
3+
import {
4+
buildContinuationPrompt,
5+
buildUnfinishedTaskNotice,
6+
} from '../taskAnchorReminder.js'
7+
8+
function task(partial: Partial<Task> & Pick<Task, 'id' | 'status'>): Task {
9+
return {
10+
subject: `Task ${partial.id}`,
11+
description: '',
12+
blocks: [],
13+
blockedBy: [],
14+
...partial,
15+
}
16+
}
17+
18+
describe('buildContinuationPrompt', () => {
19+
test('returns null when there are no unfinished tasks', () => {
20+
expect(buildContinuationPrompt([])).toBeNull()
21+
expect(
22+
buildContinuationPrompt([
23+
task({ id: '1', status: 'completed' }),
24+
task({ id: '2', status: 'completed' }),
25+
]),
26+
).toBeNull()
27+
})
28+
29+
test('picks the in_progress task over pending ones', () => {
30+
const prompt = buildContinuationPrompt([
31+
task({ id: '1', status: 'pending', subject: 'first' }),
32+
task({ id: '2', status: 'in_progress', subject: 'second' }),
33+
])
34+
expect(prompt).toBe('按计划继续 Task #2 second')
35+
})
36+
37+
test('among same status picks the lowest numeric id', () => {
38+
const prompt = buildContinuationPrompt([
39+
task({ id: '10', status: 'pending', subject: 'ten' }),
40+
task({ id: '2', status: 'pending', subject: 'two' }),
41+
])
42+
expect(prompt).toBe('按计划继续 Task #2 two')
43+
})
44+
45+
test('ignores completed tasks when picking', () => {
46+
const prompt = buildContinuationPrompt([
47+
task({ id: '1', status: 'completed', subject: 'done' }),
48+
task({ id: '3', status: 'pending', subject: 'todo' }),
49+
])
50+
expect(prompt).toBe('按计划继续 Task #3 todo')
51+
})
52+
})
53+
54+
describe('buildUnfinishedTaskNotice', () => {
55+
test('returns null when no task is in_progress', () => {
56+
expect(buildUnfinishedTaskNotice([])).toBeNull()
57+
expect(
58+
buildUnfinishedTaskNotice([
59+
task({ id: '1', status: 'pending' }),
60+
task({ id: '2', status: 'completed' }),
61+
]),
62+
).toBeNull()
63+
})
64+
65+
test('surfaces in_progress task names and pending count', () => {
66+
const notice = buildUnfinishedTaskNotice([
67+
task({ id: '1', status: 'in_progress', subject: '写模块' }),
68+
task({ id: '2', status: 'pending' }),
69+
task({ id: '3', status: 'pending' }),
70+
task({ id: '4', status: 'completed' }),
71+
])!
72+
expect(notice).toContain('#1 写模块')
73+
expect(notice).toContain('2 个待办')
74+
})
75+
76+
test('collapses overflow when more than three in_progress tasks', () => {
77+
const notice = buildUnfinishedTaskNotice(
78+
Array.from({ length: 5 }, (_, i) =>
79+
task({ id: String(i + 1), status: 'in_progress' }),
80+
),
81+
)!
82+
expect(notice).toContain('等 5 个')
83+
})
84+
85+
test('guides the user to press Tab instead of promising bare Enter', () => {
86+
const notice = buildUnfinishedTaskNotice([
87+
task({ id: '1', status: 'in_progress', subject: '写模块' }),
88+
])!
89+
expect(notice).toContain('按 Tab')
90+
expect(notice).not.toContain('可直接回车继续')
91+
})
92+
})
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Context-bleed / degradation guard.
2+
//
3+
// When the model regurgitates a whole internal <system-reminder> block as its
4+
// own assistant output, that is a clean early signal of long-context
5+
// inference-side degradation. These blocks are injected by the harness into the
6+
// USER side and are never legitimately authored by the model.
7+
//
8+
// Seen 2026-07-27 (session 816c6bda): after regurgitating a
9+
// "<system-reminder>...tool result is 5 hours old...</system-reminder>" block,
10+
// the model went on to hallucinate "file corrupted" / "tool calls were rendered
11+
// as literal text and not executed" — while every tool had actually run and
12+
// every edit had landed. Same inference-side hallucination family documented in
13+
// the context-contamination investigation.
14+
//
15+
// Detection is deliberately narrow: naming the <system-reminder> tag while
16+
// *discussing* the mechanism (as CCB development sessions constantly do — this
17+
// very repo has 90+ references across 34 files), writing it in code, or quoting
18+
// the degradation's hallucination phrases ("文件被污染", "工具未执行") must NOT
19+
// trip the guard. We therefore require a *closed* <system-reminder>…</system-reminder>
20+
// block whose body carries fingerprint text that only harness-generated
21+
// reminders contain. A false alarm here erodes trust and nags the user, so we
22+
// bias toward missing an unclosed/novel regurgitation over firing on normal prose.
23+
24+
const SYSTEM_REMINDER_OPEN = '<system-reminder>'
25+
26+
// Matches a fully closed reminder block; capture group 1 is the body.
27+
const CLOSED_REMINDER_BLOCK = /<system-reminder>([\s\S]*?)<\/system-reminder>/gi
28+
29+
// Body-text fingerprints that appear *inside* harness-generated reminders
30+
// (tool-result staleness, memory age, todo nudge, hook context, the former
31+
// task-anchor injection…). Discussing the mechanism references the tag, not
32+
// these full internal sentences, so requiring one of these inside a closed
33+
// block is what keeps the false-positive rate low.
34+
const REMINDER_BODY_FINGERPRINTS = [
35+
/tool result is\b/i,
36+
/\b(?:hours?|minutes?) old\b/i,
37+
/this memory is\b[\s\S]*\bdays? old\b/i,
38+
/gentle reminder/i,
39+
/never mention this reminder/i,
40+
/hook additional context/i,
41+
/generated by the client/i,
42+
/not sent by the user/i,
43+
]
44+
45+
const CONTEXT_BLEED_NOTICE =
46+
'检测到模型在输出中回吐了完整的内部 <system-reminder> 提醒块——这通常是超长上下文下推理侧退化/上下文回吐的早期信号,模型接下来可能虚构"文件被污染/工具未执行"等并不存在的问题。建议 /rewind 回到上一轮,或先核对文件与工具结果均正常再继续。'
47+
48+
/**
49+
* Returns a display-only, user-facing warning when `assistantText` contains a
50+
* regurgitated internal <system-reminder> block, else null. Requires a closed
51+
* <system-reminder>…</system-reminder> block whose body matches a harness
52+
* reminder fingerprint, so merely mentioning the tag (in discussion, code, or
53+
* docs) does not fire. Zero token cost — the caller surfaces this as a local
54+
* 'warning' system message, never sent to the model.
55+
*/
56+
export function detectContextBleed(
57+
assistantText: string | null,
58+
): string | null {
59+
if (!assistantText || !assistantText.includes(SYSTEM_REMINDER_OPEN)) {
60+
return null
61+
}
62+
for (const match of assistantText.matchAll(CLOSED_REMINDER_BLOCK)) {
63+
const body = match[1]
64+
if (REMINDER_BODY_FINGERPRINTS.some(re => re.test(body))) {
65+
return CONTEXT_BLEED_NOTICE
66+
}
67+
}
68+
return null
69+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { Task } from '../../utils/tasks.js'
2+
3+
// Unfinished tasks are re-surfaced to the *user* (never injected into the model
4+
// request) after a turn ends with work still pending. See the long-session
5+
// drift investigation (2026-07-23): rather than silently re-injecting a
6+
// <system-reminder> — which a degraded model can misread as a prompt-injection
7+
// attack — we let the user re-anchor with one keystroke that pre-fills a real
8+
// user instruction into the input box.
9+
10+
const STATUS_ORDER: Record<string, number> = {
11+
in_progress: 0,
12+
pending: 1,
13+
}
14+
15+
/**
16+
* Picks the highest-priority unfinished task (in_progress before pending, then
17+
* by numeric id) and returns a ready-to-send user instruction re-anchoring the
18+
* model to it, e.g. "按计划继续 Task #2 改 onemin_cut_process.py". Returns null
19+
* when nothing is unfinished. The caller pre-fills this into the input box; it
20+
* is sent only when the user actually presses Enter, so it costs no extra token
21+
* and is never injected behind the user's back.
22+
*/
23+
export function buildContinuationPrompt(tasks: Task[]): string | null {
24+
const unfinished = tasks.filter(t => t.status !== 'completed')
25+
if (unfinished.length === 0) {
26+
return null
27+
}
28+
const [top] = [...unfinished].sort((a, b) => {
29+
const byStatus =
30+
(STATUS_ORDER[a.status] ?? 2) - (STATUS_ORDER[b.status] ?? 2)
31+
if (byStatus !== 0) {
32+
return byStatus
33+
}
34+
return Number(a.id) - Number(b.id)
35+
})
36+
return `按计划继续 Task #${top.id} ${top.subject}`
37+
}
38+
39+
// How many in_progress task names to spell out in the local notice.
40+
const MAX_NOTICE_TASK_NAMES = 3
41+
42+
/**
43+
* Builds a display-only, user-facing notice for when a turn ends naturally while
44+
* tasks are still in_progress — a strong signal the model stopped mid-work. Only
45+
* in_progress tasks trigger it (a pending backlog the user is pacing does not).
46+
* Returns null when there is nothing worth surfacing. This text is shown in the
47+
* REPL and never sent to the model (zero token cost).
48+
*/
49+
export function buildUnfinishedTaskNotice(tasks: Task[]): string | null {
50+
const inProgress = tasks.filter(t => t.status === 'in_progress')
51+
if (inProgress.length === 0) {
52+
return null
53+
}
54+
const pendingCount = tasks.filter(t => t.status === 'pending').length
55+
const names = inProgress
56+
.slice(0, MAX_NOTICE_TASK_NAMES)
57+
.map(t => `#${t.id} ${t.subject}`)
58+
.join('、')
59+
const overflow = inProgress.length - MAX_NOTICE_TASK_NAMES
60+
const namesSuffix = overflow > 0 ? ` 等 ${inProgress.length} 个` : ''
61+
const pendingSuffix = pendingCount > 0 ? `,另有 ${pendingCount} 个待办` : ''
62+
return `还有进行中的任务未完成:${names}${namesSuffix}${pendingSuffix}。按 Tab 填入续跑指令(可编辑后回车发送)。`
63+
}

0 commit comments

Comments
 (0)