diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 05051799b9..2c880ab251 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -3522,6 +3522,27 @@ 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 }] = await Promise.all([ + import('src/utils/tasks.js'), + import('src/services/api/taskAnchorReminder.js'), + ]); + const notice = buildUnfinishedTaskNotice(await listTasks(getTaskListId())); + if (notice) { + setMessages(prev => [...prev, createSystemMessage(notice, 'warning')]); + } + } catch { + // Ignore — the notice is a convenience, not part of the turn contract. + } + } + if (feature('BUDDY') && typeof (globalThis as Record).fireCompanionObserver === 'function') { const _fireCompanionObserver = (globalThis as Record).fireCompanionObserver as ( msgs: unknown, diff --git a/src/services/api/__tests__/taskAnchorReminder.test.ts b/src/services/api/__tests__/taskAnchorReminder.test.ts new file mode 100644 index 0000000000..a5bde215ec --- /dev/null +++ b/src/services/api/__tests__/taskAnchorReminder.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from 'bun:test' +import type { Task } from '../../../utils/tasks.js' +import { + buildTaskAnchorReminder, + buildUnfinishedTaskNotice, + isAnchorlessNudge, +} from '../taskAnchorReminder.js' + +function task(partial: Partial & Pick): Task { + return { + subject: `Task ${partial.id}`, + description: '', + blocks: [], + blockedBy: [], + ...partial, + } +} + +describe('isAnchorlessNudge', () => { + test('matches short Chinese keep-going nudges', () => { + expect(isAnchorlessNudge('继续执行后续的工作')).toBe(true) + expect(isAnchorlessNudge('继续完成当前任务')).toBe(true) + expect(isAnchorlessNudge('自动执行后续的所有流程')).toBe(true) + expect(isAnchorlessNudge('接着做')).toBe(true) + }) + + test('matches short English keep-going nudges', () => { + expect(isAnchorlessNudge('continue')).toBe(true) + expect(isAnchorlessNudge('keep going')).toBe(true) + expect(isAnchorlessNudge('go on please')).toBe(true) + }) + + test('does not match empty or whitespace', () => { + expect(isAnchorlessNudge('')).toBe(false) + expect(isAnchorlessNudge(' ')).toBe(false) + }) + + test('does not match long messages that merely contain a nudge word', () => { + expect( + isAnchorlessNudge( + '继续用状态机方案重构 REPL 的输入处理模块,并补齐对应的单元测试', + ), + ).toBe(false) + }) + + test('does not match substantive requests without nudge words', () => { + expect(isAnchorlessNudge('给我看下 query.ts 的结构')).toBe(false) + expect(isAnchorlessNudge('fix the login bug')).toBe(false) + }) +}) + +describe('buildTaskAnchorReminder', () => { + test('returns null when there are no unfinished tasks', () => { + expect(buildTaskAnchorReminder([])).toBeNull() + expect( + buildTaskAnchorReminder([ + task({ id: '1', status: 'completed' }), + task({ id: '2', status: 'completed' }), + ]), + ).toBeNull() + }) + + test('lists only unfinished tasks, in_progress before pending', () => { + const reminder = buildTaskAnchorReminder([ + task({ id: '1', status: 'completed', subject: 'done' }), + task({ id: '2', status: 'pending', subject: 'second' }), + task({ id: '3', status: 'in_progress', subject: 'third' }), + ]) + expect(reminder).not.toBeNull() + const body = reminder! + expect(body).toContain('2 unfinished task(s)') + // in_progress (#3) must appear before pending (#2) + expect(body.indexOf('#3 [in_progress]')).toBeLessThan( + body.indexOf('#2 [pending]'), + ) + expect(body).not.toContain('#1') + expect(body).toContain('') + expect(body).toContain('') + }) + + test('orders same-status tasks by numeric id', () => { + const reminder = buildTaskAnchorReminder([ + task({ id: '10', status: 'pending', subject: 'ten' }), + task({ id: '2', status: 'pending', subject: 'two' }), + ])! + expect(reminder.indexOf('#2 ')).toBeLessThan(reminder.indexOf('#10 ')) + }) + + test('caps the list and reports overflow', () => { + const many: Task[] = Array.from({ length: 25 }, (_, i) => + task({ id: String(i + 1), status: 'pending' }), + ) + const reminder = buildTaskAnchorReminder(many)! + expect(reminder).toContain('25 unfinished task(s)') + expect(reminder).toContain('…and 5 more') + }) +}) + +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 个') + }) +}) diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index 5c75e22221..c1bfcc934d 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -58,6 +58,10 @@ import { toolToAPISchema, } from '../../utils/api.js' import { getOauthAccountInfo } from '../../utils/auth.js' +import { + buildTaskAnchorReminder, + isAnchorlessNudge, +} from './taskAnchorReminder.js' import { getBedrockExtraBodyParamsBetas, getMergedBetas, @@ -77,6 +81,7 @@ import { createAssistantAPIErrorMessage, createUserMessage, ensureToolResultPairing, + getUserMessageText, normalizeContentFromAPI, normalizeMessagesForAPI, stripAdvisorBlocks, @@ -1337,6 +1342,36 @@ async function* queryModel( API_MAX_MEDIA_PER_REQUEST, ) + // ── Long-task anchor re-injection ── + // When the user's latest message is a short anchorless nudge ("继续"/"keep + // going") and the task list still has unfinished items, re-surface them once + // for this request. This fires only in that exact situation (short nudge is + // the LAST message, which is only true at turn start — during tool loops the + // last message is a tool_result), so there is no per-turn token cost. Mirrors + // the ephemeral, non-persisted isMeta injection used for deferred tools below. + { + const lastMessage = messagesForAPI.at(-1) + const lastText = lastMessage ? getUserMessageText(lastMessage) : null + if (lastText && isAnchorlessNudge(lastText)) { + try { + const { listTasks, getTaskListId } = await import( + '../../utils/tasks.js' + ) + const reminder = buildTaskAnchorReminder( + await listTasks(getTaskListId()), + ) + if (reminder) { + messagesForAPI = [ + ...messagesForAPI, + createUserMessage({ content: reminder, isMeta: true }), + ] + } + } catch { + // Best-effort anchor: task read failures must never block the request. + } + } + } + // OpenAI-compatible provider: delegate to the OpenAI adapter layer // after shared preprocessing (message normalization, tool filtering, // media stripping) but before Anthropic-specific logic (betas, thinking, caching). diff --git a/src/services/api/taskAnchorReminder.ts b/src/services/api/taskAnchorReminder.ts new file mode 100644 index 0000000000..9991ea1a88 --- /dev/null +++ b/src/services/api/taskAnchorReminder.ts @@ -0,0 +1,95 @@ +import type { Task } from '../../utils/tasks.js' + +// Short, generic "keep going" nudges that carry no task anchor. In a long +// session these dilute the task anchor and the model drifts off the task +// (see the long-session drift investigation, 2026-07-23). When one arrives and +// the task list still has unfinished items, we re-surface them once — only in +// this exact situation, so there is no per-turn token cost. +const ANCHORLESS_NUDGE_PATTERN = + /(继续|接着|后续|往下|完成剩余|自动执行|自动跑|接着做|keep going|carry on|go on|continue|proceed|next step|move on)/i + +// Guard against false positives on substantive messages that merely contain a +// nudge word (e.g. "继续用 X 方案重构 Y 模块…"). Real anchorless nudges are short +// — the ones seen in the investigation were all ≤12 CJK chars ("继续执行后续的工作"). +const MAX_NUDGE_CHARS = 20 + +/** + * True when `text` is a short "keep going" style nudge that carries no specific + * task anchor. Long messages never match, even if they contain a nudge word. + */ +export function isAnchorlessNudge(text: string): boolean { + const trimmed = text.trim() + if (!trimmed || trimmed.length > MAX_NUDGE_CHARS) { + return false + } + return ANCHORLESS_NUDGE_PATTERN.test(trimmed) +} + +// Cap the injected list so a large backlog can't balloon the reminder. +const MAX_TASKS_IN_REMINDER = 20 + +const STATUS_ORDER: Record = { + in_progress: 0, + pending: 1, +} + +/** + * Builds a `` re-surfacing the unfinished tasks, or null when + * there are none. in_progress tasks come first, then pending, ordered by id. + */ +export function buildTaskAnchorReminder(tasks: Task[]): string | null { + const unfinished = tasks.filter(t => t.status !== 'completed') + if (unfinished.length === 0) { + return null + } + + const sorted = [...unfinished].sort((a, b) => { + const byStatus = + (STATUS_ORDER[a.status] ?? 2) - (STATUS_ORDER[b.status] ?? 2) + if (byStatus !== 0) { + return byStatus + } + return Number(a.id) - Number(b.id) + }) + + const shown = sorted.slice(0, MAX_TASKS_IN_REMINDER) + const lines = shown.map(t => `- #${t.id} [${t.status}] ${t.subject}`) + const overflow = sorted.length - shown.length + if (overflow > 0) { + lines.push(`- …and ${overflow} more`) + } + + return [ + '', + `You still have ${unfinished.length} unfinished task(s) in the current task list. The user's last message was a short "keep going" nudge without a specific anchor. Re-align to these tasks and continue the highest-priority unfinished one. Do not end your turn until the tasks are done or you genuinely need user input:`, + ...lines, + 'Use TaskUpdate to mark progress as you go. This reminder is generated by the client, not sent by the user.', + '', + ].join('\n') +} + +// How many in_progress task names to spell out in the local notice. +const MAX_NOTICE_TASK_NAMES = 3 + +/** + * Builds a display-only, user-facing notice for when a turn ends naturally while + * tasks are still in_progress — a strong signal the model stopped mid-work. Only + * in_progress tasks trigger it (a pending backlog the user is pacing does not). + * Returns null when there is nothing worth surfacing. This text is shown in the + * REPL and never sent to the model (zero token cost). + */ +export function buildUnfinishedTaskNotice(tasks: Task[]): string | null { + const inProgress = tasks.filter(t => t.status === 'in_progress') + if (inProgress.length === 0) { + return null + } + const pendingCount = tasks.filter(t => t.status === 'pending').length + const names = inProgress + .slice(0, MAX_NOTICE_TASK_NAMES) + .map(t => `#${t.id} ${t.subject}`) + .join('、') + const overflow = inProgress.length - MAX_NOTICE_TASK_NAMES + const namesSuffix = overflow > 0 ? ` 等 ${inProgress.length} 个` : '' + const pendingSuffix = pendingCount > 0 ? `,另有 ${pendingCount} 个待办` : '' + return `还有进行中的任务未完成:${names}${namesSuffix}${pendingSuffix}。可直接回车继续,或用「按计划继续 Task #N」这类带锚点的话催促,避免长任务跑偏。` +}