diff --git a/src/functions/todo-extract-langextract.py b/src/functions/todo-extract-langextract.py index 46d3d7a4..6b0e4731 100644 --- a/src/functions/todo-extract-langextract.py +++ b/src/functions/todo-extract-langextract.py @@ -25,6 +25,12 @@ When refreshAction metadata is provided, use it as advisory context about the existing card. Prefer a clearer title/description suggested by cleanup metadata only when the nearby source quote supports it. + The input may include taskChains. Treat each taskChain as the primary unit: + title should anchor to the user's original intent, while description should + anchor to the latest agent status, blocker, or next step. Low-information + user turns such as "继续", "重试", "再来一次", "retry", or "continue" must be + merged back into the previous related taskChain; do not create separate + cards for those turns. Extract only UNRESOLVED, actionable items: explicit next actions, follow-ups, failed validations, blocked work, and in-progress work that still needs doing. DO NOT extract completed work, results, status reports, confirmations, or @@ -137,11 +143,36 @@ def main() -> int: return 0 lx = load_langextract() - text = "\n\n".join( + task_chains = payload.get("taskChains") or [] + chain_texts = [] + if isinstance(task_chains, list): + for chain in task_chains: + if not isinstance(chain, dict): + continue + user_id = chain.get("userObservationId", "") + status_id = chain.get("latestStatusObservationId", "") + user_intent = chain.get("userIntent", "") + latest_status = chain.get("latestStatus", "") + summary = chain.get("completionSummary", "") + next_step = chain.get("nextStep", "") + chain_texts.append( + "\n".join( + [ + f"[taskChain:{chain.get('chainId', '')}]", + f"[obs:{user_id}] 用户意图: {user_intent}", + f"[obs:{status_id}] Agent最新状态: {latest_status}", + f"completionState: {chain.get('completionState', '')}", + f"completionSummary: {summary}", + f"nextStep: {next_step}", + ] + ) + ) + block_text = "\n\n".join( f"[obs:{b.get('sourceObservationId','')}]\n{b.get('text','')}" for b in blocks if isinstance(b, dict) and b.get("text") ) + text = "\n\n".join([part for part in chain_texts + [block_text] if part]) if not text.strip(): print(json.dumps({"todos": []}, ensure_ascii=False)) return 0 @@ -351,6 +382,12 @@ class DummyLx: assert "做最后一次状态确认" in PROMPT assert "Negative example" in PROMPT assert "refreshAction metadata" in PROMPT + assert "taskChains" in PROMPT + assert "用户意图" in "\n".join([ + f"[taskChain:test]", + f"[obs:u1] 用户意图: 继续", + f"[obs:a1] Agent最新状态: 下一步需要修复", + ]) assert "0.55-0.81" in PROMPT assert "dedupeKey must be a short STABLE slug" in PROMPT assert "克隆 AI-Todo 仓库" in PROMPT diff --git a/src/functions/todo-extract.ts b/src/functions/todo-extract.ts index c8fcd2eb..d0726472 100644 --- a/src/functions/todo-extract.ts +++ b/src/functions/todo-extract.ts @@ -65,6 +65,29 @@ type ObservationBlock = { text: string; }; +type TodoCompletionState = "completed" | "in_progress" | "interrupted"; + +type TodoTaskChain = { + chainId: string; + sourceSessionId: string; + userObservationId: string; + userIntent: string; + latestStatusObservationId: string; + latestStatus: string; + continuedByObservationIds: string[]; + observationIds: string[]; + confidence: number; + completionState: TodoCompletionState; + completionSummary: string; + nextStep?: string; + dedupeKey: string; +}; + +type ExtractedTodoWithContext = ExtractedTodo & { + quality?: TodoQuality; + taskChain?: TodoTaskChain; +}; + type TodoExtractOptions = { maxSessions?: number; maxObservationsPerSession?: number; @@ -79,6 +102,7 @@ type TodoExtractOptions = { force?: boolean; scanSources?: boolean; cleanup?: "none" | "dry-run" | "apply"; + runLangExtractSidecar?: LangExtractRunner; }; type TodoRefreshActionOptions = { @@ -91,6 +115,7 @@ type ExtractForSessionOptions = { runLangExtractSidecar?: LangExtractRunner; refreshAction?: Record; forceLlmContext?: boolean; + taskChains?: TodoTaskChain[]; }; const TIME_BUCKETS = new Set(["current", "recent", "history"]); @@ -298,7 +323,7 @@ function todoQualityMetadata(quality: TodoQuality): Record { } function effectiveTodoConfidence(todo: ExtractedTodo): number { - const quality = (todo as ExtractedTodo & { quality?: TodoQuality }).quality; + const quality = (todo as ExtractedTodoWithContext).quality; return Math.min(todo.confidence, quality?.confidence ?? 1); } @@ -528,6 +553,170 @@ function interactionRanges(observations: CompressedObservation[]): Array<{ start })); } +const LOW_INFO_USER_PROMPT = /^(?:继续|重试|再试一次|再来一次|再跑一次|看下|看一下|这个不对|不对|继续处理|继续吧|retry|continue|again|try again|rerun|re-run)$/i; +const BLOCKED_SIGNAL = /(?:阻塞|卡住|卡在|失败|报错|没有权限|网络错误|超时|\b(?:blocked|blocking|failed|failing|error|permission denied|network error|timeout)\b)/i; +const NEXT_STEP_SIGNAL = /(?:下一步|后续|还需|仍需|待办|待处理|需要继续|需要修|需要补|需要排查|需要重试|TODO|FIXME|\b(?:need to|needs? fixing|must|follow up|follow-up|next step)\b)/i; +const NO_MORE_WORK_SIGNAL = /(?:无需后续|无需继续|无需处理|不需要后续|无后续|no action needed|nothing else|no further action)/i; + +function isLowInfoUserPrompt(value: string): boolean { + const text = normalizeText(value).replace(/[。!?!?,,;;::\s]+$/u, ""); + if (!text || Array.from(text).length > 32) return false; + return LOW_INFO_USER_PROMPT.test(text); +} + +function hasNextStepSignal(value: string): boolean { + const text = normalizeText(value); + if (!text || NO_MORE_WORK_SIGNAL.test(text)) return false; + return NEXT_STEP_SIGNAL.test(text); +} + +function completionStateFor(value: string): TodoCompletionState { + const text = normalizeText(value); + if (!text) return "in_progress"; + if (BLOCKED_SIGNAL.test(text)) return "interrupted"; + if (hasNextStepSignal(text)) return "in_progress"; + if (NO_MORE_WORK_SIGNAL.test(text) || isCompletedTodoText(text)) return "completed"; + return "in_progress"; +} + +function completionSummaryFor(value: string): string { + const text = normalizeText(value); + const first = text.match(/[^。!?!?\n]+[。!?!?]?/u)?.[0] || text; + return trimToTitleBoundary(first.replace(/\s+/g, " ").trim(), 140); +} + +function nextStepFor(value: string): string | undefined { + const text = normalizeText(value); + if (!text || NO_MORE_WORK_SIGNAL.test(text)) return undefined; + const match = text.match(/(?:下一步|后续|还需|仍需|待办|待处理)[::\s]*(需要)?([^。!?!?\n]{2,120})/u) || + text.match(/\b(?:need to|next step|follow up)[:\s]+([^.!?\n]{2,120})/i); + const next = normalizeText((match && (match[2] || match[1])) || ""); + return next ? next.replace(/[。!?!?,,;;::\s]+$/u, "") : undefined; +} + +function latestStatusObservation(range: CompressedObservation[], userObservationId: string | undefined): CompressedObservation | undefined { + for (let i = range.length - 1; i >= 0; i--) { + const obs = range[i]; + if (obs.id === userObservationId) continue; + const text = textForObservation(obs); + if (text && !isPollutedTodoText(text)) return obs; + } + return range[range.length - 1]; +} + +function intentUsefulForTaskChain(value: string): boolean { + const text = normalizeText(value); + if (!text || isLowInfoUserPrompt(text) || isPollutedTodoText(text)) return false; + if (/^|^#\s+/i.test(text)) return false; + return hasTodoActionTrigger(text) || Array.from(text).length >= 8; +} + +function taskChainDedupeKey(intent: string, status: string): string { + return normalizedKey(`${firstTitleSentence(intent)}:${firstTitleSentence(status)}`).slice(0, 180); +} + +function createTaskChain(session: Session, userObs: CompressedObservation | undefined, statusObs: CompressedObservation, range: CompressedObservation[]): TodoTaskChain | null { + const userIntent = normalizeText(userObs ? textForObservation(userObs) : ""); + const latestStatus = normalizeText(textForObservation(statusObs)); + if (!intentUsefulForTaskChain(userIntent) && !observationUsefulForTodo(statusObs)) return null; + const anchorObs = userObs || statusObs; + const state = completionStateFor(latestStatus || userIntent); + return { + chainId: fingerprintId("chain", `${session.id}:${anchorObs.id}`), + sourceSessionId: session.id, + userObservationId: anchorObs.id, + userIntent: userIntent || latestStatus, + latestStatusObservationId: statusObs.id, + latestStatus, + continuedByObservationIds: [], + observationIds: range.map((obs) => obs.id).filter(Boolean), + confidence: observationUsefulForTodo(statusObs) ? 0.9 : 0.72, + completionState: state, + completionSummary: completionSummaryFor(latestStatus || userIntent), + ...(nextStepFor(latestStatus) ? { nextStep: nextStepFor(latestStatus) } : {}), + dedupeKey: taskChainDedupeKey(userIntent || latestStatus, latestStatus), + }; +} + +function mergeRangeIntoTaskChain(chain: TodoTaskChain, userObs: CompressedObservation | undefined, statusObs: CompressedObservation, range: CompressedObservation[]): TodoTaskChain { + const latestStatus = normalizeText(textForObservation(statusObs)) || chain.latestStatus; + const continued = userObs?.id ? [...chain.continuedByObservationIds, userObs.id] : chain.continuedByObservationIds; + const observationIds = Array.from(new Set([...chain.observationIds, ...range.map((obs) => obs.id).filter(Boolean)])); + return { + ...chain, + latestStatusObservationId: statusObs.id, + latestStatus, + continuedByObservationIds: continued, + observationIds, + completionState: completionStateFor(latestStatus), + completionSummary: completionSummaryFor(latestStatus || chain.completionSummary), + ...(nextStepFor(latestStatus) ? { nextStep: nextStepFor(latestStatus) } : {}), + dedupeKey: taskChainDedupeKey(chain.userIntent, latestStatus), + }; +} + +function buildTaskChains(session: Session, observations: CompressedObservation[]): TodoTaskChain[] { + const sorted = [...observations].sort((a, b) => (a.timestamp || "").localeCompare(b.timestamp || "")); + const chains: TodoTaskChain[] = []; + for (const range of interactionRanges(sorted)) { + const rangeObservations = sorted.slice(range.start, range.end); + if (!rangeObservations.length) continue; + const first = rangeObservations[0]; + const userObs = observationStartsInteraction(first) ? first : undefined; + const userText = normalizeText(userObs ? textForObservation(userObs) : ""); + const statusObs = latestStatusObservation(rangeObservations, userObs?.id); + if (!statusObs) continue; + if (userObs && isLowInfoUserPrompt(userText) && chains.length) { + chains[chains.length - 1] = mergeRangeIntoTaskChain(chains[chains.length - 1], userObs, statusObs, rangeObservations); + continue; + } + const chain = createTaskChain(session, userObs, statusObs, rangeObservations); + if (chain) chains.push(chain); + } + return chains.filter((chain) => chain.userIntent || chain.latestStatus); +} + +function taskChainForPrompt(chain: TodoTaskChain): Record { + return { + chainId: chain.chainId, + userObservationId: chain.userObservationId, + userIntent: chain.userIntent.slice(0, 1200), + latestStatusObservationId: chain.latestStatusObservationId, + latestStatus: chain.latestStatus.slice(0, 1200), + continuedByObservationIds: chain.continuedByObservationIds, + observationIds: chain.observationIds, + confidence: chain.confidence, + completionState: chain.completionState, + completionSummary: chain.completionSummary, + ...(chain.nextStep ? { nextStep: chain.nextStep } : {}), + dedupeKey: chain.dedupeKey, + }; +} + +function taskChainMetadata(chain: TodoTaskChain): Record { + return { + sourceSessionId: chain.sourceSessionId, + userObservationId: chain.userObservationId, + latestStatusObservationId: chain.latestStatusObservationId, + continuedByObservationIds: chain.continuedByObservationIds, + observationIds: chain.observationIds, + confidence: chain.confidence, + completionState: chain.completionState, + completionSummary: chain.completionSummary, + ...(chain.nextStep ? { nextStep: chain.nextStep } : {}), + dedupeKey: chain.dedupeKey, + }; +} + +function attachTaskChains(todos: ExtractedTodo[], chains: TodoTaskChain[]): ExtractedTodo[] { + if (!chains.length) return todos; + return todos.map((todo) => { + const sourceId = todo.evidence?.sourceObservationId; + const chain = chains.find((item) => sourceId && item.observationIds.includes(sourceId)) || (chains.length === 1 ? chains[0] : undefined); + return chain ? { ...todo, taskChain: chain } : todo; + }); +} + function nearbyObservationContext( observations: CompressedObservation[], sourceObservationId: string | undefined, @@ -765,7 +954,8 @@ function parseCheckpoint(cursor: string | undefined): Record { } function makeAction(todo: ExtractedTodo, session: Session, now: string): Action { - const quality = (todo as ExtractedTodo & { quality?: TodoQuality }).quality; + const quality = (todo as ExtractedTodoWithContext).quality; + const taskChain = (todo as ExtractedTodoWithContext).taskChain; const changed = sessionChangedSinceExtraction(todo, session); const tags = ["todo-extracted", `time:${todo.timeBucket}`, `type:${todo.typeBucket}`, ...(changed ? ["todo-recheck"] : [])]; return { @@ -784,12 +974,14 @@ function makeAction(todo: ExtractedTodo, session: Session, now: string): Action metadata: { todoExtraction: withSourceCheckpoint(todo, session), ...(quality ? { todoQuality: todoQualityMetadata(quality) } : {}), + ...(taskChain ? { todoChain: taskChainMetadata(taskChain) } : {}), }, }; } function makeReview(todo: ExtractedTodo, session: Session, now: string): ReviewQueueItem { - const quality = (todo as ExtractedTodo & { quality?: TodoQuality }).quality; + const quality = (todo as ExtractedTodoWithContext).quality; + const taskChain = (todo as ExtractedTodoWithContext).taskChain; const tags = ["todo-extracted", `time:${todo.timeBucket}`, `type:${todo.typeBucket}`]; return { id: generateId("review"), @@ -815,6 +1007,7 @@ function makeReview(todo: ExtractedTodo, session: Session, now: string): ReviewQ // session later changes (otherwise the review-update path is dead). todoExtraction: withSourceCheckpoint(todo, session), ...(quality ? { todoQuality: todoQualityMetadata(quality) } : {}), + ...(taskChain ? { todoChain: taskChainMetadata(taskChain) } : {}), }, }; } @@ -843,6 +1036,7 @@ function replaceActionFromTodo(action: Action, fresh: Action, engine: "langextra ...(action.metadata || {}), todoExtraction: fresh.metadata?.todoExtraction, ...(fresh.metadata?.todoQuality ? { todoQuality: fresh.metadata.todoQuality } : {}), + ...(fresh.metadata?.todoChain ? { todoChain: fresh.metadata.todoChain } : {}), refresh: { refreshedAt: now, engine, @@ -859,7 +1053,17 @@ function sessionChangedSinceExtraction(todo: ExtractedTodo, session: Session): b } function withSourceCheckpoint(todo: ExtractedTodo, session: Session): ExtractedTodo & { sourceCheckpoint: string } { - return { ...todo, sourceCheckpoint: checkpointKey(session) }; + return { + title: todo.title, + description: todo.description, + confidence: todo.confidence, + timeBucket: todo.timeBucket, + typeBucket: todo.typeBucket, + sourceSessionId: todo.sourceSessionId, + evidence: todo.evidence, + dedupeKey: todo.dedupeKey, + sourceCheckpoint: checkpointKey(session), + }; } async function markChangedGeneratedActions( @@ -1380,9 +1584,14 @@ async function extractForSession( options: ExtractForSessionOptions = {}, ): Promise<{ todos: ExtractedTodo[]; engine: "langextract" | "rules"; fallbackReason?: string }> { const { ruleObservations, llmObservations } = prefilterTodoObservations(session, observations); - const llmSourceObservations = options.forceLlmContext && !llmObservations.length - ? observations.slice(0, MAX_LLM_OBSERVATIONS_PER_SESSION) - : llmObservations; + const taskChains = options.taskChains ?? buildTaskChains(session, observations); + const chainObservationIds = new Set(taskChains.flatMap((chain) => chain.observationIds)); + const chainObservations = observations.filter((obs) => chainObservationIds.has(obs.id)); + const llmSourceObservations = taskChains.length + ? chainObservations.slice(0, MAX_LLM_OBSERVATIONS_PER_SESSION) + : options.forceLlmContext && !llmObservations.length + ? observations.slice(0, MAX_LLM_OBSERVATIONS_PER_SESSION) + : llmObservations; const blocks = llmSourceObservations.map(blockFor).filter((block) => block.text); const bucket = timeBucketFor(session); let fallbackReason = ""; @@ -1396,10 +1605,11 @@ async function extractForSession( startedAt: session.startedAt, endedAt: session.endedAt, blocks, + taskChains: taskChains.map(taskChainForPrompt), }; if (options.refreshAction) input.refreshAction = options.refreshAction; const todos = await run(input); - return { todos: todos.map((todo) => safeTodo(todo, session)), engine: "langextract" }; + return { todos: attachTaskChains(todos.map((todo) => safeTodo(todo, session)), taskChains), engine: "langextract" }; } catch (err) { fallbackReason = err instanceof Error ? err.message : String(err || "langextract failed"); // Fall back to rules even in langextract mode; the sidecar is optional. @@ -1736,10 +1946,14 @@ export async function generateTodosFromSessions( const observations = takeRecentInteractions(sortedObservations, maxInteractionsPerSession) .slice(-maxObservationsPerSession); const { ruleObservations, llmObservations } = prefilterTodoObservations(session, observations); + const taskChains = buildTaskChains(session, observations); scannedObservations += ruleObservations.length; const evidenceObservations = mode === "rules" ? ruleObservations : llmObservations.length ? llmObservations : ruleObservations; - const blockMap = new Map(evidenceObservations.map((obs) => [obs.id, blockFor(obs)])); - const { todos, engine, fallbackReason } = await extractForSession(session, ruleObservations, mode); + const blockMap = new Map([...observations, ...evidenceObservations].map((obs) => [obs.id, blockFor(obs)])); + const { todos, engine, fallbackReason } = await extractForSession(session, observations, mode, { + runLangExtractSidecar: data.runLangExtractSidecar, + taskChains, + }); engines.add(engine); if (fallbackReason) fallbackReasons.add(fallbackReason.slice(0, 240)); for (const rawTodo of todos) { diff --git a/src/viewer/index.html b/src/viewer/index.html index 5f82501d..7bb7ce8f 100644 --- a/src/viewer/index.html +++ b/src/viewer/index.html @@ -4259,6 +4259,8 @@

