-
Notifications
You must be signed in to change notification settings - Fork 16.5k
fix: 长会话任务锚点重注入 + 过早 end_turn 提示,缓解任务跑偏 #1309
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Task> & Pick<Task, 'id' | 'status'>): 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('<system-reminder>') | ||
| expect(body).toContain('</system-reminder>') | ||
| }) | ||
|
|
||
| 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 个') | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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) | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+20
to
+25
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Reject nudges that include a task anchor. Line 25 treats any short text containing a nudge word as anchorless. For example, Proposed direction+const TASK_REFERENCE_PATTERN = /(?:task|任务)\s*#?\d+\b/i
+
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)
+ return (
+ ANCHORLESS_NUDGE_PATTERN.test(trimmed) &&
+ !TASK_REFERENCE_PATTERN.test(trimmed)
+ )
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| // Cap the injected list so a large backlog can't balloon the reminder. | ||||||||||||||||||||||||||||||||||||||
| const MAX_TASKS_IN_REMINDER = 20 | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const STATUS_ORDER: Record<string, number> = { | ||||||||||||||||||||||||||||||||||||||
| in_progress: 0, | ||||||||||||||||||||||||||||||||||||||
| pending: 1, | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||
| * Builds a `<system-reminder>` 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 [ | ||||||||||||||||||||||||||||||||||||||
| '<system-reminder>', | ||||||||||||||||||||||||||||||||||||||
| `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.', | ||||||||||||||||||||||||||||||||||||||
| '</system-reminder>', | ||||||||||||||||||||||||||||||||||||||
| ].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」这类带锚点的话催促,避免长任务跑偏。` | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: claude-code-best/claude-code
Length of output: 18729
🏁 Script executed:
Repository: claude-code-best/claude-code
Length of output: 7679
🏁 Script executed:
Repository: claude-code-best/claude-code
Length of output: 424
🏁 Script executed:
Repository: claude-code-best/claude-code
Length of output: 359
🏁 Script executed:
Repository: claude-code-best/claude-code
Length of output: 4906
Use relative dynamic imports for the notice injection.
src/*is only configured in TypeScript and Vite config; Bun package imports are not configured with asrc/alias, so these bare imports can resolve differently at native Bun runtime. Use../utils/tasks.jsand../services/api/taskAnchorReminder.jsinstead.🤖 Prompt for AI Agents