From 2d8e51e2dc9c11afe68c2e9a7d1a2dce0fb6fcda Mon Sep 17 00:00:00 2001 From: zhuqingyv Date: Mon, 13 Jul 2026 23:05:31 +0800 Subject: [PATCH 1/5] fix: make startup responsive and file reads provable --- servers/fs-mcp.ts | 176 ++++++++++-- src/agent.ts | 24 +- src/agent/completion-obligations.ts | 92 +++++- src/agent/file-read-ledger.ts | 268 ++++++++++++++++++ src/agent/tool-executor.ts | 43 ++- src/cli/App.tsx | 14 +- src/cli/StartupCoordinator.tsx | 164 +++++++++++ src/cli/components/InputBox.tsx | 44 +-- src/cli/components/StatusBar.tsx | 3 + src/cli/index.tsx | 43 ++- src/index.ts | 166 ++++++----- src/mcp/client.ts | 22 +- src/session/store.ts | 1 + test/agent-completion-obligations.test.ts | 151 +++++++++- .../reports/issue42-startup-shell.json | 90 ++++++ test/bootstrap-startup.test.ts | 90 ++++++ test/cli-ux.test.ts | 164 ++++++++++- test/e2e-real.ts | 63 ++++ test/e2e/helpers/agent-runner.ts | 4 +- test/file-read-ledger.test.ts | 143 ++++++++++ test/fs-mcp.test.ts | 146 +++++++++- test/tool-executor.test.ts | 61 ++++ test/visual/screenshot.test.ts | 8 +- test/visual/server.ts | 4 +- 24 files changed, 1800 insertions(+), 184 deletions(-) create mode 100644 src/agent/file-read-ledger.ts create mode 100644 src/cli/StartupCoordinator.tsx create mode 100644 test/benchmark/reports/issue42-startup-shell.json create mode 100644 test/bootstrap-startup.test.ts create mode 100644 test/file-read-ledger.test.ts diff --git a/servers/fs-mcp.ts b/servers/fs-mcp.ts index 56875a1..6ef03ac 100644 --- a/servers/fs-mcp.ts +++ b/servers/fs-mcp.ts @@ -1,5 +1,5 @@ import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, lstatSync, readlinkSync, existsSync, openSync, readSync, closeSync } from 'node:fs'; -import { dirname, extname, join, relative } from 'node:path'; +import { dirname, extname, join, relative, resolve } from 'node:path'; import { createInterface } from 'node:readline'; import { createHash, randomUUID } from 'node:crypto'; import pico from 'picocolors'; @@ -9,6 +9,7 @@ interface Req { jsonrpc: '2.0'; id?: Id; method: string; params?: unknown } interface Res { jsonrpc: '2.0'; id: Id; result?: unknown; error?: { code: number; message: string } } const MAX_FILE_BYTES = 256 * 1024; +const READ_PAGE_BODY_CHARS = 3200; const MAX_IMAGE_BYTES = 5 * 1024 * 1024; const IGNORED = new Set(['node_modules', '.git', 'dist']); const IMG_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.ico', '.svg']); @@ -18,8 +19,9 @@ const TOOLS = [ { name: 'read_file', description: '读取文件内容,带行号输出。支持 offset/limit 分段读取,二进制文件拒绝,大文件保护 256KB。', inputSchema: { type: 'object', required: ['path'], properties: { path: { type: 'string', description: '文件路径(必填)。例如: ./package.json' }, - offset: { type: 'number', description: '起始行(1-indexed,可选)' }, - limit: { type: 'number', description: '读取行数(可选)' } } } }, + offset: { type: ['number', 'string'], description: '起始行(1-indexed,可选;正整数字符串会规范化)' }, + limit: { type: ['number', 'string'], description: '最多读取行数(可选;仍受 3200 字符预算约束)' }, + cursor: { type: 'string', description: '超长单行续读位置,格式 line:column;与 offset 互斥' } } } }, { name: 'write_file', description: '写入文件,自动创建父目录。行尾统一为 LF。', inputSchema: { type: 'object', required: ['path', 'content'], properties: { path: { type: 'string' }, content: { type: 'string' } } } }, @@ -68,14 +70,108 @@ function isBinary(path: string): boolean { } catch { return false; } finally { if (fd !== null) try { closeSync(fd); } catch { /* */ } } } -function formatLines(s: string, offset: number, limit: number | null): string { - const all = s.split('\n'); - const start = Math.max(1, offset) - 1; - const end = limit !== null ? Math.min(all.length, start + limit) : all.length; - const width = String(end).length; - const out: string[] = []; - for (let i = start; i < end; i++) out.push(`${String(i + 1).padStart(width, ' ')}│${all[i]}`); - return out.join('\n'); +function invalidPagination(field: string, value: unknown, expected: string) { + return ok( + `read_file: invalid ${field}; expected ${expected}`, + true, + { error: { kind: 'invalid_pagination_argument', field, value, expected } }, + ); +} + +function positiveInteger(field: string, value: unknown): number | null | ReturnType { + if (value === undefined) return null; + const parsed = typeof value === 'string' && /^[1-9]\d*$/.test(value.trim()) + ? Number(value.trim()) + : value; + if (typeof parsed !== 'number' || !Number.isSafeInteger(parsed) || parsed <= 0) { + return invalidPagination(field, value, 'a positive integer'); + } + return parsed; +} + +function parseCursor(value: unknown): { line: number; column: number } | null | ReturnType { + if (value === undefined) return null; + if (typeof value !== 'string') return invalidPagination('cursor', value, 'line:column'); + const match = /^([1-9]\d*):(0|[1-9]\d*)$/.exec(value.trim()); + if (!match) return invalidPagination('cursor', value, 'line:column'); + const line = Number(match[1]); + const column = Number(match[2]); + if (!Number.isSafeInteger(line) || !Number.isSafeInteger(column)) { + return invalidPagination('cursor', value, 'safe integer line:column'); + } + return { line, column }; +} + +function isToolError(value: unknown): value is ReturnType { + return Boolean(value && typeof value === 'object' && 'isError' in value); +} + +interface ReadPage { + body: string; + startLine: number; + startColumn: number; + endLine: number; + endColumn: number; + complete: boolean; + nextOffset: number | null; + nextCursor: string | null; +} + +function buildReadPage( + lines: string[], + startLine: number, + startColumn: number, + limit: number | null, +): ReadPage { + const width = String(lines.length).length; + const fragments: string[] = []; + let bodyChars = 0; + let lineIndex = startLine - 1; + let column = startColumn; + let touchedLines = 0; + let endLine = startLine; + let endColumn = startColumn; + + while (lineIndex < lines.length && (limit === null || touchedLines < limit)) { + const lineNumber = lineIndex + 1; + const line = lines[lineIndex] ?? ''; + if (column > line.length) break; + const separatorChars = fragments.length > 0 ? 1 : 0; + const prefix = `${String(lineNumber).padStart(width, ' ')}│`; + const available = READ_PAGE_BODY_CHARS - bodyChars - separatorChars - prefix.length; + if (available < 0 || (available === 0 && line.length > column)) break; + const remaining = line.length - column; + // Keep ordinary source lines atomic. Column pagination is reserved for a + // line that cannot fit on an otherwise empty page (minified/generated code). + if (remaining > available && fragments.length > 0 && column === 0) break; + const consumed = Math.min(remaining, available); + const fragment = prefix + line.slice(column, column + consumed); + fragments.push(fragment); + bodyChars += separatorChars + fragment.length; + touchedLines++; + endLine = lineNumber; + endColumn = column + consumed; + + if (consumed < remaining) { + column += consumed; + break; + } + lineIndex++; + column = 0; + } + + const complete = lineIndex >= lines.length; + const nextCursor = complete ? null : `${lineIndex + 1}:${column}`; + return { + body: fragments.join('\n'), + startLine, + startColumn, + endLine, + endColumn, + complete, + nextOffset: complete || column !== 0 ? null : lineIndex + 1, + nextCursor, + }; } function fsErr(op: string, path: string, e: unknown) { @@ -89,30 +185,64 @@ function handleReadFile(args: Record) { if (typeof args.path !== 'string' || !args.path.trim()) return ok('Error: path parameter is required', true); const path = args.path.trim(); if (DEVICES.has(path)) return ok(`拒绝读取设备文件: ${path}`, true); - const offset = typeof args.offset === 'number' && args.offset > 0 ? Math.floor(args.offset) : 1; - const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.floor(args.limit) : null; + const parsedOffset = positiveInteger('offset', args.offset); + if (isToolError(parsedOffset)) return parsedOffset; + const parsedLimit = positiveInteger('limit', args.limit); + if (isToolError(parsedLimit)) return parsedLimit; + const parsedCursor = parseCursor(args.cursor); + if (isToolError(parsedCursor)) return parsedCursor; + if (parsedCursor && parsedOffset !== null) { + return invalidPagination('cursor', args.cursor, 'line:column without offset'); + } + const offset = parsedCursor?.line ?? parsedOffset ?? 1; + const startColumn = parsedCursor?.column ?? 0; + const limit = parsedLimit; const ext = extname(path).toLowerCase(); try { const st = statSync(path); if (st.isDirectory()) return ok(`不是文件(是目录): ${path}`, true); if (IMG_EXTS.has(ext)) return ok(`[图片文件] ${path}\n格式: ${mime(ext)}\n大小: ${Math.round(st.size / 1024)}KB\n提示: 这是图片文件,无法以文本形式读取。请使用 read_image 获取 base64 data URL。`); - if (st.size > MAX_FILE_BYTES && offset === 1 && limit === null) return ok(`文件过大(${Math.round(st.size / 1024)}KB),请指定 offset 和 limit 参数读取部分内容`, true); + const hasExplicitPagination = parsedOffset !== null || parsedLimit !== null || parsedCursor !== null; + if (st.size > MAX_FILE_BYTES && !hasExplicitPagination) { + return ok(`文件过大(${Math.round(st.size / 1024)}KB),请使用 offset/limit 或 cursor 分页读取`, true); + } if (isBinary(path)) return ok(`二进制文件,无法读取: ${path}`, true); const raw = readFileSync(path, 'utf-8'); const content = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw; const lines = content.split('\n'); - const start = Math.max(1, offset); - const end = limit === null ? lines.length : Math.min(lines.length, start + limit - 1); - const complete = end >= lines.length; - return ok(formatLines(content, offset, limit), false, { + if (offset > lines.length || startColumn > (lines[offset - 1]?.length ?? 0)) { + return invalidPagination('cursor', parsedCursor ? args.cursor : `${offset}:${startColumn}`, 'an in-range line:column'); + } + const page = buildReadPage(lines, offset, startColumn, limit); + const canonicalPath = resolve(path); + const hash = createHash('sha256').update(raw, 'utf8').digest('hex'); + const receipt = { + kind: 'read_file_page', + canonical_path: canonicalPath, + file_hash: hash, + cursor: `${page.startLine}:${page.startColumn}`, + start_line: page.startLine, + start_column: page.startColumn, + end_line: page.endLine, + end_column: page.endColumn, + total_lines: lines.length, + complete: page.complete, + next_offset: page.nextOffset, + next_cursor: page.nextCursor, + body_chars: page.body.length, + }; + const modelText = `${page.body}\n[read_file receipt] ${JSON.stringify(receipt)}`; + return ok(modelText, false, { offset, limit, totalLines: lines.length, - start, - end, - complete, - nextOffset: complete ? null : end + 1, - hash: createHash('sha256').update(raw, 'utf8').digest('hex'), + start: page.startLine, + end: page.endLine, + complete: page.complete, + nextOffset: page.nextOffset, + nextCursor: page.nextCursor, + hash, + read_file_page: receipt, }); } catch (e) { return fsErr('read_file', path, e); } } diff --git a/src/agent.ts b/src/agent.ts index 6d251ac..4cf9387 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -58,6 +58,8 @@ import { } from './agent/context-manager.js'; import { RuntimeContextSlotStore } from './agent/runtime-context-slots.js'; import { CompletionObligationAudit } from './agent/completion-obligations.js'; +import { FileReadLedger } from './agent/file-read-ledger.js'; +import { join } from 'node:path'; export interface CreateAgentOptions { resumeMessages?: ChatCompletionMessageParam[]; @@ -1035,6 +1037,11 @@ export async function createAgent( sessionStore?.getSessionDir() ); const runtimeSlots = new RuntimeContextSlotStore(); + const fileReadLedger = new FileReadLedger( + sessionStore && sessionId + ? join(sessionStore.getSessionDir(), `${sessionId}.reads.json`) + : undefined, + ); if (options.resumeMessages) { contextManager.ensureIndexed( options.resumeMessages.filter((m) => m.role !== 'system') @@ -1261,7 +1268,8 @@ export async function createAgent( config, connections, activeBuiltinTools, - { nextId: nextConfirmId, awaitApproval: awaitConfirm } + { nextId: nextConfirmId, awaitApproval: awaitConfirm }, + fileReadLedger, ); let openingContent: ChatCompletionUserMessageParam['content']; @@ -1509,7 +1517,8 @@ export async function createAgent( message: 'Model repeated prior truncated output after continuation; stopped automatic continuation to avoid runaway repetition.', }; if (!task.parentId) { - const completionDecision = completionAudit.inspectFinalAttempt(); + completionAudit.setFileReadCoverage(fileReadLedger.coverage()); + const completionDecision = completionAudit.inspectFinalAttempt(contentBuf || lastLengthContinuationContent); if (completionDecision.status === 'retry') { store.appendUser(completionDecision.message!); persistPending(); @@ -1564,7 +1573,8 @@ export async function createAgent( finalText = contentBuf || lastLengthContinuationContent; persistPending(); if (!task.parentId) { - const completionDecision = completionAudit.inspectFinalAttempt(); + completionAudit.setFileReadCoverage(fileReadLedger.coverage()); + const completionDecision = completionAudit.inspectFinalAttempt(contentBuf); if (completionDecision.status === 'retry') { store.appendUser(completionDecision.message!); persistPending(); @@ -1624,7 +1634,8 @@ export async function createAgent( continue; } if (!task.parentId && contentBuf.trim().length > 0) { - const completionDecision = completionAudit.inspectFinalAttempt(); + completionAudit.setFileReadCoverage(fileReadLedger.coverage()); + const completionDecision = completionAudit.inspectFinalAttempt(contentBuf); if (completionDecision.status === 'retry') { await completePendingZimoosOperationSummaries(contentBuf); store.appendAssistant(contentBuf, undefined, { reasoningContent }); @@ -1746,6 +1757,8 @@ export async function createAgent( isError, runtimeSlotUpdate, actionEvidence, + fileReadCoverage, + progressSummary, } = yield* toolExecutor.execute( tc, toolCtx, @@ -1757,6 +1770,7 @@ export async function createAgent( succeeded: !isError, verifiedAction: actionEvidence?.status === 'verified', }); + if (fileReadCoverage) completionAudit.setFileReadCoverage(fileReadCoverage); if (actionEvidence?.status === 'verified') { incompleteActionEvidence.delete(actionEvidence.key); } else if (actionEvidence) { @@ -1776,7 +1790,7 @@ export async function createAgent( persistProviderState(); store.appendToolResult(tc.id, result); errorTracker.record(fullName, args, isError); - const progress = recordToolProgress(fullName, args, !isError, result); + const progress = recordToolProgress(fullName, args, !isError, progressSummary ?? result); if (progress) yield progress; } // A local model can spend a truncated turn only updating its plan, then diff --git a/src/agent/completion-obligations.ts b/src/agent/completion-obligations.ts index a594610..b6ccfba 100644 --- a/src/agent/completion-obligations.ts +++ b/src/agent/completion-obligations.ts @@ -1,4 +1,6 @@ -export type CompletionObligationKind = 'test' | 'browser'; +import type { FileReadCoverage } from './file-read-ledger.js'; + +export type CompletionObligationKind = 'test' | 'browser' | 'file_read_coverage'; export interface CompletionToolEvidence { toolName: string; @@ -18,6 +20,7 @@ const MAX_COMPLETION_RETRIES = 2; const OBLIGATION_LABELS: Record = { test: '运行测试并取得成功结果', browser: '使用真实浏览器自动化完成验证', + file_read_coverage: '用 read_file receipt 证明目标文件已连续完整读取', }; function asksToRunTests(prompt: string): boolean { @@ -42,12 +45,51 @@ function asksForBrowserVerification(prompt: string): boolean { ].some((pattern) => pattern.test(prompt)); } +function asksForCompleteFileReading(prompt: string): boolean { + if ( + extractExplicitFileHints(prompt).length > 0 && + /(?:完整|全部|全量|逐行)\s*(?:阅读|读取|查看|审阅|检查)|\b(?:fully|completely)\s+(?:read|review|inspect)\b/i.test(prompt) + ) return true; + return [ + /(?:完整|全部|全量|逐行).{0,24}(?:阅读|读取|查看|审阅|检查).{0,24}(?:文件|源码|代码|项目)/i, + /(?:阅读|读取|查看|审阅|检查).{0,24}(?:完整|全部|全量).{0,24}(?:文件|源码|代码|项目)/i, + /\b(?:fully|completely)\s+(?:read|review|inspect)\b.{0,40}\b(?:file|source|code|project)/i, + /\b(?:read|review|inspect)\s+(?:all|every|the\s+entire)\b.{0,40}\b(?:file|source|code|project)/i, + ].some((pattern) => pattern.test(prompt)); +} + +function claimsCompleteFileReading(text: string): boolean { + return [ + /(?:已经|已|我已).{0,8}(?:完整|全部|全量).{0,16}(?:阅读|读取|查看|审阅|检查)/i, + /(?:完整|全部|全量).{0,12}(?:看过|读过|审阅完|检查完)/i, + /\b(?:fully|completely)\s+(?:read|reviewed|inspected)\b/i, + /\b(?:read|reviewed|inspected)\s+(?:all|every|the\s+entire)\b/i, + ].some((pattern) => pattern.test(text)); +} + +export function extractExplicitFileHints(prompt: string): string[] { + const withoutUrls = prompt.replace(/\b[a-z][a-z0-9+.-]*:\/\/\S+/gi, ' '); + const candidates = withoutUrls.match(/[A-Za-z0-9_@./\\:+-]{1,512}/g) ?? []; + const matches = candidates + .map((value) => value.replace(/\\/g, '/').replace(/^\.\//, '')) + .filter((value) => /^(?:[A-Za-z]:\/|\/)?(?:[A-Za-z0-9_@.+-]+\/)*[A-Za-z0-9_@+-]+\.[A-Za-z][A-Za-z0-9]{0,11}$/.test(value)); + return [...new Set(matches)]; +} + +function coverageMatchesHint(path: string, hint: string): boolean { + const normalizedPath = path.replace(/\\/g, '/'); + const normalizedHint = hint.replace(/\\/g, '/').replace(/^\.\//, ''); + if (hint.startsWith('/')) return normalizedPath === normalizedHint; + return normalizedPath === normalizedHint || normalizedPath.endsWith(`/${normalizedHint}`); +} + export function extractCompletionObligations( rootPrompt: string ): CompletionObligationKind[] { const obligations: CompletionObligationKind[] = []; if (asksToRunTests(rootPrompt)) obligations.push('test'); if (asksForBrowserVerification(rootPrompt)) obligations.push('browser'); + if (asksForCompleteFileReading(rootPrompt)) obligations.push('file_read_coverage'); return obligations; } @@ -99,11 +141,19 @@ function isBrowserAutomationTool(toolName: string): boolean { export class CompletionObligationAudit { private readonly required: Set; + private readonly explicitFileHints: string[]; private readonly completed = new Set(); private retryCount = 0; + private fileReadCoverage: FileReadCoverage = { + files: [], + trackedFiles: 0, + completeFiles: 0, + allComplete: false, + }; constructor(rootPrompt: string) { this.required = new Set(extractCompletionObligations(rootPrompt)); + this.explicitFileHints = extractExplicitFileHints(rootPrompt); } recordToolEvidence(evidence: CompletionToolEvidence): void { @@ -119,12 +169,26 @@ export class CompletionObligationAudit { if (isBrowserVerificationCommand(command)) this.completed.add('browser'); } - missing(): CompletionObligationKind[] { - return [...this.required].filter((kind) => !this.completed.has(kind)); + setFileReadCoverage(coverage: FileReadCoverage): void { + this.fileReadCoverage = coverage; + const allExplicitFilesComplete = this.explicitFileHints.every((hint) => + coverage.files.some((file) => file.complete && coverageMatchesHint(file.path, hint)), + ); + const targetCoverageComplete = this.explicitFileHints.length > 0 + ? allExplicitFilesComplete + : coverage.allComplete; + if (targetCoverageComplete) this.completed.add('file_read_coverage'); + else this.completed.delete('file_read_coverage'); } - inspectFinalAttempt(): CompletionAuditDecision { - const missing = this.missing(); + missing(candidateText = ''): CompletionObligationKind[] { + const required = new Set(this.required); + if (claimsCompleteFileReading(candidateText)) required.add('file_read_coverage'); + return [...required].filter((kind) => !this.completed.has(kind)); + } + + inspectFinalAttempt(candidateText = ''): CompletionAuditDecision { + const missing = this.missing(candidateText); if (missing.length === 0) return { status: 'complete', missing }; if (this.retryCount < MAX_COMPLETION_RETRIES) { @@ -145,11 +209,23 @@ export class CompletionObligationAudit { private buildRetryMessage(missing: CompletionObligationKind[]): string { const details = missing.map((kind) => OBLIGATION_LABELS[kind]).join(';'); + const absentHints = this.explicitFileHints.filter((hint) => + !this.fileReadCoverage.files.some((file) => file.complete && coverageMatchesHint(file.path, hint)), + ); + const partialFiles = this.fileReadCoverage.files + .filter((file) => !file.complete) + .map((file) => `${file.path} next_cursor=${file.nextCursor}`); + const unread = missing.includes('file_read_coverage') + ? this.fileReadCoverage.files.length === 0 + ? `当前没有任何可信 read_file page receipt。${absentHints.length > 0 ? ` 用户点名但尚未证明:${absentHints.join(';')}` : ''}` + : `未覆盖:${[...absentHints.map((hint) => `${hint} 尚无完整回执`), ...partialFiles].join(';')}` + : ''; return [ `[MA completion audit] 不能完成任务:仍缺少 ${details}。`, - '请现在直接调用工具补齐验证。测试必须由成功的测试命令证明;浏览器验证必须由真实浏览器自动化或明确的 Playwright/Puppeteer/browser verification 命令证明。', - 'web_fetch/HTTP 200、只写验证脚本、以及文字自述都不算完成证据。', - ].join('\n'); + unread, + '请现在直接调用工具补齐验证。测试必须由成功的测试命令证明;浏览器验证必须由真实浏览器自动化证明;完整读取必须由同一文件 hash 上从 1:0 连续到 EOF 的 read_file receipt 证明。', + 'exec cat/sed/head/tail、web_fetch/HTTP 200、只写验证脚本、以及文字自述都不算对应完成证据。', + ].filter(Boolean).join('\n'); } private buildFailureMessage(missing: CompletionObligationKind[]): string { diff --git a/src/agent/file-read-ledger.ts b/src/agent/file-read-ledger.ts new file mode 100644 index 0000000..4e40ba5 --- /dev/null +++ b/src/agent/file-read-ledger.ts @@ -0,0 +1,268 @@ +import { dirname } from 'node:path'; +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; + +export interface ReadFileReceipt { + kind: 'read_file_page'; + canonicalPath: string; + fileHash: string; + cursor: string; + startLine: number; + startColumn: number; + endLine: number; + endColumn: number; + totalLines: number; + complete: boolean; + nextOffset: number | null; + nextCursor: string | null; + bodyChars: number; +} + +interface StoredPage { + nextCursor: string | null; + nextOffset: number | null; + complete: boolean; + startLine: number; + endLine: number; + startColumn: number; + endColumn: number; +} + +interface StoredFile { + hash: string; + totalLines: number; + pages: Record; +} + +interface LedgerSnapshot { + version: 1; + files: Record; +} + +export interface FileReadCoverageFile { + path: string; + hash: string; + totalLines: number; + complete: boolean; + nextCursor: string | null; + pageCount: number; +} + +export interface FileReadCoverage { + files: FileReadCoverageFile[]; + trackedFiles: number; + completeFiles: number; + allComplete: boolean; +} + +export interface FileReadRecordResult { + receipt: ReadFileReceipt; + duplicate: boolean; + fileChanged: boolean; + coverage: FileReadCoverageFile; +} + +function finiteInteger(value: unknown, minimum = 0): number | null { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= minimum + ? value + : null; +} + +function nullableInteger(value: unknown): number | null | undefined { + if (value === null) return null; + const parsed = finiteInteger(value, 1); + return parsed === null ? undefined : parsed; +} + +function nullableCursor(value: unknown): string | null | undefined { + if (value === null) return null; + return typeof value === 'string' && /^[1-9]\d*:(?:0|[1-9]\d*)$/.test(value) + ? value + : undefined; +} + +export function parseReadFileReceipt( + structuredContent: Record | undefined, +): ReadFileReceipt | null { + const raw = structuredContent?.read_file_page; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; + const value = raw as Record; + const startLine = finiteInteger(value.start_line, 1); + const startColumn = finiteInteger(value.start_column, 0); + const endLine = finiteInteger(value.end_line, 1); + const endColumn = finiteInteger(value.end_column, 0); + const totalLines = finiteInteger(value.total_lines, 1); + const bodyChars = finiteInteger(value.body_chars, 0); + const nextOffset = nullableInteger(value.next_offset); + const nextCursor = nullableCursor(value.next_cursor); + if ( + value.kind !== 'read_file_page' || + typeof value.canonical_path !== 'string' || !value.canonical_path || + typeof value.file_hash !== 'string' || !/^[0-9a-f]{64}$/.test(value.file_hash) || + typeof value.cursor !== 'string' || !/^[1-9]\d*:(?:0|[1-9]\d*)$/.test(value.cursor) || + startLine === null || startColumn === null || endLine === null || endColumn === null || + totalLines === null || bodyChars === null || typeof value.complete !== 'boolean' || + nextOffset === undefined || nextCursor === undefined + ) return null; + const cursor = `${startLine}:${startColumn}`; + if (value.cursor !== cursor || startLine > endLine || endLine > totalLines) return null; + if (startLine === endLine && endColumn < startColumn) return null; + if (value.complete) { + if (endLine !== totalLines || nextOffset !== null || nextCursor !== null) return null; + } else { + if (nextCursor === null) return null; + const sameLineContinuation = `${endLine}:${endColumn}`; + const nextLine = endLine < totalLines ? `${endLine + 1}:0` : null; + if (nextCursor !== sameLineContinuation && nextCursor !== nextLine) return null; + if (nextCursor === cursor) return null; + const [nextLineNumber, nextColumn] = nextCursor.split(':').map(Number); + const expectedOffset = nextColumn === 0 ? nextLineNumber : null; + if (nextOffset !== expectedOffset) return null; + } + return { + kind: 'read_file_page', + canonicalPath: value.canonical_path, + fileHash: value.file_hash, + cursor: value.cursor, + startLine, + startColumn, + endLine, + endColumn, + totalLines, + complete: value.complete, + nextOffset, + nextCursor, + bodyChars, + }; +} + +export class FileReadLedger { + private snapshot: LedgerSnapshot = { version: 1, files: {} }; + + constructor(private readonly persistencePath?: string) { + this.load(); + } + + record(structuredContent: Record | undefined): FileReadRecordResult | null { + const receipt = parseReadFileReceipt(structuredContent); + if (!receipt) return null; + const previous = this.snapshot.files[receipt.canonicalPath]; + if (previous && previous.hash === receipt.fileHash && previous.totalLines !== receipt.totalLines) { + return null; + } + const fileChanged = Boolean(previous && previous.hash !== receipt.fileHash); + const file: StoredFile = !previous || fileChanged + ? { hash: receipt.fileHash, totalLines: receipt.totalLines, pages: {} } + : previous; + file.totalLines = receipt.totalLines; + const existing = file.pages[receipt.cursor]; + const duplicate = Boolean( + existing && + existing.nextCursor === receipt.nextCursor && + existing.nextOffset === receipt.nextOffset && + existing.complete === receipt.complete && + existing.startLine === receipt.startLine && + existing.startColumn === receipt.startColumn && + existing.endLine === receipt.endLine && + existing.endColumn === receipt.endColumn + ); + if (!duplicate) { + file.pages[receipt.cursor] = { + nextCursor: receipt.nextCursor, + nextOffset: receipt.nextOffset, + complete: receipt.complete, + startLine: receipt.startLine, + endLine: receipt.endLine, + startColumn: receipt.startColumn, + endColumn: receipt.endColumn, + }; + this.snapshot.files[receipt.canonicalPath] = file; + this.persist(); + } + return { + receipt, + duplicate, + fileChanged, + coverage: this.coverageFor(receipt.canonicalPath, file), + }; + } + + coverage(): FileReadCoverage { + const files = Object.entries(this.snapshot.files) + .map(([path, file]) => this.coverageFor(path, file)) + .sort((a, b) => a.path.localeCompare(b.path)); + const completeFiles = files.filter((file) => file.complete).length; + return { + files, + trackedFiles: files.length, + completeFiles, + allComplete: files.length > 0 && completeFiles === files.length, + }; + } + + private coverageFor(path: string, file: StoredFile): FileReadCoverageFile { + let cursor: string | null = '1:0'; + const visited = new Set(); + let complete = false; + while (cursor !== null && !visited.has(cursor)) { + visited.add(cursor); + const page: StoredPage | undefined = file.pages[cursor]; + if (!page) break; + if (page.complete && page.nextCursor === null) { + cursor = null; + complete = true; + break; + } + cursor = page.nextCursor; + } + return { + path, + hash: file.hash, + totalLines: file.totalLines, + complete, + nextCursor: complete ? null : cursor, + pageCount: Object.keys(file.pages).length, + }; + } + + private load(): void { + if (!this.persistencePath || !existsSync(this.persistencePath)) return; + try { + const parsed = JSON.parse(readFileSync(this.persistencePath, 'utf8')) as LedgerSnapshot; + if (parsed?.version === 1 && parsed.files && typeof parsed.files === 'object') { + this.snapshot = parsed; + } + } catch { + this.snapshot = { version: 1, files: {} }; + } + } + + private persist(): void { + if (!this.persistencePath) return; + mkdirSync(dirname(this.persistencePath), { recursive: true }); + const temp = `${this.persistencePath}.tmp`; + writeFileSync(temp, JSON.stringify(this.snapshot), 'utf8'); + renameSync(temp, this.persistencePath); + } +} + +export function duplicateReadPageText(record: FileReadRecordResult): string { + const next = record.coverage.complete + ? 'coverage is already complete' + : `continue with cursor=${JSON.stringify(record.coverage.nextCursor)}`; + return `[read_file duplicate_page] ${record.receipt.canonicalPath} · hash=${record.receipt.fileHash.slice(0, 12)} · cursor=${record.receipt.cursor} · ${next}. The duplicate body was not added to context.`; +} + +export function readPageUiSummary(record: FileReadRecordResult): string { + const page = record.receipt; + const columnPage = page.startColumn > 0 || (!page.complete && page.nextOffset === null); + const range = columnPage + ? `line ${page.startLine} columns ${page.startColumn}-${page.endColumn}` + : `lines ${page.startLine}-${page.endLine}/${page.totalLines}`; + const status = record.duplicate + ? '重复页面已去重' + : record.coverage.complete + ? '文件覆盖完成' + : `页面未读完 · next_cursor=${record.coverage.nextCursor}`; + const changed = record.fileChanged ? ' · 文件已变化,旧覆盖作废' : ''; + return `[read_file page] ${page.canonicalPath} · ${range} · ${status}${changed} · hash=${page.fileHash.slice(0, 12)} · 正文预览已折叠`; +} diff --git a/src/agent/tool-executor.ts b/src/agent/tool-executor.ts index 0b42ec3..0ca3642 100644 --- a/src/agent/tool-executor.ts +++ b/src/agent/tool-executor.ts @@ -16,6 +16,12 @@ import { sanitizeZimoosToolResultForHistory, type RuntimeContextSlotUpdate, } from './runtime-context-slots.js'; +import { + FileReadLedger, + duplicateReadPageText, + readPageUiSummary, + type FileReadCoverage, +} from './file-read-ledger.js'; const ASK_USER_PREFIX = '[ask_user] '; const PLAN_OPEN = '[plan]\n'; @@ -85,6 +91,8 @@ export interface ToolExecutionResult { operation: string; status: 'verified' | 'missing' | 'failed'; }; + fileReadCoverage?: FileReadCoverage; + progressSummary?: string; } function requiresStructuredEvidence( @@ -146,14 +154,20 @@ export class ToolExecutor { private webFetchCount = 0; private seenWebSearchQueries = new Set(); private seenWebFetchUrls = new Set(); + private seenReadPages = new Set(); constructor( private config: AgentConfig, private connections: McpConnection[], private builtinTools: Map, - private confirmProvider: ConfirmProvider + private confirmProvider: ConfirmProvider, + private fileReadLedger: FileReadLedger = new FileReadLedger(), ) {} + getFileReadCoverage(): FileReadCoverage { + return this.fileReadLedger.coverage(); + } + /** Look up the JSON Schema for a tool (exposed for pre-flight validation). */ getSchema(fullName: string): Record | null { return findToolSchema(this.connections, fullName); @@ -289,6 +303,21 @@ export class ToolExecutor { } } + const recordedReadPage = !isError && executedToolName === 'read_file' + ? this.fileReadLedger.record(structuredContent) + : null; + let readPageRecord = recordedReadPage; + if (recordedReadPage) { + const key = [ + recordedReadPage.receipt.canonicalPath, + recordedReadPage.receipt.fileHash, + recordedReadPage.receipt.cursor, + ].join('\0'); + const duplicateInCurrentTask = this.seenReadPages.has(key); + this.seenReadPages.add(key); + readPageRecord = { ...recordedReadPage, duplicate: duplicateInCurrentTask }; + } + const runtimeSlotUpdate = createZimoosRuntimeSlotUpdate({ rawResult: toolResult, isError, @@ -307,12 +336,18 @@ export class ToolExecutor { ? runtimeSlotUpdate.auditText : zimoosHistoryAudit ? zimoosHistoryAudit - : compactToolResult(toolResult); + : readPageRecord + ? readPageRecord.duplicate + ? duplicateReadPageText(readPageRecord) + : `${toolResult}${readPageRecord.fileChanged ? '\n[read_file ledger] file_changed: prior coverage for this path was invalidated.' : ''}` + : compactToolResult(toolResult); const uiResult = runtimeSlotUpdate ? runtimeSlotUpdate.auditText : zimoosHistoryAudit ? zimoosHistoryAudit - : toolResult; + : readPageRecord + ? readPageUiSummary(readPageRecord) + : toolResult; const short = formatToolResultForUi(uiResult); const artifact = !isError ? parseToolResultDiff(short) : undefined; @@ -341,6 +376,8 @@ export class ToolExecutor { executionResult.structuredContent = structuredContent; } if (meta !== undefined) executionResult._meta = meta; + if (readPageRecord) executionResult.fileReadCoverage = this.fileReadLedger.coverage(); + if (readPageRecord) executionResult.progressSummary = readPageUiSummary(readPageRecord); if (requiresEvidence) { const hasVerifiedEvidence = !isError && hasVerifiedStructuredEvidence(structuredContent, executedToolName); diff --git a/src/cli/App.tsx b/src/cli/App.tsx index da8c5b2..1129303 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -50,6 +50,9 @@ export interface AppProps { debug?: boolean; onSwitchSession?: (sessionId: string) => void; onRestartSession?: (sessionId: string) => void; + initialPrompt?: string; + initialDraft?: string; + startupStatus?: string; } let sysMsgCounter = 0; @@ -57,7 +60,7 @@ function nextSysId() { return `sys_${++sysMsgCounter}`; } -export function App({ config, connections, agent, sessionStore, currentSessionId, debug, onSwitchSession, onRestartSession }: AppProps) { +export function App({ config, connections, agent, sessionStore, currentSessionId, debug, onSwitchSession, onRestartSession, initialPrompt, initialDraft, startupStatus }: AppProps) { const app = useApp(); const store = useMemo(() => { const s = createUiStore(); @@ -346,6 +349,13 @@ export function App({ config, connections, agent, sessionStore, currentSessionId [agent, connections, app, store, send, log, pendingImages, handleRevertLastTurn, config, memoryProfileId, openModelPicker, openMemoryConsole, startMemoryBatch, switchModelChoice] ); + const initialPromptSentRef = useRef(false); + useEffect(() => { + if (!initialPrompt || initialPromptSentRef.current) return; + initialPromptSentRef.current = true; + handleSubmit(initialPrompt); + }, [handleSubmit, initialPrompt]); + useEffect(() => { if (!memoryController || config.model.provider?.toLowerCase() !== 'agora') return; const timer = setInterval(async () => { @@ -674,6 +684,7 @@ export function App({ config, connections, agent, sessionStore, currentSessionId setPendingImages([])} @@ -692,6 +703,7 @@ export function App({ config, connections, agent, sessionStore, currentSessionId contextSource={contextUsage.source} memoryActivity={memoryActivity} animateMemory={!thinking && !pendingConfirm} + runtimeStatus={startupStatus} /> ); diff --git a/src/cli/StartupCoordinator.tsx b/src/cli/StartupCoordinator.tsx new file mode 100644 index 0000000..fed78fe --- /dev/null +++ b/src/cli/StartupCoordinator.tsx @@ -0,0 +1,164 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Box, Text, useApp } from 'ink'; +import type { BootstrapPreparation, BootstrapResult } from '../index.js'; +import { hydrateBootstrap, shutdown } from '../index.js'; +import type { AppProps } from './App.js'; +import { InputBox } from './components/InputBox.js'; +import { ModelPicker } from './components/ModelPicker.js'; +import { listModelChoices, saveDefaultModelChoice, type ModelChoice } from './utils/modelProfiles.js'; + +export interface StartupCoordinatorProps { + prepared: BootstrapPreparation; + debug?: boolean; + onReady?: (boot: BootstrapResult) => void; + onSwitchSession?: (sessionId: string) => void; + onRestartSession?: (sessionId: string) => void; +} + +type StartupState = + | { status: 'connecting'; attempt: number } + | { status: 'failed'; attempt: number; error: string } + | { status: 'ready'; attempt: number; boot: BootstrapResult; AppComponent: React.ComponentType }; + +export function StartupCoordinator({ + prepared, + debug, + onReady, + onSwitchSession, + onRestartSession, +}: StartupCoordinatorProps) { + const app = useApp(); + const [attempt, setAttempt] = useState(0); + const [state, setState] = useState({ status: 'connecting', attempt: 0 }); + const [queuedPrompt, setQueuedPrompt] = useState(''); + const [draft, setDraft] = useState(''); + const [notice, setNotice] = useState(''); + const [modelChoices, setModelChoices] = useState(null); + + useEffect(() => { + let cancelled = false; + setState({ status: 'connecting', attempt }); + void Promise.allSettled([ + hydrateBootstrap(prepared), + import('./App.js').then((module) => module.App), + ]).then(async ([bootResult, componentResult]) => { + if (bootResult.status === 'rejected') { + if (!cancelled) { + setState({ status: 'failed', attempt, error: bootResult.reason instanceof Error ? bootResult.reason.message : String(bootResult.reason) }); + } + return; + } + const boot = bootResult.value; + if (componentResult.status === 'rejected') { + await shutdown(boot.connections, boot.agent); + if (!cancelled) { + setState({ status: 'failed', attempt, error: componentResult.reason instanceof Error ? componentResult.reason.message : String(componentResult.reason) }); + } + return; + } + if (cancelled) { + void shutdown(boot.connections, boot.agent); + return; + } + setState({ status: 'ready', attempt, boot, AppComponent: componentResult.value }); + onReady?.(boot); + }); + return () => { + cancelled = true; + // Once ready, the outer CLI lifecycle owns shutdown. Before hand-off, + // the settled branch above closes any runtime that completed late. + }; + // `prepared` is immutable for one outer session lifecycle. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [attempt, prepared]); + + const submitDuringStartup = useCallback((text: string) => { + if (text === '/retry') { + setNotice('正在重新连接 runtime…'); + setAttempt((value) => value + 1); + return; + } + if (text === '/quit') { + app.exit(); + return; + } + if (text === '/model') { + void listModelChoices(prepared.config) + .then(setModelChoices) + .catch((error) => setNotice(`模型列表失败: ${(error as Error).message}`)); + return; + } + if (!queuedPrompt) { + setQueuedPrompt(text); + setNotice('首条消息已安全排队,runtime ready 后自动发送。'); + } else { + setDraft(text); + setNotice('仅排队一条消息;新输入已保留为草稿。'); + } + }, [app, prepared.config, queuedPrompt]); + + if (state.status === 'ready') { + const ReadyApp = state.AppComponent; + return ( + 0 + ? `degraded · MCP failed: ${state.boot.connectionFailures.map((failure) => `${failure.name}: ${failure.error.replace(/\s+/g, ' ').slice(0, 120)}`).join(';')}` + : 'ready'} + /> + ); + } + + const contextWindow = prepared.config.model.contextWindow ?? 0; + const failure = state.status === 'failed' ? state.error : ''; + return ( + + + MA + · 可输入 · runtime {state.status === 'connecting' ? '连接中' : '连接失败'} + + + {notice ? {notice} : null} + {queuedPrompt ? 已排队: {queuedPrompt.slice(0, 100)} : null} + {failure ? ( + + 启动失败: {failure} + /retry 重试 · /model 切换 Provider/模型 · /quit 退出;草稿不会丢失 + + ) : null} + + {' '}Provider: {prepared.config.model.provider ?? 'openai'} · Model: {prepared.config.model.model} + {contextWindow > 0 ? ` · win ${Math.round(contextWindow / 1000)}k` : ''} + + + {' '}{prepared.resumed ? '恢复' : '新建'} session {prepared.sessionId} + {prepared.createdDefault ? ' · 已创建 ~/.my-agent/config.json' : ''} + + {' '}runtime 启动在后台进行 · 模型权重仅在首次对话加载 + {modelChoices ? ( + setModelChoices(null)} + onSelect={(choice) => { + saveDefaultModelChoice(choice); + onRestartSession?.(prepared.sessionId); + app.exit(); + }} + /> + ) : null} + + ); +} diff --git a/src/cli/components/InputBox.tsx b/src/cli/components/InputBox.tsx index b9731da..19384c1 100644 --- a/src/cli/components/InputBox.tsx +++ b/src/cli/components/InputBox.tsx @@ -11,6 +11,8 @@ interface InputBoxProps { pendingImages?: UiImage[]; onClearPendingImages?: () => void; onOpenSessionPicker?: () => void; + initialValue?: string; + onValueChange?: (value: string) => void; } const PASTE_JUNK = /\[200~|\[201~|\x1b\[200~|\x1b\[201~/g; @@ -22,8 +24,8 @@ interface CommandSuggestion { description: string; } -export function InputBox({ onSubmit, disabled, pendingImages, onClearPendingImages, onOpenSessionPicker }: InputBoxProps) { - const [value, setValue] = useState(''); +export function InputBox({ onSubmit, disabled, pendingImages, onClearPendingImages, onOpenSessionPicker, initialValue = '', onValueChange }: InputBoxProps) { + const [value, setValue] = useState(initialValue); const [history, setHistory] = useState([]); const [historyIndex, setHistoryIndex] = useState(-1); const [savedInput, setSavedInput] = useState(''); @@ -81,9 +83,10 @@ export function InputBox({ onSubmit, disabled, pendingImages, onClearPendingImag const handleChange = useCallback((newVal: string) => { const clean = newVal.replace(PASTE_JUNK, ''); setValue(clean); + onValueChange?.(clean); setCommandIndex(0); clearEscapeState(); - }, [clearEscapeState]); + }, [clearEscapeState, onValueChange]); const handleSubmit = useCallback( (text: string) => { @@ -95,10 +98,11 @@ export function InputBox({ onSubmit, disabled, pendingImages, onClearPendingImag setHistoryIndex(-1); setSavedInput(''); setValue(''); + onValueChange?.(''); clearEscapeState(); onSubmit(trimmed); }, - [onSubmit, pendingImages, clearEscapeState] + [onSubmit, pendingImages, clearEscapeState, onValueChange] ); const handleHistoryUp = useCallback(() => { @@ -196,24 +200,20 @@ export function InputBox({ onSubmit, disabled, pendingImages, onClearPendingImag ) : null} - {disabled ? ( - thinking... - ) : ( - - )} + {showCommandSuggestions ? ( diff --git a/src/cli/components/StatusBar.tsx b/src/cli/components/StatusBar.tsx index 6b8d1f2..cb80c85 100644 --- a/src/cli/components/StatusBar.tsx +++ b/src/cli/components/StatusBar.tsx @@ -14,6 +14,7 @@ interface StatusBarProps { contextSource?: string; memoryActivity?: string; animateMemory?: boolean; + runtimeStatus?: string; } export function StatusBar({ @@ -28,6 +29,7 @@ export function StatusBar({ contextSource, memoryActivity, animateMemory = true, + runtimeStatus, }: StatusBarProps) { const isAgora = provider?.toLowerCase() === 'agora'; let ctxLabel = ''; @@ -71,6 +73,7 @@ export function StatusBar({ {ctxColor ? {ctxLabel} : ctxLabel} {taskCount ? ` · tasks: ${taskCount}` : ''} {debug ? ' · 🔧 debug' : ''} + {runtimeStatus ? ` · runtime ${runtimeStatus}` : ''} {isAgora ? ( diff --git a/src/cli/index.tsx b/src/cli/index.tsx index 73f57da..c97c438 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -7,14 +7,14 @@ import * as path from 'node:path'; import * as os from 'node:os'; import pc from 'picocolors'; import figures from 'figures'; -import { bootstrap, shutdown } from '../index.js'; +import { bootstrap, prepareBootstrap, shutdown } from '../index.js'; import { globalConfigPath } from '../config.js'; import { deleteSecret, maskSecret, readSecret, repairSecretAccess } from '../secrets/keychain.js'; import { createSessionStore } from '../session/store.js'; import { runInit } from '../init.js'; import type { BootstrapResult } from '../index.js'; import type { Agent, McpConnection } from '../mcp/types.js'; -import { App } from './App.js'; +import { StartupCoordinator } from './StartupCoordinator.js'; import { VERSION } from './version.js'; import { assertInteractiveInput, TerminalInputError } from './terminal.js'; import { runContextWatch } from './watch.js'; @@ -99,37 +99,25 @@ async function runChat(configPath: string | undefined, runOpts: RunChatOptions): } for (;;) { - let boot: BootstrapResult; + let prepared; try { - boot = await bootstrap(configPath, { resume }); + prepared = prepareBootstrap(configPath, { resume }); } catch (err) { console.error(pc.red(`[error] ${(err as Error).message}`)); process.exit(1); } - - const { config, createdDefault, connections, agent, sessionId, resumed } = boot; - const sessionStore = createSessionStore(); + let boot: BootstrapResult | undefined; let nextSessionId: string | null = null; - activeConnections = connections; - activeAgent = agent; - - if (createdDefault) { - console.log(pc.yellow(`Created ~/.my-agent/config.json — edit model settings there.`)); - } - if (resumed) { - console.log(pc.dim(`resumed session ${sessionId}`)); - } else { - console.log(pc.dim(`session ${sessionId}`)); - } const { waitUntilExit } = render( - { + boot = ready; + activeConnections = ready.connections; + activeAgent = ready.agent; + }} onSwitchSession={(id) => { nextSessionId = id; }} @@ -141,7 +129,7 @@ async function runChat(configPath: string | undefined, runOpts: RunChatOptions): const onSigint = (): void => { void (async () => { - await shutdown(connections, agent); + await shutdown(boot?.connections ?? [], boot?.agent); process.exit(0); })(); }; @@ -151,7 +139,7 @@ async function runChat(configPath: string | undefined, runOpts: RunChatOptions): await waitUntilExit(); } finally { process.off('SIGINT', onSigint); - await shutdown(connections, agent); + await shutdown(boot?.connections ?? [], boot?.agent); activeConnections = []; activeAgent = undefined; } @@ -171,6 +159,9 @@ async function runPrompt(configPath: string | undefined, prompt: string): Promis } const { connections, agent } = boot; + for (const failure of boot.connectionFailures) { + process.stderr.write(`[warn] mcp "${failure.name}" failed to connect: ${failure.error}\n`); + } activeConnections = connections; activeAgent = agent; let finalText = ''; diff --git a/src/index.ts b/src/index.ts index 86d0f69..c2f9aa4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,5 @@ import { loadConfigDetailed, resolveConfigPath } from './config.js'; import { connectMcpServer } from './mcp/client.js'; -import { createAgent } from './agent.js'; import { createSessionStore } from './session/store.js'; import { resolveModelCapabilities } from './provider/capabilities.js'; import type { AgentConfig, McpConnection, Agent, McpServerConfig } from './mcp/types.js'; @@ -18,79 +17,34 @@ export interface BootstrapResult { agent: Agent; sessionId: string; resumed: boolean; + connectionFailures: Array<{ name: string; error: string }>; } -export async function bootstrap( +export interface BootstrapPreparation { + config: AgentConfig; + configPath: string | null; + configSources: string[]; + createdDefault: boolean; + sessionStore: ReturnType; + sessionId: string; + resumeMessages?: any[]; + resumed: boolean; + contextWindowConfigured?: boolean; +} + +export function prepareBootstrap( configPath?: string, opts: BootstrapOptions = {} -): Promise { +): BootstrapPreparation { const { config, sources, createdDefault } = loadConfigDetailed(configPath); const resolved = resolveConfigPath(configPath); - const isAgoraProvider = config.model.provider?.toLowerCase() === 'agora'; - - // Auto-detect model from server - if (!isAgoraProvider) { - try { - const res = await fetch(`${config.model.baseURL}/models`); - const data = await res.json() as { data: Array<{ id: string }> }; - const available = data.data.map(m => m.id); - if (available.length > 0) { - const configured = config.model.model; - if (available.includes(configured)) { - // configured model available, keep it - } else if (available.length === 1) { - config.model.model = available[0]; - process.stderr.write(`\x1b[33m[info] auto-selected model: ${available[0]}\x1b[0m\n`); - } else { - config.model.model = available[0]; - process.stderr.write(`\x1b[33m[info] "${configured}" not found, using: ${available[0]}\x1b[0m\n`); - } - } - } catch { - // server unreachable, keep config as-is - } - } - - let lmStudioContextWindow: number | undefined; - - // Auto-detect context window from LM Studio native API - if (!config.model.contextWindow && !isAgoraProvider) { - try { - const base = config.model.baseURL.replace(/\/v1\/?$/, ''); - const res = await fetch(`${base}/api/v0/models`); - const data = await res.json() as { - data: Array<{ id: string; max_context_length?: number }>; - }; - const found = data.data.find((m) => m.id === config.model.model); - if (found?.max_context_length) { - lmStudioContextWindow = found.max_context_length; - process.stderr.write( - `\x1b[33m[info] auto-detected context window: ${found.max_context_length}\x1b[0m\n` - ); - } - } catch { - // native API unavailable, use default - } - } - - const capabilities = resolveModelCapabilities(config.model, { lmStudioContextWindow }); + const contextWindowConfigured = typeof config.model.contextWindow === 'number' && config.model.contextWindow > 0; + const capabilities = resolveModelCapabilities(config.model); config.model.contextWindow = capabilities.contextWindow; config.model.contextWindowSource = capabilities.contextWindowSource; config.model.requestBodyByteLimit = capabilities.requestBodyByteLimit; config.model.requestBodyByteLimitSource = capabilities.requestBodyByteLimitSource; - const entries = Object.entries(config.mcpServers ?? {}) as Array<[string, McpServerConfig]>; - const connections: McpConnection[] = []; - for (const [name, serverConfig] of entries) { - try { - const conn = await connectMcpServer(name, serverConfig); - connections.push(conn); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - process.stderr.write(`\x1b[33m[warn] mcp "${name}" failed to connect: ${msg}\x1b[0m\n`); - } - } - const sessionStore = createSessionStore(); let sessionId: string; let resumeMessages: any[] | undefined; @@ -126,24 +80,94 @@ export async function bootstrap( }); } - const agent = await createAgent(config, connections, { - resumeMessages, - sessionStore, - sessionId, - }); - return { config, configPath: resolved, configSources: sources, createdDefault, - connections, - agent, + sessionStore, sessionId, + resumeMessages, resumed, + contextWindowConfigured, }; } +async function hydrateRemoteModelConfig(config: AgentConfig, detectContextWindow: boolean): Promise { + if (config.model.provider?.toLowerCase() === 'agora') return; + let lmStudioContextWindow: number | undefined; + try { + const res = await fetch(`${config.model.baseURL}/models`, { signal: AbortSignal.timeout(300) }); + const data = await res.json() as { data: Array<{ id: string }> }; + const available = data.data.map((model) => model.id); + if (available.length > 0 && !available.includes(config.model.model)) { + config.model.model = available[0]; + } + } catch { + // Provider availability is resolved by the first chat request. + } + if (detectContextWindow) { + try { + const base = config.model.baseURL.replace(/\/v1\/?$/, ''); + const res = await fetch(`${base}/api/v0/models`, { signal: AbortSignal.timeout(300) }); + const data = await res.json() as { data: Array<{ id: string; max_context_length?: number }> }; + lmStudioContextWindow = data.data.find((model) => model.id === config.model.model)?.max_context_length; + } catch { + // LM Studio native metadata is optional. + } + } + const capabilities = resolveModelCapabilities(config.model, { lmStudioContextWindow }); + config.model.contextWindow = capabilities.contextWindow; + config.model.contextWindowSource = capabilities.contextWindowSource; + config.model.requestBodyByteLimit = capabilities.requestBodyByteLimit; + config.model.requestBodyByteLimitSource = capabilities.requestBodyByteLimitSource; +} + +export async function hydrateBootstrap(prepared: BootstrapPreparation): Promise { + const modelConfigReady = hydrateRemoteModelConfig(prepared.config, !prepared.contextWindowConfigured); + const entries = Object.entries(prepared.config.mcpServers ?? {}) as Array<[string, McpServerConfig]>; + const connectionsReady = Promise.allSettled( + entries.map(async ([name, serverConfig]) => ({ name, connection: await connectMcpServer(name, serverConfig) })), + ); + const [, settled] = await Promise.all([modelConfigReady, connectionsReady]); + const connections: McpConnection[] = []; + const connectionFailures: Array<{ name: string; error: string }> = []; + settled.forEach((result, index) => { + const name = entries[index]?.[0] ?? `mcp-${index}`; + if (result.status === 'fulfilled') connections.push(result.value.connection); + else connectionFailures.push({ name, error: result.reason instanceof Error ? result.reason.message : String(result.reason) }); + }); + try { + const { createAgent } = await import('./agent.js'); + const agent = await createAgent(prepared.config, connections, { + resumeMessages: prepared.resumeMessages, + sessionStore: prepared.sessionStore, + sessionId: prepared.sessionId, + }); + return { + config: prepared.config, + configPath: prepared.configPath, + configSources: prepared.configSources, + createdDefault: prepared.createdDefault, + connections, + agent, + sessionId: prepared.sessionId, + resumed: prepared.resumed, + connectionFailures, + }; + } catch (error) { + await shutdown(connections); + throw error; + } +} + +export async function bootstrap( + configPath?: string, + opts: BootstrapOptions = {} +): Promise { + return hydrateBootstrap(prepareBootstrap(configPath, opts)); +} + export async function shutdown(connections: McpConnection[], agent?: Agent): Promise { try { await agent?.close(); diff --git a/src/mcp/client.ts b/src/mcp/client.ts index 76c0419..be48104 100644 --- a/src/mcp/client.ts +++ b/src/mcp/client.ts @@ -337,20 +337,30 @@ export class McpClient implements McpConnection { /* ignore */ } if (this.process.exitCode === null && this.process.signalCode === null) { - this.process.kill('SIGTERM'); await new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(t); + this.process.off('exit', finish); + resolve(); + }; const t = setTimeout(() => { try { this.process.kill('SIGKILL'); } catch { /* ignore */ } - resolve(); + finish(); }, 2000); - this.process.once('exit', () => { - clearTimeout(t); - resolve(); - }); + this.process.once('exit', finish); + try { + this.process.kill('SIGTERM'); + } catch { + finish(); + } + if (this.process.exitCode !== null || this.process.signalCode !== null) finish(); }); } } diff --git a/src/session/store.ts b/src/session/store.ts index 6487821..1a04e85 100644 --- a/src/session/store.ts +++ b/src/session/store.ts @@ -70,6 +70,7 @@ export function createSessionStore(sessionDir?: string): SessionStore { path.join(dir, `${id}.pool.jsonl`), path.join(dir, `${id}.index.jsonl`), path.join(dir, `${id}.patch.jsonl`), + path.join(dir, `${id}.reads.json`), ]; function create(partial: Omit): string { diff --git a/test/agent-completion-obligations.test.ts b/test/agent-completion-obligations.test.ts index 29a23df..51c16b9 100644 --- a/test/agent-completion-obligations.test.ts +++ b/test/agent-completion-obligations.test.ts @@ -2,7 +2,11 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import OpenAI from 'openai'; import { createAgent } from '../src/agent.js'; -import { isSemanticTestCommand } from '../src/agent/completion-obligations.js'; +import { + CompletionObligationAudit, + extractExplicitFileHints, + isSemanticTestCommand, +} from '../src/agent/completion-obligations.js'; import type { AgentConfig, AgentEvent, McpConnection } from '../src/mcp/types.js'; type StreamChunk = { @@ -119,6 +123,50 @@ function verifiedExecConnection(): McpConnection { }); } +function readPage( + cursor: string, + nextCursor: string | null, + complete: boolean, +): { content: string; structuredContent: Record } { + const [line, column] = cursor.split(':').map(Number); + const endLine = complete ? 4 : 2; + return { + content: `${line}│page body\n[read_file receipt] test`, + structuredContent: { + read_file_page: { + kind: 'read_file_page', + canonical_path: '/tmp/complete-review.ts', + file_hash: 'c'.repeat(64), + cursor, + start_line: line, + start_column: column, + end_line: endLine, + end_column: 10, + total_lines: 4, + complete, + next_offset: complete ? null : 3, + next_cursor: nextCursor, + body_chars: 20, + }, + }, + }; +} + +function pagedReadConnection(): McpConnection { + const pages = [readPage('1:0', '3:0', false), readPage('3:0', null, true)]; + return { + name: 'fs', + process: {} as any, + tools: [{ + name: 'read_file', + description: 'read page', + inputSchema: { type: 'object', required: ['path'], properties: { path: { type: 'string' } } }, + }], + call: async () => ({ ...(pages.shift() ?? readPage('3:0', null, true)), isError: false }), + close: async () => {}, + }; +} + test('completion obligations: ordinary question completes without another provider request', async () => { const provider = installProviderResponses([textResponse('42')]); const agent = await createAgent(config, []); @@ -253,3 +301,104 @@ test('completion obligations: repeated unsupported final exhausts bounded retrie await agent.close(); } }); + +test('completion obligations: full-file claim is blocked until contiguous read receipts reach EOF', async () => { + const provider = installProviderResponses([ + toolResponse('call_page_1', 'fs__read_file', { path: '/tmp/complete-review.ts' }), + textResponse('已经完整查看全部代码。'), + toolResponse('call_page_2', 'fs__read_file', { path: '/tmp/complete-review.ts', cursor: '3:0' }), + textResponse('已基于完整文件完成审阅。'), + ]); + const agent = await createAgent(config, [pagedReadConnection()]); + try { + const events = await drain(agent.chat('请完整审阅 /tmp/complete-review.ts 的全部代码。')); + assert.equal(provider.calls(), 4); + assert.ok(events.some((event) => event.type === 'warning' && /next_cursor=3:0/.test(event.message))); + assert.ok(events.some((event) => event.type === 'task:done')); + assert.equal(events.some((event) => event.type === 'task:failed'), false); + } finally { + provider.restore(); + await agent.close(); + } +}); + +test('completion obligations: spontaneous full-read prose cannot replace missing receipts', async () => { + const provider = installProviderResponses([ + toolResponse('call_partial', 'fs__read_file', { path: '/tmp/complete-review.ts' }), + textResponse('I fully read and reviewed the file.'), + textResponse('I completely read it already.'), + textResponse('Final: fully read.'), + ]); + const connection = pagedReadConnection(); + const agent = await createAgent(config, [connection]); + try { + const events = await drain(agent.chat('Tell me what this file does.')); + assert.equal(provider.calls(), 4); + assert.equal(events.some((event) => event.type === 'task:done'), false); + assert.ok(events.some((event) => event.type === 'task:failed')); + } finally { + provider.restore(); + await agent.close(); + } +}); + +test('completion obligations: every explicitly named file needs its own complete coverage', () => { + assert.deepEqual( + extractExplicitFileHints('请完整阅读 alpha.ts 和 src/beta.ts,参考 https://github.com/example/repo。'), + ['alpha.ts', 'src/beta.ts'], + ); + const audit = new CompletionObligationAudit('请完整阅读 alpha.ts 和 beta.ts。'); + audit.setFileReadCoverage({ + files: [{ + path: '/workspace/alpha.ts', hash: 'a'.repeat(64), totalLines: 10, + complete: true, nextCursor: null, pageCount: 1, + }], + trackedFiles: 1, + completeFiles: 1, + allComplete: true, + }); + assert.deepEqual(audit.missing(), ['file_read_coverage']); + assert.match(audit.inspectFinalAttempt().message ?? '', /beta\.ts 尚无完整回执/); + + audit.setFileReadCoverage({ + files: [ + { + path: '/workspace/alpha.ts', hash: 'a'.repeat(64), totalLines: 10, + complete: true, nextCursor: null, pageCount: 1, + }, + { + path: '/workspace/beta.ts', hash: 'b'.repeat(64), totalLines: 20, + complete: true, nextCursor: null, pageCount: 2, + }, + ], + trackedFiles: 2, + completeFiles: 2, + allComplete: true, + }); + assert.deepEqual(audit.missing(), []); +}); + +test('completion obligations: file hint extraction is bounded for large prose', () => { + const prompt = `检查 ${'a'.repeat(200_000)} 然后完整阅读 src/alpha.ts。`; + assert.deepEqual(extractExplicitFileHints(prompt), ['src/alpha.ts']); +}); + +test('completion obligations: unrelated partial history does not block named-file coverage', () => { + const audit = new CompletionObligationAudit('请完整阅读 alpha.ts。'); + audit.setFileReadCoverage({ + files: [ + { + path: '/workspace/old.ts', hash: '0'.repeat(64), totalLines: 100, + complete: false, nextCursor: '20:0', pageCount: 1, + }, + { + path: '/workspace/alpha.ts', hash: 'a'.repeat(64), totalLines: 10, + complete: true, nextCursor: null, pageCount: 1, + }, + ], + trackedFiles: 2, + completeFiles: 1, + allComplete: false, + }); + assert.deepEqual(audit.missing(), []); +}); diff --git a/test/benchmark/reports/issue42-startup-shell.json b/test/benchmark/reports/issue42-startup-shell.json new file mode 100644 index 0000000..81490d3 --- /dev/null +++ b/test/benchmark/reports/issue42-startup-shell.json @@ -0,0 +1,90 @@ +{ + "cold": { + "runs": 5, + "firstFrames": [ + 306.484708, + 204.77158399999996, + 205.08133299999986, + 256.3192080000001, + 205.18662500000005 + ], + "editable": [ + 433.7255, + 383.729792, + 383.929208, + 383.5765409999999, + 331.84804099999997 + ], + "firstFrameP95": 306.484708, + "editableP95": 433.7255 + }, + "warm": { + "runs": 30, + "firstFrames": [ + 256.4725000000001, + 205.516208, + 204.94533300000012, + 205.0935830000003, + 205.11524999999983, + 204.7108330000001, + 205.33787500000017, + 204.98416700000053, + 257.8083340000003, + 204.86149999999998, + 205.52429200000006, + 256.1627920000001, + 207.03704199999993, + 255.07683300000008, + 205.78762499999993, + 255.2393750000001, + 204.3251249999994, + 256.99041600000055, + 205.45437499999935, + 205.22820899999897, + 204.5109169999996, + 205.00237500000003, + 256.7301659999994, + 206.77129200000127, + 206.94133299999885, + 205.7016249999997, + 205.13154099999883, + 256.73383400000057, + 205.6982080000016, + 255.51287500000035 + ], + "editable": [ + 384.3439999999998, + 384.231667, + 383.1411250000001, + 331.6501250000001, + 383.3651249999998, + 382.8907079999999, + 383.83708300000035, + 384.68512499999997, + 384.34483400000045, + 383.19845799999985, + 384.3381669999999, + 383.9298340000005, + 385.28183400000034, + 382.7919160000001, + 383.47862499999974, + 382.2897499999999, + 382.248458, + 383.813666, + 384.36133399999926, + 384.0980419999996, + 381.976584, + 383.8938340000004, + 383.76254100000006, + 333.25883299999987, + 334.0829999999987, + 383.3442500000001, + 384.09587499999907, + 383.8950420000001, + 332.5228330000009, + 382.88579200000095 + ], + "firstFrameP95": 256.99041600000055, + "editableP95": 384.68512499999997 + } +} \ No newline at end of file diff --git a/test/bootstrap-startup.test.ts b/test/bootstrap-startup.test.ts new file mode 100644 index 0000000..f6b4015 --- /dev/null +++ b/test/bootstrap-startup.test.ts @@ -0,0 +1,90 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { hydrateBootstrap, shutdown, type BootstrapPreparation } from '../src/index.js'; +import { createSessionStore } from '../src/session/store.js'; + +function serverSource(delayMs: number, fail = false): string { + if (fail) return 'process.exit(23);\n'; + return `let buffer = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + buffer += chunk; + while (true) { + const index = buffer.indexOf('\\n'); + if (index < 0) return; + const line = buffer.slice(0, index).trim(); + buffer = buffer.slice(index + 1); + if (!line) continue; + const request = JSON.parse(line); + if (request.method === 'notifications/initialized') continue; + const result = request.method === 'tools/list' ? { tools: [] } : { + protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'slow', version: '1' } + }; + setTimeout(() => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\\n'), ${delayMs}); + } +}); +`; +} + +test('bootstrap hydration connects five MCP servers in parallel, preserves config order, and isolates failures', async () => { + const dir = mkdtempSync(join(tmpdir(), 'ma-bootstrap-')); + try { + const slowA = join(dir, 'slow-a.mjs'); + const slowB = join(dir, 'slow-b.mjs'); + const slowC = join(dir, 'slow-c.mjs'); + const broken = join(dir, 'broken.mjs'); + const slowD = join(dir, 'slow-d.mjs'); + const slowE = join(dir, 'slow-e.mjs'); + writeFileSync(slowA, serverSource(140)); + writeFileSync(slowB, serverSource(140)); + writeFileSync(slowC, serverSource(140)); + writeFileSync(broken, serverSource(0, true)); + writeFileSync(slowD, serverSource(140)); + writeFileSync(slowE, serverSource(140)); + const config = { + model: { + provider: 'openai', + baseURL: 'http://127.0.0.1:9/v1', + model: 'parallel-test', + apiKey: 'test', + contextWindow: 32768, + }, + mcpServers: { + alpha: { command: process.execPath, args: [slowA] }, + beta: { command: process.execPath, args: [slowB] }, + gamma: { command: process.execPath, args: [slowC] }, + broken: { command: process.execPath, args: [broken] }, + delta: { command: process.execPath, args: [slowD] }, + omega: { command: process.execPath, args: [slowE] }, + }, + }; + const sessionStore = createSessionStore(join(dir, 'sessions')); + const sessionId = sessionStore.create({ createdAt: Date.now(), cwd: dir, model: 'parallel-test' }); + const prepared: BootstrapPreparation = { + config, + configPath: null, + configSources: [], + createdDefault: false, + sessionStore, + sessionId, + resumed: false, + contextWindowConfigured: true, + }; + const started = performance.now(); + const boot = await hydrateBootstrap(prepared); + const elapsed = performance.now() - started; + try { + assert.ok(elapsed < 700, `parallel hydration took ${elapsed.toFixed(1)}ms`); + assert.deepEqual(boot.connections.map((connection) => connection.name), ['alpha', 'beta', 'gamma', 'delta', 'omega']); + assert.equal(boot.connectionFailures.length, 1); + assert.equal(boot.connectionFailures[0]?.name, 'broken'); + } finally { + await shutdown(boot.connections, boot.agent); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/cli-ux.test.ts b/test/cli-ux.test.ts index 419542e..712727f 100644 --- a/test/cli-ux.test.ts +++ b/test/cli-ux.test.ts @@ -9,6 +9,162 @@ import * as pty from 'node-pty'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, '..'); +test('CLI UX PTY: full startup shell and editable input appear before a slow Agora runtime is ready', async (t) => { + if (process.env.MA_RUN_PTY_TESTS !== '1') { + t.skip('PTY UI verification runs only in the explicit MA_RUN_PTY_TESTS E2E lane'); + return; + } + if (!canSpawnPty()) { + t.skip('node-pty cannot spawn a basic /bin/echo process in this environment'); + return; + } + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-cli-startup-shell-')); + const slowAgora = path.join(tmp, 'slow-agora.mjs'); + const configPath = path.join(tmp, 'config.json'); + fs.writeFileSync(slowAgora, `let buffer=''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data',(chunk)=>{buffer+=chunk;while(true){const i=buffer.indexOf('\\n');if(i<0)return;const line=buffer.slice(0,i).trim();buffer=buffer.slice(i+1);if(!line)continue;const req=JSON.parse(line);if(req.method==='notifications/initialized')continue;setTimeout(()=>process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:req.id,result:{protocolVersion:'2024-11-05',capabilities:{tools:{}},serverInfo:{name:'slow',version:'1'}}})+'\\n'),5000);}}); +process.stdin.on('close',()=>process.exit(0));\n`); + fs.writeFileSync(configPath, JSON.stringify({ + defaultProfile: '', + model: { + provider: 'agora', baseURL: 'mcp-stdio://agora', model: 'slow-test', apiKey: 'agora-mcp', + agoraRuntime: { command: process.execPath, args: [slowAgora] }, + }, + mcpServers: {}, + })); + const builtCli = path.join(repoRoot, 'dist', 'src', 'cli', 'index.js'); + assert.ok(fs.existsSync(builtCli), 'run npm run build before the explicit PTY release lane'); + const firstFrames: number[] = []; + const editable: number[] = []; + const coldRuns = 5; + const warmRuns = Number.parseInt(process.env.MA_STARTUP_PTY_WARM_RUNS ?? '0', 10) || 0; + try { + for (let run = 0; run < coldRuns + warmRuns; run++) { + const started = performance.now(); + const child = pty.spawn(process.execPath, [builtCli, 'chat', '--config', configPath], { + name: 'xterm-256color', cols: 100, rows: 24, cwd: repoRoot, + env: { ...process.env, NO_COLOR: '1', MA_REDUCED_MOTION: '1' }, + }); + let output = ''; + child.onData((data) => { output += data; }); + try { + await waitFor(() => /runtime 连接中/.test(stripAnsi(output)) && /❯/.test(stripAnsi(output)), 2000); + firstFrames.push(performance.now() - started); + child.write(`queued-${run}`); + await new Promise((resolve) => setTimeout(resolve, 75)); + child.write('\r'); + await waitFor(() => /首条消息已安全排队/.test(stripAnsi(output)) && new RegExp(`queued-${run}`).test(stripAnsi(output)), 2000); + editable.push(performance.now() - started); + } finally { + child.kill('SIGINT'); + } + } + const p95 = (values: number[]) => [...values].sort((a, b) => a - b)[Math.ceil(values.length * 0.95) - 1] ?? Infinity; + const coldFirstFrames = firstFrames.slice(0, coldRuns); + const coldEditable = editable.slice(0, coldRuns); + const warmFirstFrames = firstFrames.slice(coldRuns); + const warmEditable = editable.slice(coldRuns); + assert.ok(p95(coldFirstFrames) <= 500, `cold first-frame p95 ${p95(coldFirstFrames).toFixed(1)}ms: ${coldFirstFrames}`); + assert.ok(p95(coldEditable) <= 1000, `cold editable p95 ${p95(coldEditable).toFixed(1)}ms: ${coldEditable}`); + if (warmRuns > 0) { + assert.ok(p95(warmFirstFrames) <= 500, `warm first-frame p95 ${p95(warmFirstFrames).toFixed(1)}ms`); + assert.ok(p95(warmEditable) <= 1000, `warm editable p95 ${p95(warmEditable).toFixed(1)}ms`); + } + const reportDir = path.join(repoRoot, 'test', 'benchmark', 'reports'); + fs.mkdirSync(reportDir, { recursive: true }); + fs.writeFileSync(path.join(reportDir, 'issue42-startup-shell.json'), JSON.stringify({ + cold: { runs: coldRuns, firstFrames: coldFirstFrames, editable: coldEditable, firstFrameP95: p95(coldFirstFrames), editableP95: p95(coldEditable) }, + warm: { runs: warmRuns, firstFrames: warmFirstFrames, editable: warmEditable, firstFrameP95: warmRuns > 0 ? p95(warmFirstFrames) : null, editableP95: warmRuns > 0 ? p95(warmEditable) : null }, + }, null, 2)); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +test('CLI UX PTY: Agora startup failure stays in TUI and preserves the current draft', async (t) => { + if (process.env.MA_RUN_PTY_TESTS !== '1') { + t.skip('PTY UI verification runs only in the explicit MA_RUN_PTY_TESTS E2E lane'); + return; + } + if (!canSpawnPty()) { + t.skip('node-pty cannot spawn a basic /bin/echo process in this environment'); + return; + } + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-cli-startup-failure-')); + const failingAgora = path.join(tmp, 'failing-agora.mjs'); + const configPath = path.join(tmp, 'config.json'); + fs.writeFileSync(failingAgora, 'setTimeout(() => process.exit(23), 500);\n'); + fs.writeFileSync(configPath, JSON.stringify({ + defaultProfile: '', + model: { + provider: 'agora', baseURL: 'mcp-stdio://agora', model: 'failure-test', apiKey: 'agora-mcp', + agoraRuntime: { command: process.execPath, args: [failingAgora] }, + }, + mcpServers: {}, + })); + const builtCli = path.join(repoRoot, 'dist', 'src', 'cli', 'index.js'); + const child = pty.spawn(process.execPath, [builtCli, 'chat', '--config', configPath], { + name: 'xterm-256color', cols: 100, rows: 24, cwd: repoRoot, + env: { ...process.env, NO_COLOR: '1', MA_REDUCED_MOTION: '1' }, + }); + let output = ''; + child.onData((data) => { output += data; }); + try { + await waitFor(() => /runtime 连接中/.test(stripAnsi(output)), 1500); + child.write('draft-survives'); + await waitFor(() => /启动失败:/.test(stripAnsi(output)), 3000); + const plain = stripAnsi(output); + assert.match(plain, /draft-survives/); + assert.match(plain, /\/retry 重试/); + assert.match(plain, /\/model 切换 Provider\/模型/); + } finally { + child.kill('SIGINT'); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +test('CLI UX PTY: queued startup message auto-sends once after Agora becomes ready', async (t) => { + if (process.env.MA_RUN_PTY_TESTS !== '1') { + t.skip('PTY UI verification runs only in the explicit MA_RUN_PTY_TESTS E2E lane'); + return; + } + if (!canSpawnPty()) { + t.skip('node-pty cannot spawn a basic /bin/echo process in this environment'); + return; + } + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-cli-startup-queue-')); + const fakeAgora = path.join(tmp, 'delayed-agora.mjs'); + const configPath = path.join(tmp, 'config.json'); + fs.writeFileSync(fakeAgora, fakeAgoraV2Source(900), 'utf8'); + fs.writeFileSync(configPath, JSON.stringify({ + defaultProfile: '', + model: { + provider: 'agora', baseURL: 'mcp-stdio://agora', model: 'base-a', apiKey: 'agora-mcp', + agoraRuntime: { command: process.execPath, args: [fakeAgora] }, + }, + mcpServers: {}, + })); + const builtCli = path.join(repoRoot, 'dist', 'src', 'cli', 'index.js'); + const child = pty.spawn(process.execPath, [builtCli, 'chat', '--config', configPath], { + name: 'xterm-256color', cols: 100, rows: 24, cwd: repoRoot, + env: { ...process.env, NO_COLOR: '1', MA_REDUCED_MOTION: '1' }, + }); + let output = ''; + child.onData((data) => { output += data; }); + try { + await waitFor(() => /runtime 连接中/.test(stripAnsi(output)), 1500); + child.write('send-after-ready'); + await new Promise((resolve) => setTimeout(resolve, 75)); + child.write('\r'); + await waitFor(() => /首条消息已安全排队/.test(stripAnsi(output)), 1500); + await waitFor(() => /queued-auto-response/.test(stripAnsi(output)), 10_000); + } finally { + child.kill('SIGINT'); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + test('CLI UX PTY: DeepSeek status bar uses provider capability instead of 33k fallback', async (t) => { if (process.env.MA_RUN_PTY_TESTS !== '1') { t.skip('PTY UI verification runs only in the explicit MA_RUN_PTY_TESTS E2E lane'); @@ -166,7 +322,7 @@ test('CLI UX PTY: Agora Memory v2 console is name-first and readable at 80 colum } }); -function fakeAgoraV2Source(): string { +function fakeAgoraV2Source(initializeDelayMs = 0): string { return `let buffer = ''; const names = ${JSON.stringify([ 'doctor', 'runtime_capabilities', 'models_list', 'models_status', 'models_download', 'chat_complete', @@ -182,7 +338,7 @@ const payloads = { memories_list: { memories: [{ id: 'memory-a', name: '产品记忆', base_model_id: 'base-a', head_patch_id: 'patch-a', status: 'available' }] }, memory_profiles_list: { profiles: [{ id: 'profile-a', name: 'project', base_model_id: 'base-a', active_memory_patch_ids: [], auto_intake_target_memory_ids: [], auto_intake_policy: { enabled: false }, status: 'available' }] }, memory_patches_list: { patches: [{ id: 'patch-a', name: '产品记忆@v1', base_model_id: 'base-a', family: 'memory-a', version: 'v1', mountable: true, status: 'available', memory_id: 'memory-a' }] }, - chat_complete: { status: 'completed', id: 'chat-a', session_id: 'session-a', message: { role: 'assistant', content: 'ok' }, output_text: 'ok', finish_reason: 'stop', active_memory_patch_ids: ['patch-a'], metadata: { session_id: 'session-a', memory: { enabled: true, profile_id: 'profile-a', active_memory_patch_ids: ['patch-a'] }, memory_runtime: { patchset_revision: 2 } }, memory: { enabled: true, profile_id: 'profile-a', active_memory_patch_ids: ['patch-a'] } }, + chat_complete: { status: 'completed', id: 'chat-a', session_id: 'session-a', message: { role: 'assistant', content: 'queued-auto-response' }, output_text: 'queued-auto-response', finish_reason: 'stop', active_memory_patch_ids: ['patch-a'], metadata: { session_id: 'session-a', memory: { enabled: true, profile_id: 'profile-a', active_memory_patch_ids: ['patch-a'] }, memory_runtime: { patchset_revision: 2 } }, memory: { enabled: true, profile_id: 'profile-a', active_memory_patch_ids: ['patch-a'] } }, }; process.stdin.setEncoding('utf8'); process.stdin.on('data', (chunk) => { @@ -199,7 +355,9 @@ process.stdin.on('data', (chunk) => { if (req.method === 'tools/list') result = { tools: names.map((name) => ({ name, inputSchema: { type: 'object' } })) }; else if (req.method === 'resources/list') result = { resources: [] }; else if (req.method === 'tools/call') result = { content: [{ type: 'text', text: JSON.stringify(payloads[req.params.name] || { status: 'ok' }) }] }; - process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: req.id, result }) + '\\n'); + const send = () => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: req.id, result }) + '\\n'); + if (req.method === 'initialize' && ${initializeDelayMs} > 0) setTimeout(send, ${initializeDelayMs}); + else send(); } }); `; diff --git a/test/e2e-real.ts b/test/e2e-real.ts index 246dfc0..63ce71f 100644 --- a/test/e2e-real.ts +++ b/test/e2e-real.ts @@ -1,11 +1,16 @@ import test, { type TestContext } from 'node:test'; import assert from 'node:assert'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; import { defaultE2ECwd, e2eConfigSkipReason, + REPO_ROOT, resolveE2EConfigPath, } from './e2e/helpers/real-env.js'; import { countChinese, hasLlmError, runMaPrompt } from './e2e/helpers/cli-runner.js'; +import { runAgent } from './e2e/helpers/agent-runner.js'; import { canSpawnPty, killMa, @@ -71,6 +76,64 @@ test('real ma run smoke: tool call path lists project files', { timeout: 360000 assert.ok(!hasLlmError(combined), `unexpected LLM error. Tail: ${combined.slice(-1000)}`); }); +test('real remote provider: bounded read receipts complete two large files without duplicate pages', { timeout: 420000 }, async (t) => { + const baseConfigPath = requireConfig(t); + if (!baseConfigPath) return; + const baseConfig = JSON.parse(fs.readFileSync(baseConfigPath, 'utf8')); + if (String(baseConfig.model?.provider ?? '').toLowerCase() === 'agora') { + t.skip('requires a real remote OpenAI-compatible provider config'); + return; + } + + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-issue42-remote-read-')); + const configPath = path.join(tmp, 'config.json'); + try { + const writeFixture = (name: string, lineCount: number, marker: string): void => { + const lines = Array.from({ length: lineCount }, (_, index) => + `export const ${name}${index + 1} = ${JSON.stringify(`${name}-${index + 1}-${'x'.repeat(24)}`)};` + ); + lines[lineCount - 1] = `// ${marker}`; + fs.writeFileSync(path.join(tmp, `${name}.ts`), lines.join('\n'), 'utf8'); + }; + writeFixture('alpha', 198, 'ALPHA_END_198'); + writeFixture('beta', 212, 'BETA_END_212'); + baseConfig.defaultProfile = ''; + baseConfig.mcpServers = { + fs: { + command: process.execPath, + args: ['--import', 'tsx', path.join(REPO_ROOT, 'servers', 'fs-mcp.ts')], + cwd: REPO_ROOT, + }, + }; + fs.writeFileSync(configPath, JSON.stringify(baseConfig), 'utf8'); + + const result = await runAgent( + [ + '完整阅读当前目录的 alpha.ts 和 beta.ts,必须按 read_file receipt 的 next_cursor 逐页继续到 complete=true。', + '不要用 execute_command、cat、sed、head 或 tail 读取。', + '最后分别报告两个文件最后一行的标记。', + ].join(''), + { cwd: tmp, configPath, timeout: 360000 }, + ); + const reads = result.toolCalls.filter((call) => call.name === 'fs__read_file' && call.ok); + assert.ok(reads.length >= 4, `expected paginated reads, got ${JSON.stringify(reads)}`); + const cursors = new Set(); + for (const read of reads) { + const file = path.resolve(tmp, String(read.args.path)); + const cursor = String(read.args.cursor ?? `${read.args.offset ?? 1}:0`); + const key = `${file}:${cursor}`; + assert.equal(cursors.has(key), false, `duplicate page reached model context: ${key}`); + cursors.add(key); + } + assert.match(result.finalText, /ALPHA_END_198/); + assert.match(result.finalText, /BETA_END_212/); + assert.ok(reads.some((call) => path.resolve(tmp, String(call.args.path)).endsWith('alpha.ts'))); + assert.ok(reads.some((call) => path.resolve(tmp, String(call.args.path)).endsWith('beta.ts'))); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + test('real TUI PTY smoke: starts, accepts input, completes, exits', { timeout: 360000 }, async (t) => { const configPath = requireConfig(t); if (!configPath) return; diff --git a/test/e2e/helpers/agent-runner.ts b/test/e2e/helpers/agent-runner.ts index 305e934..c82bab0 100644 --- a/test/e2e/helpers/agent-runner.ts +++ b/test/e2e/helpers/agent-runner.ts @@ -52,6 +52,8 @@ export async function runAgent( } else { toolCalls.push({ name: '', args: {}, ok: ev.ok }); } + } else if (ev.type === 'token') { + textParts.push(ev.text); } else if (ev.type === 'text') { textParts.push(ev.content); } @@ -59,7 +61,7 @@ export async function runAgent( } finally { clearTimeout(timer); try { - await shutdown(connections); + await shutdown(connections, agent); } catch {} if (opts.cwd) process.chdir(originalCwd); } diff --git a/test/file-read-ledger.test.ts b/test/file-read-ledger.test.ts new file mode 100644 index 0000000..1fc37d8 --- /dev/null +++ b/test/file-read-ledger.test.ts @@ -0,0 +1,143 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + FileReadLedger, + duplicateReadPageText, + parseReadFileReceipt, + readPageUiSummary, +} from '../src/agent/file-read-ledger.js'; + +const HASH_A = 'a'.repeat(64); +const HASH_B = 'b'.repeat(64); + +function page(overrides: Record = {}): Record { + return { + read_file_page: { + kind: 'read_file_page', + canonical_path: '/tmp/example.ts', + file_hash: HASH_A, + cursor: '1:0', + start_line: 1, + start_column: 0, + end_line: 2, + end_column: 10, + total_lines: 4, + complete: false, + next_offset: 3, + next_cursor: '3:0', + body_chars: 100, + ...overrides, + }, + }; +} + +test('FileReadLedger proves completion only through a contiguous cursor chain', () => { + const ledger = new FileReadLedger(); + ledger.record(page({ + cursor: '3:0', + start_line: 3, + end_line: 4, + complete: true, + next_offset: null, + next_cursor: null, + })); + assert.equal(ledger.coverage().allComplete, false); + assert.equal(ledger.coverage().files[0]?.nextCursor, '1:0'); + + ledger.record(page()); + assert.equal(ledger.coverage().allComplete, true); + assert.equal(ledger.coverage().files[0]?.pageCount, 2); +}); + +test('FileReadLedger suppresses identical pages and points at the real next cursor', () => { + const ledger = new FileReadLedger(); + const first = ledger.record(page()); + const duplicate = ledger.record(page()); + assert.ok(first); + assert.ok(duplicate); + assert.equal(first.duplicate, false); + assert.equal(duplicate.duplicate, true); + assert.match(duplicateReadPageText(duplicate), /duplicate_page/); + assert.match(duplicateReadPageText(duplicate), /cursor="3:0"/); + assert.equal(duplicateReadPageText(duplicate).includes('body_chars'), false); +}); + +test('FileReadLedger invalidates old coverage when the file hash changes', () => { + const ledger = new FileReadLedger(); + ledger.record(page({ + complete: true, + next_offset: null, + next_cursor: null, + end_line: 4, + })); + assert.equal(ledger.coverage().allComplete, true); + + const changed = ledger.record(page({ file_hash: HASH_B })); + assert.ok(changed); + assert.equal(changed.fileChanged, true); + assert.equal(ledger.coverage().allComplete, false); + assert.equal(ledger.coverage().files[0]?.hash, HASH_B); + assert.equal(ledger.coverage().files[0]?.pageCount, 1); +}); + +test('FileReadLedger persists only compact receipts across session resume', () => { + const dir = mkdtempSync(join(tmpdir(), 'ma-read-ledger-')); + try { + const file = join(dir, 'session.reads.json'); + const ledger = new FileReadLedger(file); + ledger.record(page()); + const resumed = new FileReadLedger(file); + assert.equal(resumed.coverage().trackedFiles, 1); + assert.equal(resumed.coverage().files[0]?.nextCursor, '3:0'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('parseReadFileReceipt rejects prose and malformed receipts', () => { + assert.equal(parseReadFileReceipt(undefined), null); + assert.equal(parseReadFileReceipt({ read_file_page: 'claimed complete' }), null); + assert.equal(parseReadFileReceipt(page({ file_hash: 'not-a-hash' })), null); + assert.equal(parseReadFileReceipt(page({ complete: true, next_cursor: '3:0' })), null); + assert.equal(parseReadFileReceipt(page({ cursor: '2:0' })), null); + assert.equal(parseReadFileReceipt(page({ start_line: 3, end_line: 2 })), null); + assert.equal(parseReadFileReceipt(page({ end_line: 5 })), null); + assert.equal(parseReadFileReceipt(page({ next_cursor: '4:0', next_offset: 4 })), null); + assert.equal(parseReadFileReceipt(page({ next_cursor: '2:10', next_offset: 2 })), null); + assert.equal(parseReadFileReceipt(page({ complete: true, next_offset: null, next_cursor: null })), null); +}); + +test('parseReadFileReceipt accepts exact long-line continuation and requires matching offset semantics', () => { + const receipt = parseReadFileReceipt(page({ + end_line: 1, + end_column: 3100, + next_offset: null, + next_cursor: '1:3100', + })); + assert.equal(receipt?.nextCursor, '1:3100'); + assert.equal(parseReadFileReceipt(page({ + end_line: 1, + end_column: 3100, + next_offset: 1, + next_cursor: '1:3100', + })), null); +}); + +test('readPageUiSummary distinguishes ordinary line pages from long-line columns', () => { + const ledger = new FileReadLedger(); + const ordinary = ledger.record(page()); + assert.ok(ordinary); + assert.match(readPageUiSummary(ordinary), /lines 1-2\/4/); + + const longLine = new FileReadLedger().record(page({ + end_line: 1, + end_column: 3100, + next_offset: null, + next_cursor: '1:3100', + })); + assert.ok(longLine); + assert.match(readPageUiSummary(longLine), /line 1 columns 0-3100/); +}); diff --git a/test/fs-mcp.test.ts b/test/fs-mcp.test.ts index 757d130..eed19db 100644 --- a/test/fs-mcp.test.ts +++ b/test/fs-mcp.test.ts @@ -25,6 +25,16 @@ function textOf(result: ToolResult): string { return first?.type === 'text' ? first.text ?? '' : ''; } +function bodyOf(result: ToolResult): string { + return textOf(result).split('\n[read_file receipt] ', 1)[0]; +} + +function pageReceiptOf(result: ToolResult): Record { + const receipt = evidenceOf(result).read_file_page; + assert.ok(receipt && typeof receipt === 'object' && !Array.isArray(receipt)); + return receipt as Record; +} + function evidenceOf(result: ToolResult): Record { assert.ok( result.structuredContent, @@ -104,16 +114,31 @@ test('read_file: keeps the existing numbered text response and adds complete str const result = await callFsTool('read_file', { path }); assert.equal(result.isError, false); - assert.equal(textOf(result), '1│alpha\n2│beta\n3│gamma'); - assert.deepEqual(evidenceOf(result), { - offset: 1, - limit: null, - totalLines: 3, - start: 1, - end: 3, + assert.equal(bodyOf(result), '1│alpha\n2│beta\n3│gamma'); + const evidence = evidenceOf(result); + assert.equal(evidence.offset, 1); + assert.equal(evidence.limit, null); + assert.equal(evidence.totalLines, 3); + assert.equal(evidence.start, 1); + assert.equal(evidence.end, 3); + assert.equal(evidence.complete, true); + assert.equal(evidence.nextOffset, null); + assert.equal(evidence.nextCursor, null); + assert.equal(evidence.hash, sha256(content)); + assert.deepEqual(pageReceiptOf(result), { + kind: 'read_file_page', + canonical_path: path, + file_hash: sha256(content), + cursor: '1:0', + start_line: 1, + start_column: 0, + end_line: 3, + end_column: 5, + total_lines: 3, complete: true, - nextOffset: null, - hash: sha256(content), + next_offset: null, + next_cursor: null, + body_chars: bodyOf(result).length, }); } finally { rmSync(dir, { recursive: true, force: true }); @@ -130,7 +155,7 @@ test('read_file: reports precise evidence for a partial page', async () => { const result = await callFsTool('read_file', { path, offset: 3, limit: 2 }); const evidence = evidenceOf(result); - assert.equal(textOf(result), '3│line-3\n4│line-4'); + assert.equal(bodyOf(result), '3│line-3\n4│line-4'); assert.equal(evidence.offset, 3); assert.equal(evidence.limit, 2); assert.equal(evidence.totalLines, 8); @@ -160,7 +185,7 @@ test('read_file: nextOffset paginates without gaps or overlap and preserves one while (offset !== null) { const result = await callFsTool('read_file', { path, offset, limit: 3 }); const evidence = evidenceOf(result); - const pageLines = textOf(result).split('\n'); + const pageLines = bodyOf(result).split('\n'); received.push(...pageLines.map((line) => line.replace(/^\s*\d+│/, ''))); hashes.add(String(evidence.hash)); pageCount++; @@ -186,6 +211,105 @@ test('read_file: nextOffset paginates without gaps or overlap and preserves one } }); +test('read_file: normalizes positive integer strings and rejects invalid pagination instead of restarting at line one', async () => { + const dir = mkdtempSync(join(tmpdir(), 'fs-mcp-')); + try { + const path = join(dir, 'numeric.txt'); + const content = Array.from({ length: 120 }, (_, i) => `line-${i + 1}`).join('\n'); + writeFileSync(path, content); + + const numeric = await callFsTool('read_file', { path, offset: 100, limit: 2 }); + const stringified = await callFsTool('read_file', { path, offset: '100', limit: '2' }); + assert.equal(bodyOf(stringified), bodyOf(numeric)); + assert.equal(evidenceOf(stringified).offset, 100); + assert.equal(evidenceOf(stringified).limit, 2); + + for (const [field, value] of [['offset', 'abc'], ['offset', 0], ['offset', -1], ['limit', 'NaN'], ['limit', null]] as const) { + const failed = await callFsTool('read_file', { path, [field]: value }); + assert.equal(failed.isError, true); + const error = failed.structuredContent?.error as Record; + assert.equal(error.kind, 'invalid_pagination_argument'); + assert.equal(error.field, field); + assert.doesNotMatch(textOf(failed), /1│line-1/); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('read_file: 198-line and 212-line sources reconstruct through bounded pages without gaps', async () => { + for (const lineCount of [198, 212]) { + const dir = mkdtempSync(join(tmpdir(), 'fs-mcp-')); + try { + const path = join(dir, `large-${lineCount}.ts`); + const sourceLines = Array.from({ length: lineCount }, (_, i) => `export const value${i + 1} = ${JSON.stringify('x'.repeat(24))};`); + const content = sourceLines.join('\n'); + writeFileSync(path, content); + + const received: string[] = []; + let cursor: string | null = '1:0'; + const hashes = new Set(); + while (cursor !== null) { + const result = await callFsTool('read_file', { path, cursor }); + const receipt = pageReceiptOf(result); + assert.ok(Number(receipt.body_chars) <= 3200); + received.push(...bodyOf(result).split('\n').map((line) => line.replace(/^\s*\d+│/, ''))); + hashes.add(String(receipt.file_hash)); + cursor = receipt.next_cursor as string | null; + } + assert.equal(received.join('\n'), content, `${lineCount}-line source must reconstruct exactly`); + assert.deepEqual([...hashes], [sha256(content)]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } +}); + +test('read_file: a 20K single line reconstructs exactly through column cursors', async () => { + const dir = mkdtempSync(join(tmpdir(), 'fs-mcp-')); + try { + const path = join(dir, 'minified.js'); + const content = `const payload=${JSON.stringify('z'.repeat(20_000))};`; + writeFileSync(path, content); + + let cursor: string | null = '1:0'; + let rebuilt = ''; + let pages = 0; + while (cursor !== null) { + const result = await callFsTool('read_file', { path, cursor }); + const receipt = pageReceiptOf(result); + rebuilt += bodyOf(result).replace(/^\s*1│/, ''); + cursor = receipt.next_cursor as string | null; + pages++; + } + assert.ok(pages > 1); + assert.equal(rebuilt, content); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('read_file: files above 256KB remain available through explicit pagination', async () => { + const dir = mkdtempSync(join(tmpdir(), 'fs-mcp-')); + try { + const path = join(dir, 'oversized-generated.ts'); + const content = Array.from({ length: 12_000 }, (_, i) => `export const generated_${i} = ${i};`).join('\n'); + writeFileSync(path, content); + assert.ok(Buffer.byteLength(content) > 256 * 1024); + + const unpaged = await callFsTool('read_file', { path }); + assert.equal(unpaged.isError, true); + assert.match(textOf(unpaged), /offset\/limit 或 cursor/); + + const paged = await callFsTool('read_file', { path, offset: '1', limit: '2' }); + assert.equal(paged.isError, false); + assert.match(bodyOf(paged), /1│export const generated_0/); + assert.equal(evidenceOf(paged).nextOffset, 3); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test('write_file: emits canonical verified evidence without replacing legacy text output', async () => { const dir = mkdtempSync(join(tmpdir(), 'fs-mcp-')); try { diff --git a/test/tool-executor.test.ts b/test/tool-executor.test.ts index a00f3a0..73bbb16 100644 --- a/test/tool-executor.test.ts +++ b/test/tool-executor.test.ts @@ -1,6 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { ToolExecutor } from '../src/agent/tool-executor.js'; +import { FileReadLedger } from '../src/agent/file-read-ledger.js'; import type { AgentConfig, McpConnection } from '../src/mcp/types.js'; const config: AgentConfig = { @@ -141,3 +142,63 @@ test('tool executor: confirm asks before a dangerous custom execute_command alia else delete (process.stdin as any).isTTY; } }); + +test('tool executor: read_file pages use receipt-aware history and suppress duplicate bodies', async () => { + const body = `1│${'middle-evidence-'.repeat(180)}`; + const receipt = { + kind: 'read_file_page', + canonical_path: '/tmp/receipt.ts', + file_hash: 'd'.repeat(64), + cursor: '1:0', + start_line: 1, + start_column: 0, + end_line: 1, + end_column: body.length - 2, + total_lines: 2, + complete: false, + next_offset: 2, + next_cursor: '2:0', + body_chars: body.length, + }; + const connection: McpConnection = { + name: 'fs', + process: {} as any, + tools: [{ name: 'read_file', description: '', inputSchema: { type: 'object', properties: {} } }], + call: async () => ({ + content: `${body}\n[read_file receipt] ${JSON.stringify(receipt)}`, + isError: false, + structuredContent: { read_file_page: receipt }, + }), + close: async () => {}, + }; + const ledger = new FileReadLedger(); + const executor = new ToolExecutor( + config, + [connection], + new Map(), + { nextId: () => 'confirm_read', awaitApproval: async () => true }, + ledger, + ); + + const first = await runTool(executor, 'fs__read_file', { path: '/tmp/receipt.ts' }); + assert.match(first.result.result, /middle-evidence/); + assert.doesNotMatch(first.result.result, /tool result truncated/); + assert.match(first.result.progressSummary, /页面未读完/); + assert.ok(first.events.some((event) => event.type === 'tool:result' && /页面未读完/.test(event.content))); + + const duplicate = await runTool(executor, 'fs__read_file', { path: '/tmp/receipt.ts' }); + assert.match(duplicate.result.result, /duplicate_page/); + assert.doesNotMatch(duplicate.result.result, /middle-evidence/); + assert.match(duplicate.result.result, /cursor="2:0"/); + + const nextTaskExecutor = new ToolExecutor( + config, + [connection], + new Map(), + { nextId: () => 'confirm_read_again', awaitApproval: async () => true }, + ledger, + ); + const firstReadInNextTask = await runTool(nextTaskExecutor, 'fs__read_file', { path: '/tmp/receipt.ts' }); + assert.match(firstReadInNextTask.result.result, /middle-evidence/); + assert.doesNotMatch(firstReadInNextTask.result.result, /duplicate_page/); +}); diff --git a/test/visual/screenshot.test.ts b/test/visual/screenshot.test.ts index a0354f1..018b56f 100644 --- a/test/visual/screenshot.test.ts +++ b/test/visual/screenshot.test.ts @@ -44,12 +44,16 @@ test.afterAll(async () => { test('ma startup screenshot', async ({ page }) => { await page.goto('/'); await page.waitForFunction(() => (window as any).__wsReady === true, { timeout: 10_000 }); - // Give ma time to print banner and MCP connect - await page.waitForTimeout(5_000); + await page.waitForFunction( + () => (window as any).__getText().includes('runtime 连接中'), + { timeout: 2_000 }, + ); const shotPath = path.join(SHOTS_DIR, 'startup.png'); await page.screenshot({ path: shotPath, fullPage: true }); expect(fs.existsSync(shotPath)).toBe(true); const text = await page.evaluate(() => (window as any).__getText()); + expect(text).toContain('MA · 可输入 · runtime 连接中'); + expect(text).toContain('❯'); console.log('[test] terminal text (startup) first 500 chars:\n', text.slice(0, 500)); }); diff --git a/test/visual/server.ts b/test/visual/server.ts index 9d0b5ba..0e0ce4c 100644 --- a/test/visual/server.ts +++ b/test/visual/server.ts @@ -33,12 +33,14 @@ wss.on('connection', (ws) => { console.log(`[server] client connected, spawn: ${SHELL_CMD} ${SHELL_ARGS.join(' ')} (cwd=${TEST_CWD})`); let shell: pty.IPty; try { + const childEnv = { ...process.env, TERM: 'xterm-256color', FORCE_COLOR: '1' }; + delete childEnv.NO_COLOR; shell = pty.spawn(SHELL_CMD, SHELL_ARGS, { name: 'xterm-256color', cols: 120, rows: 30, cwd: TEST_CWD, - env: { ...process.env, TERM: 'xterm-256color', FORCE_COLOR: '1' }, + env: childEnv, }); } catch (err) { const msg = `[server] failed to spawn shell: ${(err as Error).message}\r\n`; From 72f9140125e9c0b37ba76a945fac1a8bef8b18b2 Mon Sep 17 00:00:00 2001 From: zhuqingyv Date: Mon, 13 Jul 2026 23:12:34 +0800 Subject: [PATCH 2/5] fix: gate Agora lock on standalone release evidence --- scripts/package-portable.mjs | 13 ++++--- scripts/sync-agora-release.mjs | 53 ++++++++++++++++++++++++++-- src/provider/agora-runtime-lock.json | 1 + src/provider/agora.ts | 22 ++++++------ test/agora-release-lock.test.ts | 47 ++++++++++++++++++++++-- 5 files changed, 116 insertions(+), 20 deletions(-) diff --git a/scripts/package-portable.mjs b/scripts/package-portable.mjs index 2265fcb..b6180ce 100755 --- a/scripts/package-portable.mjs +++ b/scripts/package-portable.mjs @@ -41,16 +41,21 @@ function copyAgoraRuntime(appDir, target) { const manifestPath = path.join(source, 'manifest.json'); const binaryPath = path.join(source, 'bin', 'agora'); if (!fs.existsSync(manifestPath) || !fs.existsSync(binaryPath)) { - throw new Error('macos-arm64 portable release requires MA_AGORA_ARTIFACT_DIR with Agora 0.2.0 native artifact'); + throw new Error(`macos-arm64 portable release requires MA_AGORA_ARTIFACT_DIR with Agora ${agoraLock.version} native artifact`); } const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); if (agoraLock.published !== true) { throw new Error('macos-arm64 portable release requires a published, notarized Agora release lock'); } - if (manifest.version !== '0.2.0' || manifest.host_protocol_major !== 1) { + if ( + manifest.version !== agoraLock.version || + manifest.host_protocol_major !== agoraLock.host_protocol_major || + manifest.native_core_abi !== agoraLock.native_core_abi || + manifest.runtime_layout !== agoraLock.runtime_layout + ) { throw new Error(`unexpected Agora contract: ${manifest.version}/host-v${manifest.host_protocol_major}`); } - const requiredCapabilities = ['mcp-stdio', 'memory-profile-v2', 'memory-intake-v2']; + const requiredCapabilities = agoraLock.capabilities ?? []; if (!requiredCapabilities.every((capability) => manifest.capabilities?.includes(capability))) { throw new Error('Agora artifact is missing required Memory v2 capabilities'); } @@ -86,7 +91,7 @@ function copyAgoraRuntime(appDir, target) { fs.writeFileSync( path.join(destination, 'runtime-lock.json'), JSON.stringify({ - version: '0.2.0', + version: agoraLock.version, package: '@zimoos/agora-darwin-arm64', platform: 'darwin-arm64', host_protocol_major: 1, diff --git a/scripts/sync-agora-release.mjs b/scripts/sync-agora-release.mjs index f6865e8..4da3b04 100644 --- a/scripts/sync-agora-release.mjs +++ b/scripts/sync-agora-release.mjs @@ -4,8 +4,21 @@ import * as path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const VERSION = '0.2.0'; +const packageMetadata = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); +const VERSION = packageMetadata.optionalDependencies?.['@zimoos/agora']; const PACKAGE_NAMES = ['@zimoos/agora', '@zimoos/agora-darwin-arm64']; +const REQUIRED_TAMPER_CHECKS = [ + 'runtime_hash_tamper', + 'unmanifested_file', + 'missing_manifest', + 'protocol_mismatch', + 'dependency_adhoc_resign', + 'adhoc_resign', +]; + +if (typeof VERSION !== 'string' || !/^\d+\.\d+\.\d+$/.test(VERSION)) { + throw new Error('Agora release evidence rejected: package.json must pin an exact @zimoos/agora semver'); +} function fail(message) { throw new Error(`Agora release evidence rejected: ${message}`); @@ -21,6 +34,34 @@ export function buildReleaseLock(evidence) { if (!/^[a-f0-9]{64}$/.test(String(evidence.manifest_sha256 ?? ''))) { fail('manifest_sha256 is missing or malformed'); } + const artifactAudit = evidence?.artifact_audit; + if ( + artifactAudit?.passed !== true || + artifactAudit?.readable_language_source_files !== 0 || + artifactAudit?.private_keys !== 0 || + artifactAudit?.repository_source_paths !== 0 || + artifactAudit?.debug_symbols !== 0 + ) { + fail('artifact source/private-key/source-path/debug audit did not pass'); + } + const tamperChecks = new Map((evidence?.tamper_audit?.checks ?? []).map((item) => [item?.name, item])); + if ( + evidence?.tamper_audit?.passed !== true || + REQUIRED_TAMPER_CHECKS.some((name) => tamperChecks.get(name)?.exit_code !== 126) + ) { + fail('required launcher tamper checks did not fail closed'); + } + const startup = evidence?.startup_performance; + if ( + startup?.passed !== true || + startup?.cold?.runs < 5 || + startup?.warm?.runs < 30 || + startup?.cold?.p95_ms > 2000 || + startup?.warm?.p95_ms > 1000 || + startup?.precondition !== 'npm postinstall packaged doctor completed' + ) { + fail('packaged Agora startup performance evidence is missing or below the Issue #42 gate'); + } const signatures = Object.values(evidence.signatures ?? {}); if (signatures.length < 5 || signatures.some((item) => ( !String(item?.authority ?? '').startsWith('Developer ID Application:') || !String(item?.team_id ?? '') @@ -33,7 +74,7 @@ export function buildReleaseLock(evidence) { if (item?.version !== VERSION || !String(item?.integrity ?? '').startsWith('sha512-')) { fail(`missing exact npm integrity for ${name}@${VERSION}`); } - const forbidden = (item.files ?? []).filter((file) => /\.(py|pyc|js|map|pem|key|p12)$/i.test(file)); + const forbidden = (item.files ?? []).filter((file) => /\.(py|pyc|pyi|js|jsx|mjs|cjs|ts|tsx|map|key|p12)$/i.test(file)); if (forbidden.length > 0) fail(`${name} contains forbidden files: ${forbidden.join(', ')}`); } const teamIds = new Set(signatures.map((item) => item.team_id)); @@ -46,7 +87,13 @@ export function buildReleaseLock(evidence) { published: true, notarization_id: evidence.notarization.id, manifest_sha256: evidence.manifest_sha256, - capabilities: ['mcp-stdio', 'memory-profile-v2', 'memory-intake-v2'], + startup_performance: { + cold_p95_ms: startup.cold.p95_ms, + warm_p95_ms: startup.warm.p95_ms, + }, + tamper_checks: REQUIRED_TAMPER_CHECKS, + runtime_layout: 'nuitka-standalone-v1', + capabilities: ['mcp-stdio', 'memory-profile-v2', 'memory-intake-v2', 'memory-lineage-cas-v1'], packages: Object.fromEntries(PACKAGE_NAMES.map((name) => [name, { integrity: packages[name].integrity }])), }; } diff --git a/src/provider/agora-runtime-lock.json b/src/provider/agora-runtime-lock.json index aca148b..61743f0 100644 --- a/src/provider/agora-runtime-lock.json +++ b/src/provider/agora-runtime-lock.json @@ -3,6 +3,7 @@ "platform": "darwin-arm64", "host_protocol_major": 1, "native_core_abi": 1, + "runtime_layout": "legacy-onefile-v1", "published": false, "notarization_id": null, "manifest_sha256": "5af44c6ed85e3bafc55c1ef83f574fb40aae4cdc5a6e5920841411808cdd35dc", diff --git a/src/provider/agora.ts b/src/provider/agora.ts index 458ef0a..595f7f6 100644 --- a/src/provider/agora.ts +++ b/src/provider/agora.ts @@ -335,14 +335,16 @@ function resolveInstalledAgoraPackage(): ResolvedAgoraCommand | null { const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); const command = path.join(packageRoot, 'bin', 'agora'); const platformCommand = path.join(platformRoot, 'bin', 'agora'); - const requiredCapabilities = ['mcp-stdio', 'memory-profile-v2', 'memory-intake-v2']; + const expectedVersion = agoraReleaseLock.version; + const requiredCapabilities = agoraReleaseLock.capabilities ?? []; if ( - packageJson.version !== '0.2.0' || - packageJson.dependencies?.['@zimoos/agora-darwin-arm64'] !== '0.2.0' || - platformPackageJson.version !== '0.2.0' || - manifest.version !== '0.2.0' || - manifest.host_protocol_major !== 1 || - manifest.native_core_abi !== 1 || + packageJson.version !== expectedVersion || + packageJson.dependencies?.['@zimoos/agora-darwin-arm64'] !== expectedVersion || + platformPackageJson.version !== expectedVersion || + manifest.version !== expectedVersion || + manifest.host_protocol_major !== agoraReleaseLock.host_protocol_major || + manifest.native_core_abi !== agoraReleaseLock.native_core_abi || + manifest.runtime_layout !== agoraReleaseLock.runtime_layout || !requiredCapabilities.every((capability) => manifest.capabilities?.includes(capability)) || sha256File(manifestPath) !== agoraReleaseLock.manifest_sha256 || !verifyAgoraManifestFiles(platformRoot, manifest) || @@ -357,9 +359,9 @@ function resolveInstalledAgoraPackage(): ResolvedAgoraCommand | null { trust: 'verified', source: 'npm', lock: { - version: '0.2.0', - host_protocol_major: 1, - native_core_abi: 1, + version: expectedVersion, + host_protocol_major: agoraReleaseLock.host_protocol_major, + native_core_abi: agoraReleaseLock.native_core_abi, manifest_sha256: agoraReleaseLock.manifest_sha256, notarization_id: agoraReleaseLock.notarization_id, }, diff --git a/test/agora-release-lock.test.ts b/test/agora-release-lock.test.ts index c17a623..d92ca0b 100644 --- a/test/agora-release-lock.test.ts +++ b/test/agora-release-lock.test.ts @@ -1,20 +1,48 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import { buildReleaseLock } from '../scripts/sync-agora-release.mjs'; import { parseDeveloperIdSignature } from '../src/provider/agora.js'; +const EXPECTED_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) + .optionalDependencies['@zimoos/agora']; + const evidence = () => ({ - version: '0.2.0', + version: EXPECTED_VERSION, published: true, notarization: { id: 'notary-id', status: 'Accepted' }, manifest_sha256: 'a'.repeat(64), + artifact_audit: { + passed: true, + readable_language_source_files: 0, + private_keys: 0, + repository_source_paths: 0, + debug_symbols: 0, + }, + tamper_audit: { + passed: true, + checks: [ + 'runtime_hash_tamper', + 'unmanifested_file', + 'missing_manifest', + 'protocol_mismatch', + 'dependency_adhoc_resign', + 'adhoc_resign', + ].map((name) => ({ name, exit_code: 126 })), + }, + startup_performance: { + passed: true, + precondition: 'npm postinstall packaged doctor completed', + cold: { runs: 5, p95_ms: 700 }, + warm: { runs: 30, p95_ms: 600 }, + }, signatures: Object.fromEntries(Array.from({ length: 5 }, (_, index) => [ `bin-${index}`, { authority: 'Developer ID Application: ZimoOS (TEAM123)', team_id: 'TEAM123' }, ])), packages: [ - { name: '@zimoos/agora', version: '0.2.0', integrity: 'sha512-launcher', files: ['package/bin/agora'] }, - { name: '@zimoos/agora-darwin-arm64', version: '0.2.0', integrity: 'sha512-platform', files: ['package/bin/agora'] }, + { name: '@zimoos/agora', version: EXPECTED_VERSION, integrity: 'sha512-launcher', files: ['package/bin/agora'] }, + { name: '@zimoos/agora-darwin-arm64', version: EXPECTED_VERSION, integrity: 'sha512-platform', files: ['package/bin/agora', 'package/bin/certifi/cacert.pem'] }, ], }); @@ -22,6 +50,8 @@ test('release lock accepts only published, notarized, Developer ID signed eviden const lock = buildReleaseLock(evidence()); assert.equal(lock.published, true); assert.equal(lock.notarization_id, 'notary-id'); + assert.equal(lock.runtime_layout, 'nuitka-standalone-v1'); + assert.equal(lock.startup_performance.cold_p95_ms, 700); assert.equal(lock.packages['@zimoos/agora-darwin-arm64'].integrity, 'sha512-platform'); assert.throws(() => buildReleaseLock({ ...evidence(), published: false }), /expected published/); @@ -29,6 +59,17 @@ test('release lock accepts only published, notarized, Developer ID signed eviden const adHoc = evidence(); adHoc.signatures['bin-0'] = { authority: '', team_id: '' }; assert.throws(() => buildReleaseLock(adHoc), /Developer ID/); + assert.throws( + () => buildReleaseLock({ ...evidence(), artifact_audit: { ...evidence().artifact_audit, private_keys: 1 } }), + /artifact source\/private-key/, + ); + assert.throws( + () => buildReleaseLock({ ...evidence(), startup_performance: { ...evidence().startup_performance, passed: false } }), + /startup performance/, + ); + const missingTamperCheck = evidence(); + missingTamperCheck.tamper_audit.checks.pop(); + assert.throws(() => buildReleaseLock(missingTamperCheck), /tamper checks/); }); test('Developer ID parser rejects ad-hoc signatures', () => { From f20d161db6f3d97e8c32d044a6c02b612fe446f0 Mon Sep 17 00:00:00 2001 From: pianoboy <40992808+zhuqingyv@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:52:30 +0800 Subject: [PATCH 3/5] fix: keep Agora memory provenance outside model context (#44) * fix: keep Agora memory provenance outside model context * docs: keep memory management in host control plane --- README.md | 2 +- README.zh-CN.md | 2 +- docs/agora-vip-provider-requirements.md | 1 + docs/prd-ma-agora-memory-v2.md | 13 +- src/agent.ts | 287 +----------------------- test/agora-provenance-boundary.test.ts | 113 ++++++++++ 6 files changed, 127 insertions(+), 291 deletions(-) create mode 100644 test/agora-provenance-boundary.test.ts diff --git a/README.md b/README.md index 15347fb..b942d05 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ DeepSeek/deepseek-v4-flash MA can run Agora as a provider-owned MCP stdio subprocess instead of asking users to manage a local HTTP server. The TUI reports real provider stages such as local-model loading, memory mounting, and generation. -When the active provider is Agora, users operate uniquely named, independently versioned Memories; MemoryPatch is the immutable version object. MA reports `mounted` only after a later Agora response returns the requested ordered Patch ids and an advanced PatchSet revision. It never fakes memory by injecting facts into a prompt. +When the active provider is Agora, users operate uniquely named, independently versioned Memories; MemoryPatch is the immutable version object. MA reports `mounted` only after a later Agora response returns the requested ordered Patch ids and an advanced PatchSet revision. Memory management stays in the host-side `/memory` control plane: MA neither injects facts into the prompt nor exposes MemoryPatch policy and management tools to the conversational model. `/memory` can mount zero or more Memories per project or conversation and hot-swap them at the next request boundary without restarting the base model. One intake batch may mix a new Memory with increments to multiple existing Memories; the source is extracted once and each target reports completed/noop/review/conflict/failed independently. Automatic intake requires explicit targets and runs after 4 new user turns or about 2,000 pending tokens plus 60 seconds of idle time. Failed targets can be retried alone or explicitly abandoned without blocking input or adding transcript noise. diff --git a/README.zh-CN.md b/README.zh-CN.md index c2caefd..259eabb 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -183,7 +183,7 @@ DeepSeek/deepseek-v4-flash MA 可以把 Agora 作为 provider 自己管理的 MCP stdio 子进程运行,不要求用户维护本地 HTTP 服务。TUI 会展示真实的本地模型加载、记忆挂载和生成阶段。 -当 Agora 是当前 provider 时,用户操作的是名称唯一、可持续迭代的 Memory;MemoryPatch 是它的不可变版本。只有下一次 Agora 响应 metadata 返回相同的 ordered Patch ids 和更新后的 PatchSet revision,MA 才会显示 `mounted`;不会把一段事实塞进 prompt 伪装成记忆。 +当 Agora 是当前 provider 时,用户操作的是名称唯一、可持续迭代的 Memory;MemoryPatch 是它的不可变版本。只有下一次 Agora 响应 metadata 返回相同的 ordered Patch ids 和更新后的 PatchSet revision,MA 才会显示 `mounted`。记忆管理只存在于宿主侧 `/memory` 控制面:MA 既不会把事实塞进 prompt,也不会向对话模型暴露 MemoryPatch 策略和管理工具。 `/memory` 可在项目或单个会话中同时挂载 0~N 个 Memory,并在下一次请求边界热拔插,不重启基座。内化时可以在一个 batch 中混合“新建 Memory”和“增量到多个旧 Memory”;同一 source 只提取一次,各目标独立报告 completed/noop/review/conflict/failed。自动内化必须显式选择目标,默认在 4 个新增用户回合或约 2000 pending tokens、空闲 60 秒后运行;失败目标可单独重试或明确放弃,输入框和 transcript 不受影响。 diff --git a/docs/agora-vip-provider-requirements.md b/docs/agora-vip-provider-requirements.md index 4f04e68..c880451 100644 --- a/docs/agora-vip-provider-requirements.md +++ b/docs/agora-vip-provider-requirements.md @@ -37,6 +37,7 @@ MA 将 Agora 作为内部 VIP provider 适配:Agora 不再只是一个 OpenAI- 4. MemoryPatch 状态 - Session meta 保留 `providerState`,字段包括 `provider_id`、`agora_session_id`、`memory.status`、`profile_id`、`binding_id`、`active_memory_patch_ids`、`last_verified_at`。 - MA 只能从 Agora `chat_complete` 响应 metadata 更新 mounted 证据。 + - Memory 控制策略、Patch 状态和管理工具只属于宿主 Controller/TUI,不得进入普通对话的 system prompt、tool schema 或 transcript。 - mount、disable、internalize、rollback 必须验证下一次 `chat_complete` metadata 后才报告成功。 - `/memory` 以具名 Memory 为第一层,提供多 Memory 挂载、新建/重命名、混合多目标内化、显式自动目标、历史和 CAS 回滚;Profile 仅作为后台 binding 实现。 - 自动 intake 一次提交显式目标列表;completed/noop 不重复,review/conflict/failed 只重试未完成目标;后台任务不得禁用输入框或写入 transcript。 diff --git a/docs/prd-ma-agora-memory-v2.md b/docs/prd-ma-agora-memory-v2.md index c488e77..5009dbe 100644 --- a/docs/prd-ma-agora-memory-v2.md +++ b/docs/prd-ma-agora-memory-v2.md @@ -69,6 +69,7 @@ Agora v2 已提供: - 不在 MA 重新实现 Memory 编译、gate、segment、CAS 或回滚算法。 - 不把记忆事实注入 system prompt 模拟 Agora Memory。 +- 不把 MemoryPatch 管理策略、状态或工具 schema 暴露给普通对话模型。 - 不支持生成到一半的 mid-token 热插拔;切换在下一次 chat 边界生效。 - 不支持跨不同 base model 挂载不兼容 Patch。 - 不引入账号、登录、机器绑定、许可证、云同步或 ZimoOS 依赖。 @@ -250,7 +251,7 @@ conversation override > project default > user default > no memory ### 9.5 手动内化 -入口:`i`、`/memory internalize` 或 agent memory tool。 +入口:`i`、`/memory internalize` 或 Memory Console。 步骤: @@ -331,7 +332,7 @@ conversation override > project default > user default > no memory | `/memory status` | 查看 ids、binding、batch、revision 和 capability 详情 | | `/memory disable` | 当前 scope 不使用记忆 | -命令、TUI 和 agent tool 必须调用同一个 Controller,禁止三套业务逻辑。 +命令和 TUI 必须调用同一个宿主 Controller,禁止重复业务逻辑;Controller 不作为对话模型工具暴露。 ## 11. MA 状态模型 @@ -381,7 +382,7 @@ type AgoraMemoryV2State = { ## 12. Controller 改造 -现有 `AgoraMemoryController` 升级为 v2,TUI、命令和 agent tool 共同复用: +现有 `AgoraMemoryController` 升级为 v2,由 TUI 和命令在宿主控制面共同复用: ```text capabilities() @@ -577,7 +578,7 @@ v2 必需 capability: ## 20. 关闭标准 - MA 不再以 `writable_patch_family` 作为 Memory Console 主交互。 -- TUI、命令、agent tool 全部复用同一个 v2 Controller。 +- TUI 和命令全部复用同一个 v2 Controller,普通模型请求不包含该 Controller 的工具 schema。 - 具名 Memory、多挂载、多目标内化、自动目标、回滚、revision 验证全部可用。 - 所有挂载成功都有真实 chat metadata 证据。 - Patch-only 切换不重启 Agora、不显示模型重新加载。 @@ -616,7 +617,7 @@ Agora v2 当前证据: - `providerState` 持久化框架。 - StatusBar 第一行 Context Usage。 - Memory Console 的 modal、键盘导航和 Activity 区域。 -- 命令注册和 agent tool 暴露框架。 +- 命令注册和宿主 Controller 调用框架。 - 现有 PTY、provider runtime、普通 Provider 和 Context Usage 测试基础。 ### 22.2 必须替换 @@ -652,7 +653,7 @@ Agora v2 当前证据: - [ ] 名称冲突保留输入并允许原地重试。 - [ ] StatusBar 显示 Memory 名称、版本、+N、verified/pending/stale 和 Activity。 - [ ] 实现 mounted badge 流动高光及 NO_COLOR/reduced-motion 降级。 -- [ ] 重写 `/memory` 命令并确保与 TUI、agent tool 共用 Controller。 +- [ ] 重写 `/memory` 命令并确保与 TUI 共用宿主 Controller,且不进入模型工具列表。 ### Automatic intake diff --git a/src/agent.ts b/src/agent.ts index 4cf9387..12359b4 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -639,263 +639,6 @@ builtinTools.set('enter_plan_mode', { }, }); -function agoraMemoryTool( - name: string, - description: string, - parameters: Record, - handler: (args: Record) => Promise<{ content: string; isError: boolean }> -): BuiltinTool { - return { - definition: { - type: 'function', - function: { - name, - description, - parameters, - }, - }, - handler, - }; -} - -function registerAgoraMemoryTools( - tools: Map, - controller: AgoraMemoryController -): void { - const memoryMountParameters = { - type: 'object', - properties: { - profile_id: { type: 'string', description: 'Agora MemoryProfile id' }, - memory_ids: { - type: 'array', - items: { type: 'string' }, - description: 'Named Memory ids to mount together', - }, - scope: { type: 'string', enum: ['user', 'project', 'conversation'] }, - }, - required: ['memory_ids'], - additionalProperties: false, - }; - tools.set( - 'agora_memory_status', - agoraMemoryTool( - 'agora_memory_status', - '查看当前 Agora provider 的真实 MemoryPatch 状态。状态来自最近一次 Agora chat_complete metadata。', - { type: 'object', properties: {}, additionalProperties: false }, - (args) => controller.status(args) - ) - ); - if (!controller.getCapabilities().memoryV2) { - tools.set( - 'agora_memory_mount', - agoraMemoryTool( - 'agora_memory_mount', - 'Legacy Agora MemoryProfile/MemoryPatch 挂载;能力受限,成功后仍需下一次 chat metadata 验证。', - { - type: 'object', - properties: { - profile_id: { type: 'string' }, - active_memory_patch_ids: { type: 'array', items: { type: 'string' } }, - writable_patch_family: { type: 'string' }, - }, - required: ['profile_id', 'active_memory_patch_ids'], - additionalProperties: false, - }, - (args) => controller.mount(args) - ) - ); - tools.set( - 'agora_memory_internalize', - agoraMemoryTool( - 'agora_memory_internalize', - 'Legacy 单目标 Memory intake;必须显式提供 Profile 和 writable family。', - { - type: 'object', - properties: { profile_id: { type: 'string' }, into: { type: 'string' } }, - required: ['profile_id', 'into'], - additionalProperties: false, - }, - (args) => controller.internalize(args) - ) - ); - tools.set( - 'agora_memory_disable', - agoraMemoryTool( - 'agora_memory_disable', - 'Legacy MemoryProfile disable。', - { type: 'object', properties: { profile_id: { type: 'string' } }, required: ['profile_id'], additionalProperties: false }, - (args) => controller.disable(args) - ) - ); - tools.set( - 'agora_memory_rollback', - agoraMemoryTool( - 'agora_memory_rollback', - 'Legacy MemoryPatch rollback。', - { - type: 'object', - properties: { profile_id: { type: 'string' }, patch_id: { type: 'string' } }, - required: ['profile_id', 'patch_id'], - additionalProperties: false, - }, - (args) => controller.rollback(args) - ) - ); - return; - } - tools.set( - 'agora_memory_list', - agoraMemoryTool( - 'agora_memory_list', - '列出当前 Agora base model 下的具名 Memory 和 current head。', - { type: 'object', properties: {}, additionalProperties: false }, - async () => ({ content: JSON.stringify({ memories: await controller.listMemories() }, null, 2), isError: false }) - ) - ); - tools.set( - 'agora_memory_create', - agoraMemoryTool( - 'agora_memory_create', - '创建一个名称唯一的 Agora Memory。重名必须把冲突返回给用户,不得自动改名。', - { - type: 'object', - properties: { name: { type: 'string' } }, - required: ['name'], - additionalProperties: false, - }, - async (args) => ({ content: JSON.stringify({ memory: await controller.createMemory(String(args.name)) }, null, 2), isError: false }) - ) - ); - tools.set( - 'agora_memory_rename', - agoraMemoryTool( - 'agora_memory_rename', - '重命名具名 Agora Memory;名称冲突时保留用户选择并请求新名称。', - { - type: 'object', - properties: { memory_id: { type: 'string' }, name: { type: 'string' } }, - required: ['memory_id', 'name'], - additionalProperties: false, - }, - async (args) => ({ content: JSON.stringify({ memory: await controller.renameMemory(String(args.memory_id), String(args.name)) }, null, 2), isError: false }) - ) - ); - tools.set( - 'agora_memory_mount', - agoraMemoryTool( - 'agora_memory_mount', - '挂载 0 到多个具名 Agora Memory。配置后只可声称 pending;下一次 chat_complete 返回相同 ordered patch ids 和 revision 后才可声称 mounted。', - memoryMountParameters, - (args) => controller.mount(args) - ) - ); - tools.set( - 'agora_memory_disable', - agoraMemoryTool( - 'agora_memory_disable', - '禁用 Agora MemoryProfile,并通过一次 Agora chat_complete metadata 验证禁用状态。', - { - type: 'object', - properties: { - profile_id: { type: 'string', description: 'Agora MemoryProfile id' }, - }, - additionalProperties: false, - }, - (args) => controller.disable(args) - ) - ); - tools.set( - 'agora_memory_internalize', - agoraMemoryTool( - 'agora_memory_internalize', - '把当前 Agora session 的一份增量 source 同时内化到一个或多个显式目标;可混合新建 Memory 和增量 Memory。', - { - type: 'object', - properties: { - targets: { - type: 'array', - minItems: 1, - items: { - type: 'object', - properties: { - mode: { type: 'string', enum: ['create', 'increment'] }, - name: { type: 'string' }, - memory_id: { type: 'string' }, - expected_parent_patch_id: { type: ['string', 'null'] }, - output_name: { type: 'string' }, - }, - required: ['mode', 'output_name'], - additionalProperties: false, - }, - }, - source_message_start: { type: 'integer', minimum: 0 }, - source_message_end: { type: 'integer', minimum: 1 }, - }, - required: ['targets'], - additionalProperties: false, - }, - (args) => controller.internalize(args) - ) - ); - tools.set( - 'agora_memory_batch_status', - agoraMemoryTool( - 'agora_memory_batch_status', - '查询多目标 Memory 内化 batch 的逐目标状态;completed/noop 与 review/conflict/failed 必须分别解释。', - { - type: 'object', - properties: { batch_id: { type: 'string' } }, - required: ['batch_id'], - additionalProperties: false, - }, - async (args) => ({ content: JSON.stringify(await controller.getBatchIntake(String(args.batch_id)), null, 2), isError: false }) - ) - ); - tools.set( - 'agora_memory_auto_policy', - agoraMemoryTool( - 'agora_memory_auto_policy', - '设置当前 Memory 组合的自动内化开关和显式具名 Memory 目标;开启时目标列表不能为空。', - { - type: 'object', - properties: { - profile_id: { type: 'string' }, - enabled: { type: 'boolean' }, - target_memory_ids: { type: 'array', items: { type: 'string' } }, - }, - required: ['profile_id', 'enabled', 'target_memory_ids'], - additionalProperties: false, - }, - async (args) => ({ - content: JSON.stringify(await controller.setAutoPolicy( - String(args.profile_id), - Boolean(args.enabled), - Array.isArray(args.target_memory_ids) ? args.target_memory_ids.map(String) : [] - ), null, 2), - isError: false, - }) - ) - ); - tools.set( - 'agora_memory_rollback', - agoraMemoryTool( - 'agora_memory_rollback', - '用 expected current head 执行具名 Memory 的 CAS 回滚。回滚不会自动覆盖当前会话挂载组合。', - { - type: 'object', - properties: { - memory_id: { type: 'string', description: 'Named Memory id' }, - expected_head_patch_id: { type: 'string', description: 'Current head used for CAS' }, - target_patch_id: { type: 'string', description: 'target MemoryPatch id' }, - }, - required: ['memory_id', 'expected_head_patch_id', 'target_patch_id'], - additionalProperties: false, - }, - (args) => controller.rollback(args) - ) - ); -} - function isMutatingTool(name: string): boolean { // Exec commands and write/edit/delete tools should never be storm-suppressed if (DANGER_EXEC_TOOLS.has(name)) return true; @@ -942,10 +685,9 @@ export async function createAgent( const providerCodec = resolveProviderCodec(config.model); const agoraMemoryController = providerRuntime.getMemoryController?.() ?? null; + // Provider control lives in the host UI/commands. It must never be exposed + // to the conversational model as a prompt instruction or tool schema. const activeBuiltinTools = new Map(builtinTools); - if (agoraMemoryController) { - registerAgoraMemoryTools(activeBuiltinTools, agoraMemoryController); - } const mcpTools = mcpToolsToOpenAI(connections); const tools: ChatCompletionTool[] = [ ...mcpTools, @@ -1000,32 +742,11 @@ export async function createAgent( '- Do not write code with security vulnerabilities (injection, XSS, etc.).', '- Do not expose internal state (task stack, system messages) to the user.', ].join('\n'); - const providerPrompt = config.model.provider?.toLowerCase() === 'agora' - ? agoraMemoryController - ? [ - '', - '# Agora Memory', - '- Agora memory is provider-scoped. The user-facing object is a uniquely named Memory; MemoryPatch is its immutable version. Use the agora_memory_* tools only when the user asks to inspect or change Agora memory.', - '- Mount can contain zero or multiple Memory ids. Internalization requires explicit targets and may mix create and increment in one batch; never guess the first mounted target.', - '- A mount action only means pending. Treat memory as mounted only after a later Agora chat_complete returns the requested ordered patch ids and PatchSet revision.', - '- Retry only review/conflict/failed batch targets; never resubmit completed/noop targets.', - '- Do not simulate memory by inserting facts into the prompt.', - ].join('\n') - : [ - '', - '# Agora Memory', - '- Agora provider is active, but Agora MCP memory tools are not all available. Tell the user memory operations require a complete Agora MCP runtime.', - ].join('\n') - : [ - '', - '# Agora Memory', - '- Agora MemoryPatch operations require switching to provider: agora. Do not claim mount/internalize/rollback is available under other providers.', - ].join('\n'); const agentMd = loadAgentMd(cwd); const envInfo = `\n\n# Environment\n当前工作目录: ${cwd}\n平台: ${process.platform}\nNode: ${process.version}`; const systemPrompt = agentMd - ? `${baseSystemPrompt}${providerPrompt}${envInfo}\n\n# Project Context\n${agentMd}` - : `${baseSystemPrompt}${providerPrompt}${envInfo}`; + ? `${baseSystemPrompt}${envInfo}\n\n# Project Context\n${agentMd}` + : `${baseSystemPrompt}${envInfo}`; const store = new MessageStore(); store.init(systemPrompt, options.resumeMessages); diff --git a/test/agora-provenance-boundary.test.ts b/test/agora-provenance-boundary.test.ts new file mode 100644 index 0000000..41c8eb6 --- /dev/null +++ b/test/agora-provenance-boundary.test.ts @@ -0,0 +1,113 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { createAgent } from '../src/agent.js'; +import type { AgentConfig } from '../src/mcp/types.js'; + +test('Agora keeps memory control metadata outside the conversational model request', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-agora-provenance-')); + const fakeAgora = path.join(tmp, 'fake-agora.mjs'); + const capturePath = path.join(tmp, 'chat-request.json'); + fs.writeFileSync(fakeAgora, fakeAgoraSource(capturePath), 'utf8'); + + const config: AgentConfig = { + model: { + provider: 'agora', + baseURL: 'mcp-stdio://agora', + apiKey: 'agora-mcp', + model: 'base-a', + agoraRuntime: { + command: process.execPath, + args: [fakeAgora], + dataRoot: path.join(tmp, 'data'), + }, + agoraMemory: { + memoryProfile: 'profile-a', + memoryEnabled: true, + }, + }, + mcpServers: {}, + }; + + const agent = await createAgent(config, []); + try { + assert.ok(agent.getMemoryController?.(), 'host control plane should remain available'); + for await (const _event of agent.chat('项目代号是什么?')) { + // Drain one real provider request through MCP stdio. + } + + const request = JSON.parse(fs.readFileSync(capturePath, 'utf8')) as { + messages: Array<{ role: string; content?: unknown }>; + tools?: unknown[]; + metadata?: Record; + }; + const modelVisibleRequest = JSON.stringify({ + messages: request.messages, + tools: request.tools ?? [], + }); + + assert.doesNotMatch( + modelVisibleRequest, + /Agora Memory|MemoryPatch|MemoryProfile|PatchSet|agora_memory_/, + ); + assert.equal(request.metadata?.memory_profile, 'profile-a'); + assert.equal(request.metadata?.memory_enabled, true); + } finally { + await agent.close(); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +function fakeAgoraSource(capturePath: string): string { + return `import fs from 'node:fs'; +let buffer = ''; +const capturePath = ${JSON.stringify(capturePath)}; +const names = ['doctor', 'runtime_capabilities', 'models_list', 'chat_complete']; +const payloads = { + doctor: { status: 'ok', version: '0.2.0', contract: { host_protocol_major: 1 } }, + runtime_capabilities: { status: 'ok', contract: { runtime_version: '0.2.0', host_protocol_major: 1, registry_schema_version: 3, capabilities: {} } }, + models_list: { models: [{ id: 'base-a', name: 'Base A', status: 'available' }] }, + chat_complete: { + status: 'completed', + id: 'chat-a', + session_id: 'session-a', + message: { role: 'assistant', content: '项目代号是 Alpha。' }, + output_text: '项目代号是 Alpha。', + finish_reason: 'stop', + active_memory_patch_ids: ['patch-a'], + metadata: { + session_id: 'session-a', + memory: { enabled: true, profile_id: 'profile-a', active_memory_patch_ids: ['patch-a'] }, + memory_runtime: { patchset_revision: 2 }, + }, + }, +}; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + buffer += chunk; + while (true) { + const index = buffer.indexOf('\\n'); + if (index < 0) return; + const line = buffer.slice(0, index).trim(); + buffer = buffer.slice(index + 1); + if (!line) continue; + const request = JSON.parse(line); + if (request.method === 'notifications/initialized') continue; + let result = {}; + if (request.method === 'tools/list') { + result = { tools: names.map((name) => ({ name, inputSchema: { type: 'object' } })) }; + } else if (request.method === 'resources/list') { + result = { resources: [] }; + } else if (request.method === 'tools/call') { + if (request.params.name === 'chat_complete') { + fs.writeFileSync(capturePath, JSON.stringify(request.params.arguments), 'utf8'); + } + result = { content: [{ type: 'text', text: JSON.stringify(payloads[request.params.name] || { status: 'ok' }) }] }; + } + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\\n'); + } +}); +`; +} From cb4586a1e80c30137960ee05fd6f65cc94b1c377 Mon Sep 17 00:00:00 2001 From: pianoboy <40992808+zhuqingyv@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:59:00 +0800 Subject: [PATCH 4/5] fix: prevent hidden provenance speculation (#45) --- src/agent.ts | 9 +++++++-- test/agora-provenance-boundary.test.ts | 2 ++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/agent.ts b/src/agent.ts index 12359b4..92c5268 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -742,11 +742,16 @@ export async function createAgent( '- Do not write code with security vulnerabilities (injection, XSS, etc.).', '- Do not expose internal state (task stack, system messages) to the user.', ].join('\n'); + const epistemicBoundaryPrompt = [ + '', + '# Epistemic boundary', + '- You cannot inspect hidden request construction or the internal cause of a generated fact. When asked why you know something, rely only on visible conversation evidence or say that you cannot inspect its origin.', + ].join('\n'); const agentMd = loadAgentMd(cwd); const envInfo = `\n\n# Environment\n当前工作目录: ${cwd}\n平台: ${process.platform}\nNode: ${process.version}`; const systemPrompt = agentMd - ? `${baseSystemPrompt}${envInfo}\n\n# Project Context\n${agentMd}` - : `${baseSystemPrompt}${envInfo}`; + ? `${baseSystemPrompt}${epistemicBoundaryPrompt}${envInfo}\n\n# Project Context\n${agentMd}` + : `${baseSystemPrompt}${epistemicBoundaryPrompt}${envInfo}`; const store = new MessageStore(); store.init(systemPrompt, options.resumeMessages); diff --git a/test/agora-provenance-boundary.test.ts b/test/agora-provenance-boundary.test.ts index 41c8eb6..44776d7 100644 --- a/test/agora-provenance-boundary.test.ts +++ b/test/agora-provenance-boundary.test.ts @@ -52,6 +52,8 @@ test('Agora keeps memory control metadata outside the conversational model reque modelVisibleRequest, /Agora Memory|MemoryPatch|MemoryProfile|PatchSet|agora_memory_/, ); + const systemMessage = request.messages.find((message) => message.role === 'system'); + assert.match(String(systemMessage?.content), /cannot inspect its origin/); assert.equal(request.metadata?.memory_profile, 'profile-a'); assert.equal(request.metadata?.memory_enabled, true); } finally { From 029fbf2541661531b9b03a92b7a00ee0b70213fd Mon Sep 17 00:00:00 2001 From: zhuqingyv Date: Tue, 14 Jul 2026 02:03:05 +0800 Subject: [PATCH 5/5] fix: render Agora generation as indeterminate progress --- src/cli/hooks/useAgent.ts | 10 ++++++- test/cli-use-agent.test.ts | 23 ++++++++++++++- test/cli-ux.test.ts | 57 +++++++++++++++++++++++++++++++++++++- 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/src/cli/hooks/useAgent.ts b/src/cli/hooks/useAgent.ts index 33a210b..25a911e 100644 --- a/src/cli/hooks/useAgent.ts +++ b/src/cli/hooks/useAgent.ts @@ -25,6 +25,14 @@ function progressLabel(message: string, progress?: number, total?: number): stri return message; } +function providerProgressLabel(event: Extract): string { + // Agora reports coarse lifecycle milestones (5/20/40/55/65), not measured + // completion. Rendering those values as percentages makes a non-streaming + // generation look frozen at 65% until the whole response arrives. + if (event.provider === 'agora') return event.message; + return progressLabel(event.message, event.progress, event.total); +} + export interface PendingConfirm { requestId: string; cmd: string; @@ -83,7 +91,7 @@ export function applyAgentEvent( break; case 'provider:progress': store.updateThinking({ - event: progressLabel(event.message, event.progress, event.total), + event: providerProgressLabel(event), isThinking: false, thoughtDurationMs: null, }); diff --git a/test/cli-use-agent.test.ts b/test/cli-use-agent.test.ts index ee93a4f..f905258 100644 --- a/test/cli-use-agent.test.ts +++ b/test/cli-use-agent.test.ts @@ -96,7 +96,7 @@ test('applyAgentEvent: retry attempts keep n/5 semantics after the first wait', assert.equal(store.getState().thinking?.event, '等待模型响应(重试 2/5)· 180s 超时'); }); -test('applyAgentEvent: provider progress updates transient status without thinking label', () => { +test('applyAgentEvent: Agora milestones stay indeterminate instead of showing fake percentages', () => { const store = createUiStore(); store.startThinking(); store.updateThinking({ isThinking: true }); @@ -109,6 +109,7 @@ test('applyAgentEvent: provider progress updates transient status without thinki phase: 'model_load', message: 'Agora · 加载本地模型 qwen3.6-35b-a3b-q4', progress: 40, + total: 100, }, {} ); @@ -120,6 +121,26 @@ test('applyAgentEvent: provider progress updates transient status without thinki assert.equal(state.messages.length, 0); }); +test('applyAgentEvent: determinate non-Agora progress still shows a measured percentage', () => { + const store = createUiStore(); + store.startThinking(); + + applyAgentEvent( + store, + { + type: 'provider:progress', + provider: 'remote-download', + phase: 'downloading', + message: '下载模型', + progress: 40, + total: 100, + }, + {} + ); + + assert.equal(store.getState().thinking?.event, '下载模型 · 40%'); +}); + test('applyAgentEvent: progress is rendered as visible system message', () => { const store = createUiStore(); store.startThinking(); diff --git a/test/cli-ux.test.ts b/test/cli-ux.test.ts index 712727f..7ae4a3c 100644 --- a/test/cli-ux.test.ts +++ b/test/cli-ux.test.ts @@ -165,6 +165,49 @@ test('CLI UX PTY: queued startup message auto-sends once after Agora becomes rea } }); +test('CLI UX PTY: Agora generation uses an indeterminate spinner without fake 65%', async (t) => { + if (process.env.MA_RUN_PTY_TESTS !== '1') { + t.skip('PTY UI verification runs only in the explicit MA_RUN_PTY_TESTS E2E lane'); + return; + } + if (!canSpawnPty()) { + t.skip('node-pty cannot spawn a basic /bin/echo process in this environment'); + return; + } + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-cli-agora-progress-')); + const fakeAgora = path.join(tmp, 'agora-progress.mjs'); + const configPath = path.join(tmp, 'config.json'); + fs.writeFileSync(fakeAgora, fakeAgoraV2Source(0, 800), 'utf8'); + fs.writeFileSync(configPath, JSON.stringify({ + defaultProfile: '', + model: { + provider: 'agora', baseURL: 'mcp-stdio://agora', model: 'base-a', apiKey: 'agora-mcp', + agoraRuntime: { command: process.execPath, args: [fakeAgora] }, + }, + mcpServers: {}, + })); + const builtCli = path.join(repoRoot, 'dist', 'src', 'cli', 'index.js'); + const child = pty.spawn(process.execPath, [builtCli, 'chat', '--config', configPath], { + name: 'xterm-256color', cols: 100, rows: 24, cwd: repoRoot, + env: { ...process.env, NO_COLOR: '1' }, + }); + let output = ''; + child.onData((data) => { output += data; }); + try { + await waitFor(() => /runtime ready/.test(stripAnsi(output)) && /❯/.test(stripAnsi(output)), 5000); + child.write('show-indeterminate-generation'); + await new Promise((resolve) => setTimeout(resolve, 75)); + child.write('\r'); + await waitFor(() => /Agora · 生成回复/.test(stripAnsi(output)), 5000); + const duringGeneration = stripAnsi(output); + assert.doesNotMatch(duringGeneration, /生成回复[^\n]*65%/); + await waitFor(() => /queued-auto-response/.test(stripAnsi(output)), 5000); + } finally { + child.kill('SIGINT'); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + test('CLI UX PTY: DeepSeek status bar uses provider capability instead of 33k fallback', async (t) => { if (process.env.MA_RUN_PTY_TESTS !== '1') { t.skip('PTY UI verification runs only in the explicit MA_RUN_PTY_TESTS E2E lane'); @@ -322,7 +365,7 @@ test('CLI UX PTY: Agora Memory v2 console is name-first and readable at 80 colum } }); -function fakeAgoraV2Source(initializeDelayMs = 0): string { +function fakeAgoraV2Source(initializeDelayMs = 0, generationDelayMs = 0): string { return `let buffer = ''; const names = ${JSON.stringify([ 'doctor', 'runtime_capabilities', 'models_list', 'models_status', 'models_download', 'chat_complete', @@ -357,6 +400,18 @@ process.stdin.on('data', (chunk) => { else if (req.method === 'tools/call') result = { content: [{ type: 'text', text: JSON.stringify(payloads[req.params.name] || { status: 'ok' }) }] }; const send = () => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: req.id, result }) + '\\n'); if (req.method === 'initialize' && ${initializeDelayMs} > 0) setTimeout(send, ${initializeDelayMs}); + else if (req.method === 'tools/call' && req.params.name === 'chat_complete' && ${generationDelayMs} > 0) { + const progressToken = req.params?._meta?.progressToken; + if (progressToken !== undefined) { + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', method: 'notifications/progress', params: { + progressToken, progress: 65, total: 100, + message: JSON.stringify({ event: { operation: 'chat.complete', phase: 'generation', progress: 65, details: { session_id: 'session-a' } } }), + }, + }) + '\\n'); + } + setTimeout(send, ${generationDelayMs}); + } else send(); } });