AI Todo

'act.section.earlierLead': 'Open work from the last 3-10 days is folded by default.', 'act.section.older': 'Older backlog', 'act.section.olderLead': 'These items are 10+ days old and may be stale.', + 'act.section.legacy': 'Legacy extraction backlog', + 'act.section.legacyLead': 'Cards from the old extraction path are hidden by default; search still finds them.', 'act.section.expand': 'Expand', 'act.section.collapse': 'Collapse', 'act.focus.label': 'Focus', @@ -4485,6 +4487,8 @@

AI Todo

'act.section.earlierLead': '3-10 天内的开放事项默认折叠,按需展开核对。', 'act.section.older': '陈旧积压', 'act.section.olderLead': '这些事项已超过 10 天,可能已经过期。', + 'act.section.legacy': '旧版抽取积压', + 'act.section.legacyLead': '这些卡片来自旧抽取链路,默认隐藏;搜索时仍可找到。', 'act.section.expand': '展开', 'act.section.collapse': '收起', 'act.focus.label': '聚焦', @@ -8850,6 +8854,8 @@

AI Todo

var frontier = (results[1] && (results[1].frontier || results[1].actions)) || []; state.actions.items = explicitActions; + state.actions.todoExtract = results[0] && results[0].todoExtract; + syncTodoExtractStatus(); state.actions.reviewItems = []; state.actions.frontier = frontier; state.actions.loaded = true; @@ -8889,12 +8895,55 @@

