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
21 changes: 21 additions & 0 deletions src/screens/REPL.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
]);
Comment on lines +3533 to +3536

Copy link
Copy Markdown
Contributor

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:

#!/bin/bash
set -euo pipefail

fd -HI -t f . | rg '/(tsconfig[^/]*\.json|package\.json|bunfig\.toml)$'
rg -n -C2 '"(baseUrl|paths)"|src/' \
  --glob 'tsconfig*.json' \
  --glob 'package.json' \
  --glob 'bunfig.toml' .

Repository: claude-code-best/claude-code

Length of output: 18729


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Top-level package manifests/configs:"
fd -HI -t f '/^(package\.json|tsconfig\.json|bunfig\.toml|vite\.config\.[^/]+)$' -d 2 . | sed 's#^\./##' | sort

echo
echo "Top-level package.json scripts and imports:"
python3 - <<'PY'
import json
from pathlib import Path
p=Path('package.json')
if p.exists():
    data=json.loads(p.read_text())
    print(json.dumps({"name": data.get("name")} | {k:data.get(k) for k in ["scripts","imports","type","tsup"]}, indent=2, sort_keys=True))
else:
    print("no package.json")
PY

echo
echo "Top-level tsconfig relevant fields:"
python3 - <<'PY'
import json
from pathlib import Path
p=Path('tsconfig.json')
if p.exists():
    data=json.loads(p.read_text())
    print(json.dumps({k:data.get(k) for k in ["extends","compilerOptions","include","exclude","references"]}, indent=2, sort_keys=True))
else:
    print("no top-level tsconfig.json")
PY

echo
echo "Top-level bunfig relevant:"
if [ -f bunfig.toml ]; then cat -n bunfig.toml; else echo "no bunfig.toml"; fi

echo
echo "Other root aliases/imports:"
rg -n -C2 '"imports"|"alias"|[Aa]lias|root:\s*[^#]+|baseUrl|resolve|alias:' --glob 'package.json' --glob 'tsconfig*.json' --glob 'vite.config.*' --glob 'bunfig.toml' .

Repository: claude-code-best/claude-code

Length of output: 7679


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import json, re
p='package.json'
data=json.load(open(p))
root=re.compile(r'^\s*root\s*=?\s*["\']?([^"'\n\#]+)["\']?\s*$|^\s*root\s*=\s*["\']?([^"'\n\#]+)["\']?\s*$')
tsconfig=json.load(open('tsconfig.json'))
files=[]
for f in ['src/utils/tasks.js', 'src/services/api/taskAnchorReminder.js']:
    try:
        from pathlib import Path
        files.append((f, Path(f).exists()))
    except Exception as e:
        files.append((f, f'<error: {e}>'))
print(json.dumps({
    "root_root_alias": re.search(root, open('bunfig.toml').read()).group(1) if root.search(open('bunfig.toml').read()) else None,
    "package_root_alias": data.get("root"),
    "tsconfig_paths_src": tsconfig.get("compilerOptions", {}).get("paths", {}).get("src/*"),
    "files_exist": dict(files)
}, indent=2))
PY

Repository: claude-code-best/claude-code

Length of output: 424


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "bunfig root settings:"
python3 - <<'PY'
from pathlib import Path
p=Path('bunfig.toml')
text=p.read_text()
for line in text.splitlines():
    s=line.strip()
    if s.startswith('root'):
        print(line)
print(bool(p.exists()))
PY

echo
echo "package root settings:"
python3 - <<'PY'
import json
data=json.load(open('package.json'))
print(data.get('root'))
PY

echo
echo "tsconfig src alias:"
python3 - <<'PY'
import json
data=json.load(open('tsconfig.json'))
print(data.get('compilerOptions',{}).get('paths',{}).get('src/*'))
PY

echo
echo "target files exist:"
python3 - <<'PY'
from pathlib import Path
for f in ['src/utils/tasks.js', 'src/services/api/taskAnchorReminder.js']:
    print(f"{f}: {Path(f).exists()}")
PY

Repository: claude-code-best/claude-code

Length of output: 359


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Search exact specifier targets:"
rg -n "'src/utils/tasks\.js'|'src/services/api/taskAnchorReminder\.js'|utils/tasks|taskAnchorReminder" --glob '!node_modules' .

echo
echo "Top-level manifest root package files:"
fd -HI -t f '^root\.js$|^src$' . -d 4 | sed 's#^\./##'

echo
echo "REPL snippet context:"
sed -n '3525,3550p' src/screens/REPL.tsx 2>/dev/null || true

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 a src/ alias, so these bare imports can resolve differently at native Bun runtime. Use ../utils/tasks.js and ../services/api/taskAnchorReminder.js instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/screens/REPL.tsx` around lines 3533 - 3536, Update the dynamic imports in
the notice injection within REPL to use the relative paths ../utils/tasks.js and
../services/api/taskAnchorReminder.js instead of src/ aliases, while preserving
the existing imported symbols and Promise.all flow.

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<string, unknown>).fireCompanionObserver === 'function') {
const _fireCompanionObserver = (globalThis as Record<string, unknown>).fireCompanionObserver as (
msgs: unknown,
Expand Down
129 changes: 129 additions & 0 deletions src/services/api/__tests__/taskAnchorReminder.test.ts
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 个')
})
})
35 changes: 35 additions & 0 deletions src/services/api/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -77,6 +81,7 @@ import {
createAssistantAPIErrorMessage,
createUserMessage,
ensureToolResultPairing,
getUserMessageText,
normalizeContentFromAPI,
normalizeMessagesForAPI,
stripAdvisorBlocks,
Expand Down Expand Up @@ -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).
Expand Down
95 changes: 95 additions & 0 deletions src/services/api/taskAnchorReminder.ts
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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, continue Task #2`` matches and injects a reminder telling the model to continue the highest-priority task, overriding the user’s explicit task selection. Exclude explicit task references (and add regression coverage for them).

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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)
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) &&
!TASK_REFERENCE_PATTERN.test(trimmed)
)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/api/taskAnchorReminder.ts` around lines 20 - 25, Update
isAnchorlessNudge to reject trimmed text containing an explicit task anchor
before applying ANCHORLESS_NUDGE_PATTERN, while preserving existing length and
nudge-word validation. Reuse the existing task-reference detection symbol if
available, and add regression coverage confirming inputs such as “continue Task
`#2`” return false.

}

// 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」这类带锚点的话催促,避免长任务跑偏。`
}