AI Todo

return !!result && (result.engine === 'langextract' || result.engine === 'mixed') && !result.llmFallback; } + function clearTodoExtractStatusPoll() { + if (!state.actions.extractStatusPollTimer) return; + clearTimeout(state.actions.extractStatusPollTimer); + state.actions.extractStatusPollTimer = null; + } + + function scheduleTodoExtractStatusPoll() { + if (state.actions.extractStatusPollTimer) return; + state.actions.extractStatusPollTimer = setTimeout(function() { + state.actions.extractStatusPollTimer = null; + if (!state.actions.extractInFlight) return; + refreshActionListsAfterExtract().then(function() { + if (state.activeTab === 'actions' && !actionsScrolledAway()) renderActions(); + else if (state.activeTab === 'actions') state.actions.stale = true; + }).catch(function() { + if (state.actions.extractInFlight) scheduleTodoExtractStatusPoll(); + }); + }, 3000); + } + + function syncTodoExtractStatus() { + var st = state.actions.todoExtract || {}; + if (st.status === 'running') { + state.actions.extractInFlight = true; + state.actions.extractStatus = 'running'; + state.actions.extractMessage = t('act.extract.background'); + scheduleTodoExtractStatusPoll(); + return; + } + clearTodoExtractStatusPoll(); + if (state.actions.extractInFlight && st.status !== 'running') state.actions.extractInFlight = false; + if (st.status === 'done' && st.summary) { + state.actions.extractStatus = 'done'; + state.actions.extractFallback = !todoExtractionUsedLlm(st.summary); + state.actions.extractMessage = todoExtractionSummary(st.summary); + } else if (st.status === 'error') { + state.actions.extractStatus = 'error'; + state.actions.extractMessage = st.error || t('act.extract.failedExisting'); + } + } + function refreshActionListsAfterExtract() { return Promise.all([ apiGet('actions'), apiGet('frontier') ]).then(function(results) { state.actions.items = (results[0] && results[0].actions) || state.actions.items || []; + state.actions.todoExtract = results[0] && results[0].todoExtract; + syncTodoExtractStatus(); state.actions.frontier = (results[1] && (results[1].frontier || results[1].actions)) || state.actions.frontier || []; state.actions.reviewItems = []; return null; @@ -9663,7 +9712,9 @@

AI Todo

if (search) { items = items.filter(function(a) { - return (a.title + ' ' + (a.description || '') + ' ' + (a.tags || []).join(' ') + ' ' + (a.project || '')).toLowerCase().indexOf(search) >= 0; + var chain = (a && a.metadata && a.metadata.todoChain) || {}; + return (a.title + ' ' + (a.description || '') + ' ' + (a.tags || []).join(' ') + ' ' + (a.project || '') + ' ' + + (chain.completionSummary || '') + ' ' + (chain.latestStatus || '') + ' ' + (chain.nextStep || '')).toLowerCase().indexOf(search) >= 0; }); } var metricItems = items.slice(); @@ -9702,6 +9753,16 @@

AI Todo

} return s; } + function actionChainStatusText(a) { + var chain = (a && a.metadata && a.metadata.todoChain) || null; + if (!chain || typeof chain !== 'object') return ''; + var summary = String(chain.completionSummary || chain.latestStatus || chain.nextStep || '').trim(); + if (!summary) return ''; + summary = todoDisplayText(summary); + var state = String(chain.completionState || '').trim(); + var prefix = state === 'completed' ? '✓ ' : (state === 'interrupted' ? '⏸ ' : '→ '); + return prefix + summary; + } function todoDisplayText(text) { return String(text || '') .replace(/Needs attention|Needs your reply|Needs confirmation|Needs follow-up|In progress|Follow up|To confirm|Reply queue|Reply/gi, 'Todo') @@ -9832,10 +9893,28 @@

AI Todo

function isOpenAction(a) { return a && (a.status === 'active' || a.status === 'blocked' || a.status === 'pending'); } + function isNoisyChainOpenAction(a) { + var chain = (a && a.metadata && a.metadata.todoChain) || null; + if (!isOpenAction(a) || !chain) return false; + var statusText = String(chain.completionSummary || '') + ' ' + String(chain.latestStatus || ''); + var text = statusText + ' ' + String(a.description || ''); + var statusHasNextStep = /(下一步|仍需|还需|需要继续|需要|待处理|待确认|待跟进|blocked|阻塞|失败|卡住|error|failed)/i.test(statusText); + if (chain.completionState === 'completed') return true; + if (/|#\s*Plan Mode\b|#\s*Agent Mode\b/i.test(text)) return true; + if (/已(?:完成|提交并推送|创建|通过|合并|上传|重启|新建)|服务已重启|正常|无需后续|no action needed|completed/i.test(statusText) && !statusHasNextStep) return true; + return false; + } + function isLegacyGeneratedOpenAction(a) { + return isOpenAction(a) && a.createdBy === 'todo-extract' && !(a.metadata && a.metadata.todoChain); + } function splitDefaultOpenItems(list) { - var split = { focus: [], earlier: [], older: [] }; + var split = { focus: [], earlier: [], older: [], legacy: [] }; (list || []).forEach(function(a) { if (!isOpenAction(a)) return; + if (isLegacyGeneratedOpenAction(a) || isNoisyChainOpenAction(a)) { + split.legacy.push(a); + return; + } var age = actionAgeDays(a); if (age <= FOCUS_DAYS || (actionNeedsRecheck(a) && age <= STALE_DAYS)) split.focus.push(a); else if (age > STALE_DAYS) split.older.push(a); @@ -9867,7 +9946,7 @@

AI Todo

html += '
'; html += '
'; html += '
' + esc(compactActionTitle(a.title)) + '
'; - var actionDesc = actionDescriptionText(a.description); + var actionDesc = actionChainStatusText(a) || actionDescriptionText(a.description); if (actionDesc) html += '
' + esc(truncate(actionDesc, 120)) + '
'; if (actionNeedsRecheck(a)) html += '
' + esc(t('act.recheck')) + '
'; // STEP-16 calm card: source + relative time are hidden at rest and fade in @@ -9957,12 +10036,13 @@

AI Todo

// (statusFilter==='done')时则照常全列,不走折叠区。 // STEP-12:cancelled(归档/被更新丢弃/被合并)也不进默认活动视图——否则 // 合并/丢弃后卡片仍以「已取消」分组留在原处,看着像「没生效」。 - if (defaultView) { + if (defaultView || (todoFilterActive && !search)) { var defaultSplit = splitDefaultOpenItems(items); html += renderTodoGroup(defaultSplit.focus); html += renderFoldedOpenSection(defaultSplit.earlier, t('act.section.earlier'), t('act.section.earlierLead'), 'earlierOpenExpanded', 'toggle-earlier-open'); html += renderFoldedOpenSection(defaultSplit.older, t('act.section.older'), t('act.section.olderLead'), 'olderBacklogExpanded', 'toggle-older-backlog'); - html += renderDoneTodaySection(items.filter(function(a) { return a.status === 'done'; }), frontierIds, renderActionCard); + html += renderFoldedOpenSection(defaultSplit.legacy, t('act.section.legacy'), t('act.section.legacyLead'), 'legacyBacklogExpanded', 'toggle-legacy-backlog'); + if (defaultView) html += renderDoneTodaySection(items.filter(function(a) { return a.status === 'done'; }), frontierIds, renderActionCard); } else { if (statusFilter !== 'done') { html += renderTodoGroup(items.filter(function(a) { return a.status === 'pending' || a.status === 'blocked' || a.status === 'active'; })); @@ -10793,6 +10873,11 @@

AI Todo

renderActions(); return; } + if (action === 'toggle-legacy-backlog') { + state.actions.legacyBacklogExpanded = !state.actions.legacyBacklogExpanded; + renderActions(); + return; + } if (action === 'toggle-briefings') { state.inbox.briefingExpanded = !state.inbox.briefingExpanded; renderActions(); diff --git a/src/viewer/parts/app/05-i18n.js b/src/viewer/parts/app/05-i18n.js index dfcdf520..fa99271e 100644 --- a/src/viewer/parts/app/05-i18n.js +++ b/src/viewer/parts/app/05-i18n.js @@ -58,6 +58,8 @@ 'act.section.earlierLead': 'Open work from the last 3-10 days is folded by default.', 'act.section.older': 'Older backlog', 'act.section.olderLead': 'These items are 10+ days old and may be stale.', + 'act.section.legacy': 'Legacy extraction backlog', + 'act.section.legacyLead': 'Cards from the old extraction path are hidden by default; search still finds them.', 'act.section.expand': 'Expand', 'act.section.collapse': 'Collapse', 'act.focus.label': 'Focus', @@ -284,6 +286,8 @@ 'act.section.earlierLead': '3-10 天内的开放事项默认折叠,按需展开核对。', 'act.section.older': '陈旧积压', 'act.section.olderLead': '这些事项已超过 10 天,可能已经过期。', + 'act.section.legacy': '旧版抽取积压', + 'act.section.legacyLead': '这些卡片来自旧抽取链路,默认隐藏;搜索时仍可找到。', 'act.section.expand': '展开', 'act.section.collapse': '收起', 'act.focus.label': '聚焦', diff --git a/src/viewer/parts/app/60-actions-todo.js b/src/viewer/parts/app/60-actions-todo.js index 5a44c46a..b37cc17c 100644 --- a/src/viewer/parts/app/60-actions-todo.js +++ b/src/viewer/parts/app/60-actions-todo.js @@ -13,6 +13,8 @@ var frontier = (results[1] && (results[1].frontier || results[1].actions)) || []; state.actions.items = explicitActions; + state.actions.todoExtract = results[0] && results[0].todoExtract; + syncTodoExtractStatus(); state.actions.reviewItems = []; state.actions.frontier = frontier; state.actions.loaded = true; @@ -52,12 +54,55 @@ return !!result && (result.engine === 'langextract' || result.engine === 'mixed') && !result.llmFallback; } + function clearTodoExtractStatusPoll() { + if (!state.actions.extractStatusPollTimer) return; + clearTimeout(state.actions.extractStatusPollTimer); + state.actions.extractStatusPollTimer = null; + } + + function scheduleTodoExtractStatusPoll() { + if (state.actions.extractStatusPollTimer) return; + state.actions.extractStatusPollTimer = setTimeout(function() { + state.actions.extractStatusPollTimer = null; + if (!state.actions.extractInFlight) return; + refreshActionListsAfterExtract().then(function() { + if (state.activeTab === 'actions' && !actionsScrolledAway()) renderActions(); + else if (state.activeTab === 'actions') state.actions.stale = true; + }).catch(function() { + if (state.actions.extractInFlight) scheduleTodoExtractStatusPoll(); + }); + }, 3000); + } + + function syncTodoExtractStatus() { + var st = state.actions.todoExtract || {}; + if (st.status === 'running') { + state.actions.extractInFlight = true; + state.actions.extractStatus = 'running'; + state.actions.extractMessage = t('act.extract.background'); + scheduleTodoExtractStatusPoll(); + return; + } + clearTodoExtractStatusPoll(); + if (state.actions.extractInFlight && st.status !== 'running') state.actions.extractInFlight = false; + if (st.status === 'done' && st.summary) { + state.actions.extractStatus = 'done'; + state.actions.extractFallback = !todoExtractionUsedLlm(st.summary); + state.actions.extractMessage = todoExtractionSummary(st.summary); + } else if (st.status === 'error') { + state.actions.extractStatus = 'error'; + state.actions.extractMessage = st.error || t('act.extract.failedExisting'); + } + } + function refreshActionListsAfterExtract() { return Promise.all([ apiGet('actions'), apiGet('frontier') ]).then(function(results) { state.actions.items = (results[0] && results[0].actions) || state.actions.items || []; + state.actions.todoExtract = results[0] && results[0].todoExtract; + syncTodoExtractStatus(); state.actions.frontier = (results[1] && (results[1].frontier || results[1].actions)) || state.actions.frontier || []; state.actions.reviewItems = []; return null; @@ -826,7 +871,9 @@ if (search) { items = items.filter(function(a) { - return (a.title + ' ' + (a.description || '') + ' ' + (a.tags || []).join(' ') + ' ' + (a.project || '')).toLowerCase().indexOf(search) >= 0; + var chain = (a && a.metadata && a.metadata.todoChain) || {}; + return (a.title + ' ' + (a.description || '') + ' ' + (a.tags || []).join(' ') + ' ' + (a.project || '') + ' ' + + (chain.completionSummary || '') + ' ' + (chain.latestStatus || '') + ' ' + (chain.nextStep || '')).toLowerCase().indexOf(search) >= 0; }); } var metricItems = items.slice(); @@ -865,6 +912,16 @@ } return s; } + function actionChainStatusText(a) { + var chain = (a && a.metadata && a.metadata.todoChain) || null; + if (!chain || typeof chain !== 'object') return ''; + var summary = String(chain.completionSummary || chain.latestStatus || chain.nextStep || '').trim(); + if (!summary) return ''; + summary = todoDisplayText(summary); + var state = String(chain.completionState || '').trim(); + var prefix = state === 'completed' ? '✓ ' : (state === 'interrupted' ? '⏸ ' : '→ '); + return prefix + summary; + } function todoDisplayText(text) { return String(text || '') .replace(/Needs attention|Needs your reply|Needs confirmation|Needs follow-up|In progress|Follow up|To confirm|Reply queue|Reply/gi, 'Todo') @@ -995,10 +1052,28 @@ function isOpenAction(a) { return a && (a.status === 'active' || a.status === 'blocked' || a.status === 'pending'); } + function isNoisyChainOpenAction(a) { + var chain = (a && a.metadata && a.metadata.todoChain) || null; + if (!isOpenAction(a) || !chain) return false; + var statusText = String(chain.completionSummary || '') + ' ' + String(chain.latestStatus || ''); + var text = statusText + ' ' + String(a.description || ''); + var statusHasNextStep = /(下一步|仍需|还需|需要继续|需要|待处理|待确认|待跟进|blocked|阻塞|失败|卡住|error|failed)/i.test(statusText); + if (chain.completionState === 'completed') return true; + if (/|#\s*Plan Mode\b|#\s*Agent Mode\b/i.test(text)) return true; + if (/已(?:完成|提交并推送|创建|通过|合并|上传|重启|新建)|服务已重启|正常|无需后续|no action needed|completed/i.test(statusText) && !statusHasNextStep) return true; + return false; + } + function isLegacyGeneratedOpenAction(a) { + return isOpenAction(a) && a.createdBy === 'todo-extract' && !(a.metadata && a.metadata.todoChain); + } function splitDefaultOpenItems(list) { - var split = { focus: [], earlier: [], older: [] }; + var split = { focus: [], earlier: [], older: [], legacy: [] }; (list || []).forEach(function(a) { if (!isOpenAction(a)) return; + if (isLegacyGeneratedOpenAction(a) || isNoisyChainOpenAction(a)) { + split.legacy.push(a); + return; + } var age = actionAgeDays(a); if (age <= FOCUS_DAYS || (actionNeedsRecheck(a) && age <= STALE_DAYS)) split.focus.push(a); else if (age > STALE_DAYS) split.older.push(a); @@ -1030,7 +1105,7 @@ html += '
'; html += '
'; html += '
' + esc(compactActionTitle(a.title)) + '
'; - var actionDesc = actionDescriptionText(a.description); + var actionDesc = actionChainStatusText(a) || actionDescriptionText(a.description); if (actionDesc) html += '
' + esc(truncate(actionDesc, 120)) + '
'; if (actionNeedsRecheck(a)) html += '
' + esc(t('act.recheck')) + '
'; // STEP-16 calm card: source + relative time are hidden at rest and fade in @@ -1120,12 +1195,13 @@ // (statusFilter==='done')时则照常全列,不走折叠区。 // STEP-12:cancelled(归档/被更新丢弃/被合并)也不进默认活动视图——否则 // 合并/丢弃后卡片仍以「已取消」分组留在原处,看着像「没生效」。 - if (defaultView) { + if (defaultView || (todoFilterActive && !search)) { var defaultSplit = splitDefaultOpenItems(items); html += renderTodoGroup(defaultSplit.focus); html += renderFoldedOpenSection(defaultSplit.earlier, t('act.section.earlier'), t('act.section.earlierLead'), 'earlierOpenExpanded', 'toggle-earlier-open'); html += renderFoldedOpenSection(defaultSplit.older, t('act.section.older'), t('act.section.olderLead'), 'olderBacklogExpanded', 'toggle-older-backlog'); - html += renderDoneTodaySection(items.filter(function(a) { return a.status === 'done'; }), frontierIds, renderActionCard); + html += renderFoldedOpenSection(defaultSplit.legacy, t('act.section.legacy'), t('act.section.legacyLead'), 'legacyBacklogExpanded', 'toggle-legacy-backlog'); + if (defaultView) html += renderDoneTodaySection(items.filter(function(a) { return a.status === 'done'; }), frontierIds, renderActionCard); } else { if (statusFilter !== 'done') { html += renderTodoGroup(items.filter(function(a) { return a.status === 'pending' || a.status === 'blocked' || a.status === 'active'; })); diff --git a/src/viewer/parts/app/64-ws-flags-events.js b/src/viewer/parts/app/64-ws-flags-events.js index b9c18563..9bd0ccb9 100644 --- a/src/viewer/parts/app/64-ws-flags-events.js +++ b/src/viewer/parts/app/64-ws-flags-events.js @@ -476,6 +476,11 @@ renderActions(); return; } + if (action === 'toggle-legacy-backlog') { + state.actions.legacyBacklogExpanded = !state.actions.legacyBacklogExpanded; + renderActions(); + return; + } if (action === 'toggle-briefings') { state.inbox.briefingExpanded = !state.inbox.briefingExpanded; renderActions(); diff --git a/src/viewer/server.ts b/src/viewer/server.ts index 75263128..93a6566f 100644 --- a/src/viewer/server.ts +++ b/src/viewer/server.ts @@ -615,10 +615,49 @@ type ViewerKv = { delete(scope: string, key: string): Promise; }; +const TODO_EXTRACT_STATUS_ID = "todo-extract:status"; + +type TodoExtractStatus = { + status: "idle" | "running" | "done" | "error"; + startedAt?: string; + finishedAt?: string; + summary?: Record; + error?: string; +}; + function asText(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } +async function readTodoExtractStatus(kv: ViewerKv): Promise { + const item = await kv.get<{ cursor?: string }>(KV.scanCheckpoints, TODO_EXTRACT_STATUS_ID).catch(() => null); + try { + const parsed = item?.cursor ? JSON.parse(item.cursor) as TodoExtractStatus : null; + if (!parsed || typeof parsed !== "object" || !parsed.status) return { status: "idle" }; + if (parsed.status !== "running") return parsed; + const startedAt = parsed.startedAt ? Date.parse(parsed.startedAt) : NaN; + const maxAgeMs = proxyTimeoutMsForPath("/agentmemory/todo-extract/generate") + 30_000; + if (!Number.isFinite(startedAt) || Date.now() - startedAt <= maxAgeMs) return parsed; + return { + status: "error", + startedAt: parsed.startedAt, + finishedAt: new Date().toISOString(), + error: "Previous todo organization did not finish; run it again.", + }; + } catch { + return { status: "idle" }; + } +} + +async function writeTodoExtractStatus(kv: ViewerKv, status: TodoExtractStatus): Promise { + await kv.set(KV.scanCheckpoints, TODO_EXTRACT_STATUS_ID, { + sourceId: TODO_EXTRACT_STATUS_ID, + cursor: JSON.stringify(status), + ...(status.status === "done" ? { lastSuccessAt: status.finishedAt } : {}), + ...(status.status === "error" ? { lastError: status.error || "todo extraction failed" } : {}), + }); +} + function stableHash(input: string): string { return createHash("sha256").update(input).digest("hex").slice(0, 12); } @@ -1345,11 +1384,12 @@ export function startViewerServer( const params = parseViewerQuery(qs); const limit = Math.max(1, Math.min(200, parseInt(params.limit || "50", 10) || 50)); let actions = await (kv as ViewerKv).list(KV.actions); + const todoExtract = await readTodoExtractStatus(kv as ViewerKv); if (params.status) actions = actions.filter((action) => action.status === params.status); if (params.project) actions = actions.filter((action) => action.project === params.project); if (params.parentId) actions = actions.filter((action) => action.parentId === params.parentId); actions.sort((a, b) => (b.updatedAt || "").localeCompare(a.updatedAt || "")); - json(res, 200, { success: true, actions: actions.slice(0, limit) }, req); + json(res, 200, { success: true, actions: actions.slice(0, limit), todoExtract }, req); return; } @@ -1382,8 +1422,26 @@ export function startViewerServer( if (method === "POST" && pathname === "/agentmemory/todo-extract/generate") { const raw = await readBody(req); const body = raw ? JSON.parse(raw) as Record : {}; - const result = await generateTodosFromSessions(kv as ViewerKv, body); - json(res, 200, result, req); + const startedAt = new Date().toISOString(); + await writeTodoExtractStatus(kv as ViewerKv, { status: "running", startedAt }); + try { + const result = await generateTodosFromSessions(kv as ViewerKv, body); + await writeTodoExtractStatus(kv as ViewerKv, { + status: "done", + startedAt, + finishedAt: new Date().toISOString(), + summary: result as unknown as Record, + }); + json(res, 200, result, req); + } catch (err) { + await writeTodoExtractStatus(kv as ViewerKv, { + status: "error", + startedAt, + finishedAt: new Date().toISOString(), + error: err instanceof Error ? err.message : String(err), + }); + throw err; + } return; } diff --git a/test/todo-extract.test.ts b/test/todo-extract.test.ts index 0a01ffa0..f083defb 100644 --- a/test/todo-extract.test.ts +++ b/test/todo-extract.test.ts @@ -237,6 +237,110 @@ describe("todo extraction", () => { expect(await kv.list(KV.reviewQueue)).toHaveLength(0); }); + it("uses session task chains so continue/retry updates the prior user task instead of creating a new card", async () => { + process.env.AGENTMEMORY_TODO_EXTRACTOR = "langextract"; + await kv.set(KV.sessions, "ses_1", session({ status: "active", observationCount: 4 })); + await kv.set(KV.observations("ses_1"), "u1", obs({ + id: "u1", + title: "prompt_submit", + narrative: "帮我把全量更新和单卡更新按钮的问题修掉。", + timestamp: "2026-06-17T08:00:00.000Z", + })); + await kv.set(KV.observations("ses_1"), "a1", obs({ + id: "a1", + title: "stop", + narrative: "全量更新会秒变已更新,单卡更新会长时间后提示没有更好卡片,后续需要调试按钮状态链路。", + timestamp: "2026-06-17T08:01:00.000Z", + })); + await kv.set(KV.observations("ses_1"), "u2", obs({ + id: "u2", + title: "prompt_submit", + narrative: "继续", + timestamp: "2026-06-17T08:02:00.000Z", + })); + await kv.set(KV.observations("ses_1"), "a2", obs({ + id: "a2", + title: "stop", + narrative: "已定位到全量更新直接返回、单卡刷新没有走 LLM;下一步需要修 sidecar 空负例。", + timestamp: "2026-06-17T08:03:00.000Z", + })); + + const seen: any[] = []; + const result = await generateTodosFromSessions(kv as never, { + force: true, + scanSources: false, + cleanup: "none", + runLangExtractSidecar: async (input: any) => { + seen.push(input); + return [{ + title: "修复全量更新和单卡更新按钮问题", + description: "下一步需要修 sidecar 空负例。", + confidence: 0.9, + timeBucket: "current", + typeBucket: "follow_up", + sourceSessionId: "ses_1", + evidence: { sourceObservationId: "a2", quote: "下一步需要修 sidecar 空负例" }, + dedupeKey: "fix-update-buttons", + }]; + }, + } as never); + delete process.env.AGENTMEMORY_TODO_EXTRACTOR; + + expect(result.directCreated).toBe(1); + expect(seen[0].taskChains).toHaveLength(1); + expect(seen[0].taskChains[0]).toMatchObject({ + userObservationId: "u1", + latestStatusObservationId: "a2", + continuedByObservationIds: ["u2"], + }); + const action = (await kv.list(KV.actions))[0]; + expect(action.title).toBe("修复全量更新和单卡更新按钮问题"); + expect(action.metadata?.todoChain).toMatchObject({ + sourceSessionId: "ses_1", + userObservationId: "u1", + latestStatusObservationId: "a2", + completionState: "in_progress", + }); + }); + + it("skips completed task chains with no next step", async () => { + process.env.AGENTMEMORY_TODO_EXTRACTOR = "langextract"; + await kv.set(KV.sessions, "ses_1", session({ status: "active", observationCount: 2 })); + await kv.set(KV.observations("ses_1"), "u1", obs({ + id: "u1", + title: "prompt_submit", + narrative: "把修改提交 PR。", + timestamp: "2026-06-17T08:00:00.000Z", + })); + await kv.set(KV.observations("ses_1"), "a1", obs({ + id: "a1", + title: "stop", + narrative: "PR 已创建,CI 已通过,无需后续处理。", + timestamp: "2026-06-17T08:01:00.000Z", + })); + + const result = await generateTodosFromSessions(kv as never, { + force: true, + scanSources: false, + cleanup: "none", + runLangExtractSidecar: async () => [{ + title: "提交 PR", + description: "PR 已创建,CI 已通过。", + confidence: 0.95, + timeBucket: "current", + typeBucket: "done", + sourceSessionId: "ses_1", + evidence: { sourceObservationId: "a1", quote: "PR 已创建,CI 已通过" }, + dedupeKey: "submit-pr", + }], + } as never); + delete process.env.AGENTMEMORY_TODO_EXTRACTOR; + + expect(result.directCreated).toBe(0); + expect(result.discarded).toBe(1); + expect(await kv.list(KV.actions)).toHaveLength(0); + }); + it("keeps history todos hidden instead of writing them to actions", async () => { await kv.set(KV.sessions, "ses_1", session({ startedAt: "2026-05-01T08:00:00.000Z", diff --git a/test/viewer-security.test.ts b/test/viewer-security.test.ts index 77341c1d..ee13fe0a 100644 --- a/test/viewer-security.test.ts +++ b/test/viewer-security.test.ts @@ -282,12 +282,45 @@ describe("viewer request handler DNS rebinding defence (e2e)", () => { const { port } = await spinUpViewer({ [KV.actions]: { [action.id]: action } }); const actions = await request(port, `localhost:${port}`, "/agentmemory/actions"); const frontier = await request(port, `localhost:${port}`, "/agentmemory/frontier"); + const actionsBody = JSON.parse(actions.body); expect(actions.status).toBe(200); - expect(JSON.parse(actions.body).actions[0].title).toBe("整理验收截图"); + expect(actionsBody.actions[0].title).toBe("整理验收截图"); + expect(actionsBody.todoExtract).toMatchObject({ status: "idle" }); expect(frontier.status).toBe(200); expect(JSON.parse(frontier.body).frontier[0].action.title).toBe("整理验收截图"); }); + it("expires stale todo extraction running status from viewer KV fallback", async () => { + const previousTimeout = process.env.AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS; + process.env.AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS = "1000"; + try { + const startedAt = new Date(Date.now() - 5 * 60 * 1000).toISOString(); + const { port } = await spinUpViewer({ + [KV.scanCheckpoints]: { + "todo-extract:status": { + sourceId: "todo-extract:status", + cursor: JSON.stringify({ status: "running", startedAt }), + }, + }, + }); + const actions = await request(port, `localhost:${port}`, "/agentmemory/actions"); + const actionsBody = JSON.parse(actions.body); + + expect(actions.status).toBe(200); + expect(actionsBody.todoExtract).toMatchObject({ + status: "error", + startedAt, + error: "Previous todo organization did not finish; run it again.", + }); + } finally { + if (previousTimeout === undefined) { + delete process.env.AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS; + } else { + process.env.AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS = previousTimeout; + } + } + }); + it("generates todos through the viewer fallback endpoint when REST proxy misses", async () => { const previousExtractor = process.env.AGENTMEMORY_TODO_EXTRACTOR; const previousDirectConfidence = process.env.AGENTMEMORY_TODO_DIRECT_CONFIDENCE; @@ -333,10 +366,12 @@ describe("viewer request handler DNS rebinding defence (e2e)", () => { JSON.stringify({ force: true, scanSources: false }), ); const actions = await request(port, `localhost:${port}`, "/agentmemory/actions"); + const actionsBody = JSON.parse(actions.body); expect(res.status).toBe(200); expect(JSON.parse(res.body)).toMatchObject({ success: true, directCreated: 1 }); - expect(JSON.parse(actions.body).actions[0]).toMatchObject({ + expect(actionsBody.todoExtract).toMatchObject({ status: "done" }); + expect(actionsBody.actions[0]).toMatchObject({ title: expect.any(String), tags: expect.arrayContaining(["todo-extracted", "time:current", "type:follow_up"]), sourceObservationIds: ["obs_todo"], diff --git a/test/viewer-session-id.test.ts b/test/viewer-session-id.test.ts index db5ccfab..374741ca 100644 --- a/test/viewer-session-id.test.ts +++ b/test/viewer-session-id.test.ts @@ -838,7 +838,7 @@ describe("viewer session rendering", () => { return { ok: true, json: async () => ({ frontier: [] }) }; } if (url.includes("actions")) { - return { ok: true, json: async () => ({ actions: [] }) }; + return { ok: true, json: async () => ({ actions: [], todoExtract: { status: "running", startedAt: "2026-06-25T02:00:00.000Z" } }) }; } return { ok: true, json: async () => ({}) }; }; @@ -866,6 +866,56 @@ describe("viewer session rendering", () => { expect(urls.some((url) => url.includes("todo-extract/generate"))).toBe(false); expect(urls.some((url) => url.includes("review/actions/generate"))).toBe(false); expect(posts).toHaveLength(0); + expect(sandbox.state.actions.extractInFlight).toBe(true); + expect(sandbox.state.actions.extractMessage).toBe("Latest todos are shown; still organizing..."); + }); + + it("polls persisted todo extraction state after a reload", async () => { + const { sandbox, runTimers } = loadViewerSandbox(); + let actionCalls = 0; + sandbox.fetch = async (input: unknown) => { + const url = String(input); + if (url.includes("frontier")) return { ok: true, json: async () => ({ frontier: [] }) }; + if (url.includes("actions")) { + actionCalls++; + return { + ok: true, + json: async () => actionCalls === 1 + ? { actions: [], todoExtract: { status: "running", startedAt: "2026-06-25T02:00:00.000Z" } } + : { + actions: [{ id: "act-1", title: "整理单卡刷新状态", status: "pending", updatedAt: "2026-06-25T02:02:00.000Z" }], + todoExtract: { + status: "done", + summary: { success: true, engine: "langextract", directCreated: 1, reviewCreated: 0, hiddenHistory: 0, discarded: 0 }, + }, + }, + }; + } + return { ok: true, json: async () => ({}) }; + }; + + sandbox.state.activeTab = "actions"; + sandbox.state.actions = { + loaded: true, + items: [], + frontier: [], + statusFilter: "", + search: "", + reviewItems: [], + extractStatus: "", + extractMessage: "", + extractInFlight: false, + }; + await sandbox.loadActions(); + await flushPromises(8); + + expect(sandbox.state.actions.extractInFlight).toBe(true); + expect(runTimers()).toBeGreaterThan(0); + await waitFor(() => sandbox.state.actions.items[0]?.title === "整理单卡刷新状态"); + + expect(sandbox.state.actions.extractInFlight).toBe(false); + expect(sandbox.state.actions.extractStatus).toBe("done"); + expect(sandbox.state.actions.items[0].title).toBe("整理单卡刷新状态"); }); it("runs LLM extraction only when explicitly requested", async () => { @@ -1368,17 +1418,20 @@ describe("viewer session rendering", () => { it("soft-refreshes actions while todo extraction is still running", async () => { const { sandbox, runTimers } = loadViewerSandbox(); - const actionResponses = [ - { actions: [] }, - { actions: [{ id: "act-1", title: "整理首版功能文档", status: "pending", updatedAt: "2026-06-17T12:00:00Z" }] }, - ]; + let actionCalls = 0; sandbox.fetch = async (input: unknown) => { const url = String(input); if (url.includes("todo-extract/generate")) return new Promise(() => undefined); if (url.includes("review?status=pending")) return { ok: true, json: async () => ({ items: [] }) }; if (url.includes("frontier")) return { ok: true, json: async () => ({ frontier: [] }) }; if (url.includes("actions")) { - return { ok: true, json: async () => actionResponses.shift() || { actions: [] } }; + actionCalls++; + return { + ok: true, + json: async () => actionCalls === 1 + ? { actions: [] } + : { actions: [{ id: "act-1", title: "整理首版功能文档", status: "pending", updatedAt: "2026-06-17T12:00:00Z" }], todoExtract: { status: "running" } }, + }; } return { ok: true, json: async () => ({}) }; }; @@ -1394,6 +1447,179 @@ describe("viewer session rendering", () => { expect(sandbox.state.actions.items[0].title).toBe("整理首版功能文档"); }); + it("folds legacy generated cards out of the default todo list", () => { + const { sandbox, getElement, dispatchDocumentClick } = loadViewerSandbox(); + sandbox.state.activeTab = "actions"; + sandbox.state.actions = { + loaded: true, + items: [ + { + id: "act_chain", + title: "修复整理按钮状态", + description: "后台整理中,刷新后仍需显示状态。", + status: "pending", + priority: "normal", + createdBy: "todo-extract", + tags: ["todo-extracted", "time:current", "type:follow_up"], + updatedAt: daysAgo(1), + metadata: { todoChain: { completionState: "in_progress", completionSummary: "后台整理中,刷新后仍需显示状态。" } }, + }, + { + id: "act_legacy", + title: "旧链路卡片", + description: "来自旧抽取链路。", + status: "pending", + priority: "normal", + createdBy: "todo-extract", + tags: ["todo-extracted", "time:current", "type:follow_up"], + updatedAt: daysAgo(1), + metadata: { todoExtraction: { sourceCheckpoint: `${daysAgo(1)}:1` } }, + }, + ], + frontier: [], + statusFilter: "", + search: "", + reviewItems: [], + }; + sandbox.state.inbox = { loaded: true, items: [] }; + + sandbox.renderActions(); + let html = getElement("view-actions").innerHTML; + expect(html).toContain("修复整理按钮状态"); + expect(html).toContain("Legacy extraction backlog"); + expect(html).not.toContain("旧链路卡片"); + + const target = Object.create(sandbox.Element.prototype); + target.getAttribute = (name: string) => name === "data-action" ? "toggle-legacy-backlog" : null; + target.closest = (selector: string) => selector === "[data-action]" ? target : null; + dispatchDocumentClick(target); + + html = getElement("view-actions").innerHTML; + expect(html).toContain("旧链路卡片"); + }); + + it("folds completed or system-context task chains out of the default todo list", () => { + const { sandbox, getElement } = loadViewerSandbox(); + sandbox.state.activeTab = "actions"; + sandbox.state.actions = { + loaded: true, + items: [ + { + id: "act_done_chain", + title: "创建 Day 1 专项复习文件", + description: "创建 Day 1 专项复习文件。", + status: "pending", + priority: "normal", + createdBy: "todo-extract", + tags: ["todo-extracted", "time:current", "type:to_start"], + updatedAt: daysAgo(1), + metadata: { todoChain: { completionState: "completed", completionSummary: "已完成 Day 1 专项复习资料。" } }, + }, + { + id: "act_system_chain", + title: "在 staging 前检查完整状态", + description: "staging 前查看完整状态。", + status: "pending", + priority: "normal", + createdBy: "todo-extract", + tags: ["todo-extracted", "time:current", "type:follow_up"], + updatedAt: daysAgo(1), + metadata: { todoChain: { completionState: "in_progress", completionSummary: "# Plan Mode" } }, + }, + { + id: "act_restarted_chain", + title: "重启服务验证页面状态", + description: "确认服务是否可访问。", + status: "pending", + priority: "normal", + createdBy: "todo-extract", + tags: ["todo-extracted", "time:current", "type:to_start"], + updatedAt: daysAgo(1), + metadata: { todoChain: { completionState: "in_progress", completionSummary: "服务已重启,http://localhost:3114/agentmemory/livez 正常。" } }, + }, + { + id: "act_copied_docx_chain", + title: "处理 DOCX 正文替换", + description: "需要继续读取当前 DOCX 的真实段落结构。", + status: "pending", + priority: "normal", + createdBy: "todo-extract", + tags: ["todo-extracted", "time:current", "type:to_start"], + updatedAt: daysAgo(1), + metadata: { todoChain: { completionState: "in_progress", completionSummary: "已完成,原文件未覆盖,已新建副本并只改正文文本内容。" } }, + }, + { + id: "act_real_followup", + title: "继续分析 DOCX 段落结构", + description: "需要继续读取当前 DOCX 的真实段落结构。", + status: "pending", + priority: "normal", + createdBy: "todo-extract", + tags: ["todo-extracted", "time:current", "type:follow_up"], + updatedAt: daysAgo(1), + metadata: { todoChain: { completionState: "in_progress", completionSummary: "需要继续读取当前 DOCX 的真实段落结构。" } }, + }, + ], + frontier: [], + statusFilter: "", + search: "", + reviewItems: [], + }; + sandbox.state.inbox = { loaded: true, items: [] }; + + sandbox.renderActions(); + const html = getElement("view-actions").innerHTML; + expect(html).toContain("Legacy extraction backlog"); + expect(html).toContain("继续分析 DOCX 段落结构"); + expect(html).not.toContain("创建 Day 1 专项复习文件"); + expect(html).not.toContain("在 staging 前检查完整状态"); + expect(html).not.toContain("重启服务验证页面状态"); + expect(html).not.toContain("处理 DOCX 正文替换"); + }); + + it("keeps the Todo filter focused on actionable cards", () => { + const { sandbox, getElement } = loadViewerSandbox(); + sandbox.state.activeTab = "actions"; + sandbox.state.actions = { + loaded: true, + items: [ + { + id: "act_open", + title: "修复整理按钮状态", + description: "后台整理中,刷新后仍需显示状态。", + status: "pending", + priority: "normal", + createdBy: "todo-extract", + tags: ["todo-extracted", "time:current", "type:follow_up"], + updatedAt: daysAgo(1), + metadata: { todoChain: { completionState: "in_progress", completionSummary: "后台整理中,刷新后仍需显示状态。" } }, + }, + { + id: "act_pushed", + title: "检查 PR 的 CI 结果", + description: "观察 PR 的新 CI 状态。", + status: "pending", + priority: "normal", + createdBy: "todo-extract", + tags: ["todo-extracted", "time:current", "type:follow_up"], + updatedAt: daysAgo(1), + metadata: { todoChain: { completionState: "in_progress", completionSummary: "已提交并推送 PR:#126 feat(todo): harden extraction job flow。" } }, + }, + ], + frontier: [], + statusFilter: "todo", + search: "", + reviewItems: [], + }; + sandbox.state.inbox = { loaded: true, items: [] }; + sandbox.renderActions(); + const html = getElement("view-actions").innerHTML; + + expect(html).toContain("修复整理按钮状态"); + expect(html).toContain("Legacy extraction backlog"); + expect(html).not.toContain("检查 PR 的 CI 结果"); + }); + it("renders only Todo and Done metrics and never shows awaiting as a todo class", () => { const { sandbox, getElement } = loadViewerSandbox(); sandbox.state.activeTab = "actions"; @@ -1623,7 +1849,46 @@ describe("viewer session rendering", () => { expect(html).not.toContain("type:to_start"); }); - it("hides generated command-log action cards from the todo view", () => { + it("shows task-chain completion as the card second line", () => { + const { sandbox, getElement } = loadViewerSandbox(); + sandbox.state.activeTab = "actions"; + sandbox.state.actions = { + loaded: true, + items: [ + { + id: "act_chain_1", + title: "上传之前的修改到远程仓库", + description: "旧描述不应优先展示。", + status: "pending", + priority: "normal", + tags: ["todo-extracted", "time:current", "type:follow_up"], + sourceObservationIds: ["obs_1"], + updatedAt: daysAgo(1), + metadata: { + todoChain: { + completionState: "in_progress", + completionSummary: "已推送分支,下一步创建 PR。", + nextStep: "创建 PR", + }, + }, + }, + ], + frontier: [], + statusFilter: "", + search: "", + reviewItems: [], + }; + sandbox.state.inbox = { loaded: true, items: [] }; + + sandbox.renderActions(); + const html = getElement("view-actions").innerHTML; + + expect(html).toContain("上传之前的修改到远程仓库"); + expect(html).toContain("→ 已推送分支,下一步创建 PR。"); + expect(html).not.toContain("旧描述不应优先展示"); + }); + + it("hides legacy generated cards from the default todo view", () => { const { sandbox, getElement } = loadViewerSandbox(); sandbox.state.activeTab = "actions"; sandbox.state.actions = { @@ -1646,6 +1911,7 @@ describe("viewer session rendering", () => { priority: "normal", createdBy: "todo-extract", tags: ["todo-extracted", "time:current", "type:to_start"], + metadata: { todoExtraction: { sourceCheckpoint: `${daysAgo(1)}:1` } }, }, ], frontier: [], @@ -1668,7 +1934,8 @@ describe("viewer session rendering", () => { sandbox.renderActions(); const html = getElement("view-actions").innerHTML; - expect(html).toContain("整理验收截图"); + expect(html).toContain("Legacy extraction backlog"); + expect(html).not.toContain("整理验收截图"); expect(html).not.toContain("json nameWithOwner"); expect(html).not.toContain("limit 20"); });