fix(teacher): 改善只读工具分页与读取预算
This commit is contained in:
@@ -12,6 +12,8 @@
|
||||
|
||||
## Scope
|
||||
|
||||
- 2026-09-27 user authorizes improving the three readonly tools: bounded directory trees, continuous UTF-8 pages with exact continuation, and useful byte/round budgets with evidence-based final answers. Retain prior activity correction; production deployment remains outside this authorization.
|
||||
|
||||
- 2026-09-27 follow-up also covers the confirmed duplicate tool-activity rows and false unfinished states across native interrupted/resumed Runs. Keep local reading, student charging and quota unchanged.
|
||||
|
||||
- Resumed 2026-09-27 for the continuing expired-read screenshot. First verify the actual installed runtime, request time and rollout boundary; make no additional product changes without a new reproduced defect.
|
||||
@@ -20,6 +22,8 @@
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- Current same-task Concurrent/Planning Gates Passed: official check/start/status match feature identities above; previously loaded entry, integrated teacher architecture/domain/decisions/evidence and all readable peer scopes reused, current rules/source contracts refreshed. No semantic peer conflict. User approval supersedes six-batch granularity for explicitly advertised new read protocol; installed legacy clients remain supported. Plan: regression cases for directory discovery and lossless pages; implement bounded Main tools and durable cloud budget; verify targeted tests, typecheck/build and real HTTP/PostgreSQL/worker completion; fresh independent review before Yuxi commit. Production revision remains unverified, not a blocker to the authorized implementation.
|
||||
|
||||
- Project Context Loaded (same-task continuation): feature ownership/branch/worktree/base unchanged and official touch succeeded. Entry, active record, integrated teacher architecture/domain and evidence, current AGENTS and source contracts reused; peer packaging 1.6.9 is complete, integration and historical peers stay read-only. Product goal remains source-grounded readonly cloud consultation; relevant decision is native checkpoint continuation with six read batches. Main cloud-runner/cloud-activity and focused tests own this correction. No conflicting peer semantic decision identified; production worker revision is unknown. Planning Gate: Passed. Plan: replay typed interruption/resume events, assert one row per logical tool call and actual local outcome, fix identity/error projection, then targeted tests/typecheck/build.
|
||||
|
||||
- Same-task resume through official check/start/status passed. Required memory context is reused with refreshed own/peer records; 1.6.9 packaging is complete but explicitly excludes installation. No new subagent consent, installation, restart or production deployment is implied by this diagnostic step.
|
||||
@@ -30,6 +34,8 @@
|
||||
|
||||
## Outcome
|
||||
|
||||
- Authorized paged-read improvement (2026-09-27): Continuous JSON text pages replace middle-deleting tool snippets; directory trees default to depth 4 (max 6), breadth-first lazy entries bounded per page. UTF-8 files stream through the existing containment owner, independent of UI preview cap; Unicode column cursors and next preserve long-line content. Main advertises read_protocol 2, returns complete tool batches within 8 KiB/page and 64 KiB/question, and retains the 12-batch hard guard. Prior logical-tool activity correction remains included.
|
||||
|
||||
- Follow-up correction: activity IDs now use this question plus logical tool_call_id, so native continuation Runs share one row. Raw tool-error does not establish failure because LangGraph emits it for interrupts; actual local results, finished tool results and failed/cancelled question termination own the visible state. Pending rows close as unfinished if the question stops. File execution, read quota, billing and answer projection are unchanged. Existing saved history is not rewritten.
|
||||
|
||||
- 08:52 +08:00: user independently upgraded to installed/running 1.6.9. The new failed request contains 36 rows but 19 distinct tool_call_id values: 17 pairs of failed/completed, plus two uncompleted seventh-batch calls. Current client update is no longer a blocker; the following 08:15 observations describe the previous installation. No tool arguments/results are retained locally, so identical path/range repetition cannot be concluded.
|
||||
@@ -45,6 +51,14 @@
|
||||
|
||||
## Verification
|
||||
|
||||
- Final independent review: PASS, no remaining material findings. The reviewer checked complete relevant diffs, prior logical-tool activity correction, protocol schemas, final-answer wire behavior, idempotence, original identity/deadline, filesystem scope and documentation. Independently confirmed 72 client cases and both protocol-2 HTTP/PG/Redis/worker cases, including exhausted-round 422 and changed-result replay 409. The same supplier tool_call_id identity assumption already governs Yuxi audit; no speculative protocol extension added.
|
||||
|
||||
- Reviewer found two concrete defects in the initial new reader, fixed before handoff: pre-truncating a directory to 2000 entries made later paths unreachable and Gradle caches hid Android source; yielding complete lines decoded a whole 6 MB minified line before returning an 8 KiB page. New red cases reproduced both directory failures and decodedBytes=6000000. Reader now lazily emits breadth-first directory entries and skips .gradle during exploration; files supply normalized UTF-8 chunks and the pager tracks positions inside them. Green includes late directory entries, Android source, bounded first-page decoding and cross-chunk CRLF positions.
|
||||
|
||||
- Main-specific type inspection: pnpm run typecheck covers Renderer only. Additional tsc -p tsconfig.node.json --noEmit reports 97 diagnostics across the existing Main project (including composite include omissions). Read-only TypeScript compiler comparison using git HEAD inputs reports current=97, baseline=97, added=[], removed=[]; no new diagnostic was introduced. Main full typecheck is therefore not reported as passing. No unrelated tsconfig or source fix made.
|
||||
|
||||
- Paged-read improvement: Red: nested Android tree failed to expose app/src/main/AndroidManifest.xml and UTF-8 original contained the middle-deletion marker. Green: pnpm exec vitest run tests/unit/coding-teacher-read-tools.test.ts tests/unit/coding-teacher-cloud.test.ts tests/unit/teacher-cloud-activity.test.ts tests/unit/coding-project-files.test.ts --maxWorkers=1: 72 passed after review corrections. A >256 KiB Chinese/emoji/escaped long-line file reconstructs exactly through pages; byte budget returns all 10 call outcomes and final answer. pnpm run typecheck and pnpm run build:vite passed (existing chunk/import/Browserslist warnings). Pure Main changes are verified through actual filesystem/runner tests; renderer Host API mocks cannot prove Main continuity.
|
||||
|
||||
- Follow-up explanation (2026-09-27): same-task check/start/status match Identity and feature ownership; 119 peer records readable, prior semantic scope/context reused without a new conflict. Planning Gate Passed for bounded read-only analysis. Rechecked cloud-runner rounds and Yuxi read_round/MAX_READ_ROUNDS: the cap is six result batches per question, not six files or UI activity rows. One batch increments before local execution, even when its result is an error. directory() lists one level; read tools default to 60 lines (maximum 100) and 2400 UTF-8 bytes. excerptTeacherText removes the middle of oversized results. These supported mechanisms can require more sequential discovery/partial-content reads; local history still lacks parameters, so their contribution to this exact question is unproven. Proposed improvement is more useful directory/continuous-page results plus a suitable explicit budget and final answer; no budget/tool behavior changed or new design accepted in this explanatory turn. No tests rerun because only source facts/documentation changed.
|
||||
|
||||
- Follow-up red: pnpm exec vitest run tests/unit/coding-teacher-cloud.test.ts -t "one .* local read|pending tool as failed" --maxWorkers=1 failed all three cases before the fix. A successful local read produced before:file failed and after:file completed, reproducing the screenshot.
|
||||
@@ -59,6 +73,8 @@
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Implementation and verification complete; fresh independent read-only review (review_paged_reads) passed after two concrete reader defects were corrected. Reviewer independently reran all 72 client cases and the two protocol-2 native integration cases. No merge, production deployment or installed-client update performed. Roll out Yuxi API and worker first, then protocol-2 Makelore. Actual supplier behavior and deployed versions still need production acceptance. Previously recorded production-diagnosis block remains a rollout limitation, not a blocker to the newly authorized source implementation.
|
||||
|
||||
- Client follow-up patch is ready for integration; it is not merged, packaged or installed. Current installed 1.6.9 predates this activity fix. Existing failed history is retained rather than rewritten.
|
||||
- Paired Yuxi completion diagnosis remains blocked on actual API/worker revision or production request evidence. Public health returns only package version 0.7.3, not commit or worker revision; local Kubernetes has no current-context. The prior question asking whether 3a278ff was deployed remains unanswered. Do not attribute the new failure to an outdated client: user independently installed 1.6.9 before the 08:52 request.
|
||||
- WS read-only task 20260927-teacher-read-gateway-41acb598 confirms model-session returns One API base URL and short authorization; Yuxi sends the model body directly to that relay. No WS model-body rewriting is present on this path. Actual relay/provider payload remains unobserved.
|
||||
@@ -67,6 +83,8 @@
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- Target: integrated consultation read-tool and runtime contract. Proposal: protocol 2 uses bounded directory trees, lossless continuous text pages and precise next positions; budgets are 12 batches / 64 KiB successful results / 8 KiB per page, final-answer at less than 512 bytes remaining. Legacy clients retain six batches and old tool schemas. Evidence: filesystem reconstruction and native HTTP/PG/worker final-state tests. Impact: avoid inefficient directory drilling and ambiguous truncation; rollout server first. User explicitly authorized this tool redesign, superseding the earlier six-batch restriction for new clients. No unresolved semantic conflict.
|
||||
|
||||
- Target: consultation activity contract in integrated state/system overview. Proposal: one question plus tool_call_id identifies a logical tool through checkpoint continuation; raw tool-error is provisional, not proof of failure. Evidence: 36 stored rows = 19 call IDs (17 failed/completed pairs) and red/green runner regression. Impact: tool counts represent calls and successful reads no longer appear unfinished. No semantic conflict; no new product-direction approval.
|
||||
|
||||
- Target: integrated consultation runtime boundary. Proposal: six valid local batches still complete normally, while protocol-invalid batches, stale context and exhausted read quota have separate error semantics. Evidence: deterministic red/green and paired worker test. Impact: future diagnosis must not infer context expiry from quota exhaustion or UI tool count. No semantic conflict or additional product-direction approval needed.
|
||||
|
||||
@@ -163,7 +163,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p
|
||||
|
||||
- 新话题可选择运营下发的 Yuxi 智能体,默认使用指定智能体;提示词、Skills、知识库和模型配置由 Yuxi 管理,运营同步后生成新版本,已有话题固定原版本。Yuxi 智能体模型费用由当前学生的编程词元点数支付,个人 Agents 模块仍由创建者付费;不要求学生开通个人云智能体模块。停用的智能体仍可查看历史,不能借另一位智能体的可用状态继续提问。
|
||||
- Main 为智能体每轮请求绑定当前账号、项目和所选操作会话,提供 `list_project_files`、`read_project_file`、`read_conversation` 三个只读工具。云端通过持久中断请求整批读取,Main 返回配对结果并续接同一问题;若原运行仍在释放资源,保留同一批读取结果等待续接,等待期间仍支持取消并受原问题时限约束。本地旧版智能体也支持相同读取边界。可读当前项目源码及 `.makelore/project.json`,拒绝其他 `.makelore` 历史、Git 内部数据和越界路径;不提供写文件或命令执行。这些能力不按名称或人设区分。
|
||||
- 文件预览最多 256 KiB,每次默认 60 行、最多 100 行及 2400 字节;长操作消息和智能体历史保留首尾节选及消息 ID,可按需读取本轮捕获的完整公开原文。每题最多六批本地读取;不上传 Pi 原始日志或思考。云端保留智能体对话及收到的片段,完整工程仍在本机。
|
||||
- 本地读取使用连续分页:目录按层展开,默认四层、最多六层,跳过 `.gradle` 等生成目录;条目随页面按需产出,深层目录可单独查询;文件和会话默认每页 120 行、最多 400 行,返回连续原文、起始行列与精确 next 游标,列按 Unicode 字符计数、换行统一为 LF。单个云端结果最多 8 KiB,长行也能继续读取;不受文件 UI 的 256 KiB 预览限制。会话索引保留消息 ID 和摘要,按 ID 读取完整公开原文。每题最多十二批、累计成功结果 64 KiB(包含结果元数据),不足 512 字节时收尾;不上传 Pi 原始日志或思考。云端保留智能体对话及收到的片段,完整工程仍在本机。本地旧版模型仍按自身上下文和六批额度缩小结果。
|
||||
|
||||
- Code 课堂左侧项目栏默认宽 171px,可拖动右边缘在 128–256px 间调整,双击恢复默认并在本机记住宽度;标题栏同步对齐。项目名与会话名超长省略并可悬停查看;右侧咨询栏默认宽 508px,可拖动至 640px,紧凑窗口仍使用覆盖式咨询。
|
||||
- 客户端没有内置老师/朋友角色。已下发智能体以头像和名称并排显示在标题栏,点击后打开对应智能体的讨论;数量较多时横向滚动,咨询面板不再提供智能体下拉框。名称、头像、简介、欢迎语和推荐问题均由服务端提供。首次使用默认选择运营指定项,各智能体的未发送草稿分别保存,已有话题保持绑定的版本;旧的未分配项目草稿可展开查看,不自动转给其他智能体。打开或切换只读取配置和历史,不触发模型;咨询栏支持调整宽度和重新打开。
|
||||
@@ -175,7 +175,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p
|
||||
- 想法板区分已留下、智能体建议和暂放内容,可由学生采纳、暂放、选择先想哪项。“把想法理一理”明确转换为智能体归纳的结构图,并可“回去补充想法”。“只聊天”暂停整理,“先这些”暂时结束并保留未确定内容,“接着改”恢复原工具;这些操作只影响智能体讨论,不创建项目分支、不执行作品修改。状态、版本和历史快照按当前账号/项目/话题保存。
|
||||
- Main 校验结构、引用和内容长度,拒绝未经学生选择的类型变更与旧版本更新。结构化回复在完整校验后一次应用;生成中显示简短处理状态,停止、失败或无效组件保留上一份内容。普通 Markdown 链接、JSON 对象/数组和代码示例保持正文,仅讨论协议的顶层字段或专用围栏进入组件解析。解析失败仍保留完整的可读 reply,并将本次原始回答保存为默认折叠、字面显示的“查看收到的原始内容”,不把它再次加入模型上下文;既有丢失原文的历史不能恢复。
|
||||
- 咨询正文支持 Markdown 标题、列表、表格、代码围栏、HTTP(S) 链接/图片和数学公式,长代码与表格在栏内横向滚动,不执行 HTML。云端正文维持 string 合同,未知对象不会猜测转成回答。工具活动只从云端主线程的类型化工具事件及 Main 本地读取过程获得,按本次问题/工具调用身份合并名称和状态,跨云端暂停、续接仍只计一次;暂停读取不算失败,状态以实际读取结果或问题终态为准,单独折叠显示;参数、结果原文和内部错误不混入回复,思考内容不作为正文。客户端仅保存自己实际收到的工具状态,不能补回旧历史或断线期间已经过期的事件。
|
||||
- 云端咨询最多执行六批本地读取。配套 Yuxi 在最后一批结果返回后以禁止工具调用的模型请求生成答案;Main 仍拒绝第七批读取,并分别提示读取达到上限、上下文失效或读取请求格式无效。
|
||||
- 云端咨询在 local_context 声明 read_protocol=2;需先部署配套 Yuxi API 和 worker,再升级客户端。云端持久累计读取字节及批次,每轮告知模型剩余额度;预算耗尽后消费最后一批结果,并以 tool_choice=none 要求根据现有证据形成答案和说明缺口。Main 限制实际返回量并拒绝第十三批读取,区分读取达到上限、上下文失效及格式无效。服务端对未声明协议的已安装旧客户端保留原工具参数与六批边界。
|
||||
- 智能体人设、职责、提示词和 Skills 由发布配置决定。Main 不追加固定教学基线、不生成“小麦”、不清空所选智能体的 Skills,只添加真实工具能力、上下文边界和本轮界面协议。智能体名称叫“老师”“朋友”或“代码顾问”不改变调用路径或权限。
|
||||
- 每轮格式由 Main 的对应意图协议决定;`discussion.ts` 为支持组件的请求注入唯一 `{reply, quickReplies, tool}` 协议。运营教学补充不另写字段协议或要求始终纯文字。工具内讨论保留类型、稳定 ID、未修改内容和采纳状态;暂停/未进入时 `tool:null`,没有实质变化时也可保留原内容。结构图、流程和对照目前没有独立的采纳/来源字段,待定、建议与预测只能在展示文字中明确,不能据此推导已确认共识。
|
||||
- 当前输入是按预算截取的所选操作对话已完成分支文本、明确引用、咨询历史和适用的当前组件,并支持上述只读原文恢复;没有跨全部操作会话自动读取,也未向操作 Agent 注入咨询共识。“先这些”仍仅结束本轮整理,保留各条目状态,不确认全部候选、不自动制作。提示词不宣称这些系统能力已接通,也不声称看过未提供的画面或试玩过作品。Yuxi 话题经云端原生执行,旧版本地智能体由 Main 调用计费网关;均不创建本地 Pi 编程 worker。
|
||||
|
||||
@@ -277,6 +277,32 @@ export class CodingProjectFileService {
|
||||
}));
|
||||
}
|
||||
|
||||
async *textChunks(projectPath: string, requestedPath: string, signal: AbortSignal): AsyncGenerator<string> {
|
||||
const relativePath = normalizeRelativePath(requestedPath);
|
||||
const target = await containedExistingTarget(projectPath, relativePath);
|
||||
if (!(await lstat(target)).isFile()) throw new Error('Project file path is not a file');
|
||||
const handle = await open(target, 'r');
|
||||
const stream = handle.createReadStream({ autoClose: false });
|
||||
const decoder = new TextDecoder('utf-8', { fatal: true });
|
||||
let pendingCR = '';
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
signal.throwIfAborted();
|
||||
let text = pendingCR + decoder.decode(chunk as Buffer, { stream: true });
|
||||
if (text.includes('\0')) throw new Error('Project file is not UTF-8 text');
|
||||
// A Windows newline can straddle two filesystem chunks.
|
||||
pendingCR = text.endsWith('\r') ? '\r' : '';
|
||||
if (pendingCR) text = text.slice(0, -1);
|
||||
if (text) yield text.replaceAll('\r\n', '\n');
|
||||
}
|
||||
const final = pendingCR + decoder.decode();
|
||||
if (final) yield final;
|
||||
} finally {
|
||||
stream.destroy();
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async content(projectPath: string, requestedPath: string): Promise<CodingProjectFileContent> {
|
||||
const relativePath = normalizeRelativePath(requestedPath);
|
||||
const result = await readBoundedFile(projectPath, relativePath, MAX_CONTENT_BYTES);
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
type TeacherAccount,
|
||||
} from './config-client';
|
||||
import type { TeacherModelMessage } from './context';
|
||||
import { createTeacherReadTools, type TeacherReadAccess } from './read-tools';
|
||||
import { createTeacherReadTools, type TeacherReadAccess, TEACHER_READ_PROTOCOL, TEACHER_READ_ROUNDS,
|
||||
TEACHER_READ_BYTES, TEACHER_READ_PAGE_BYTES, TEACHER_READ_MIN_BYTES } from './read-tools';
|
||||
|
||||
interface TeacherSession {
|
||||
access_token: string;
|
||||
@@ -209,6 +210,7 @@ export function prepareCloudTeacher(
|
||||
|| currentRequest?.presentation === 'discussion-v1';
|
||||
const localContext = {
|
||||
id: requestId,
|
||||
read_protocol: TEACHER_READ_PROTOCOL,
|
||||
scope: {
|
||||
project_id: topic.projectId,
|
||||
source_session_id: currentRequest?.sourceConversationId ?? topic.sourceConversationId,
|
||||
@@ -274,7 +276,8 @@ export function prepareCloudTeacher(
|
||||
}
|
||||
let runId = identifier(queued.run_id),
|
||||
cursor = '0-0',
|
||||
rounds = 0;
|
||||
rounds = 0,
|
||||
readBytes = 0;
|
||||
let runText = '',
|
||||
messageId = '';
|
||||
while (Date.now() < deadline) {
|
||||
@@ -308,7 +311,7 @@ export function prepareCloudTeacher(
|
||||
);
|
||||
if (!Array.isArray(pending.calls) || !pending.calls.length || pending.calls.length > 32)
|
||||
throw new TeacherError(502, 'teacher_protocol_invalid', '智能体读取请求格式无效,请重试。');
|
||||
if (++rounds > 6)
|
||||
if (++rounds > TEACHER_READ_ROUNDS)
|
||||
throw new TeacherError(
|
||||
409,
|
||||
'teacher_read_limit',
|
||||
@@ -321,7 +324,11 @@ export function prepareCloudTeacher(
|
||||
if (typeof call.tool_call_id !== 'string' || typeof call.name !== 'string')
|
||||
throw new TeacherError(502, 'teacher_protocol_invalid', '智能体读取请求无效。');
|
||||
reportActivity({ id: requestId + ':' + call.tool_call_id, name: call.name, status: 'running' });
|
||||
const result = await tools.executeResult(call.name, JSON.stringify(call.arguments), bounded);
|
||||
const remaining = TEACHER_READ_BYTES - readBytes;
|
||||
const result = remaining < TEACHER_READ_MIN_BYTES
|
||||
? { status: 'error' as const, content: 'Read budget exhausted. Answer from the evidence already returned and state any gaps.', truncated: false }
|
||||
: await tools.executeResult(call.name, JSON.stringify(call.arguments), bounded, Math.min(TEACHER_READ_PAGE_BYTES, remaining));
|
||||
if (result.status === 'success') readBytes += Buffer.byteLength(result.content);
|
||||
results.push({
|
||||
tool_call_id: call.tool_call_id,
|
||||
...result,
|
||||
|
||||
58
electron/coding-teacher/read-page.ts
Normal file
58
electron/coding-teacher/read-page.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
export function readInteger(value: unknown, fallback: number, maximum = Number.MAX_SAFE_INTEGER): number {
|
||||
const result = value ?? fallback;
|
||||
if (typeof result !== 'number' || !Number.isSafeInteger(result) || result < 1 || result > maximum)
|
||||
throw new Error('Invalid read range');
|
||||
return result;
|
||||
}
|
||||
|
||||
export function textChunks(text: string): string[] {
|
||||
return [text.replaceAll('\r\n', '\n')];
|
||||
}
|
||||
|
||||
// JSON contains contiguous original text; columns count Unicode code points, starting at 1.
|
||||
export async function readPage(
|
||||
source: string,
|
||||
chunks: AsyncIterable<string> | Iterable<string>,
|
||||
args: Record<string, unknown>,
|
||||
maxBytes: number,
|
||||
): Promise<{ content: string; truncated: boolean }> {
|
||||
const start = readInteger(args.start_line, 1);
|
||||
const column = readInteger(args.start_column, 1);
|
||||
const count = readInteger(args.line_count, 120, 400);
|
||||
const chars: string[] = [];
|
||||
let lineNumber = 1, position = 1, more = false;
|
||||
outer: for await (const chunk of chunks) {
|
||||
for (const char of chunk) {
|
||||
if (lineNumber === start && char === '\n' && position < column)
|
||||
throw new Error('Column is past the line');
|
||||
if (lineNumber > start || (lineNumber === start && position >= column)) {
|
||||
if (lineNumber >= start + count || chars.length >= maxBytes) { more = true; break outer; }
|
||||
chars.push(char);
|
||||
}
|
||||
if (char === '\n') { lineNumber++; position = 1; } else position++;
|
||||
}
|
||||
}
|
||||
if (lineNumber < start || (lineNumber === start && position < column))
|
||||
throw new Error('Read position is past the source');
|
||||
const render = (length: number) => {
|
||||
let nextLine = start, nextColumn = column;
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (chars[i] === '\n') { nextLine++; nextColumn = 1; } else nextColumn++;
|
||||
}
|
||||
const next = more || length < chars.length ? { start_line: nextLine, start_column: nextColumn } : null;
|
||||
return { content: JSON.stringify({ source, start_line: start, start_column: column,
|
||||
text: chars.slice(0, length).join(''), next }), truncated: next !== null };
|
||||
};
|
||||
const complete = render(chars.length);
|
||||
if (Buffer.byteLength(complete.content) <= maxBytes) return complete;
|
||||
let low = 0, high = chars.length;
|
||||
while (low < high) {
|
||||
const middle = Math.ceil((low + high) / 2);
|
||||
if (Buffer.byteLength(render(middle).content) <= maxBytes) low = middle;
|
||||
else high = middle - 1;
|
||||
}
|
||||
const page = render(low);
|
||||
if ((chars.length && !low) || Buffer.byteLength(page.content) > maxBytes)
|
||||
throw new Error('Read budget too small for a page');
|
||||
return page;
|
||||
}
|
||||
@@ -2,6 +2,13 @@ import path from 'node:path';
|
||||
import type { TeacherRequest, TeacherSourceContext } from '../../shared/coding-teacher';
|
||||
import { CodingProjectFileService } from '../coding-projects/project-files';
|
||||
import { excerptTeacherText, teacherHistoryMessages } from './context';
|
||||
import { readInteger, readPage, textChunks } from './read-page';
|
||||
|
||||
export const TEACHER_READ_PROTOCOL = 2;
|
||||
export const TEACHER_READ_ROUNDS = 12;
|
||||
export const TEACHER_READ_BYTES = 64 * 1024;
|
||||
export const TEACHER_READ_PAGE_BYTES = 8 * 1024;
|
||||
export const TEACHER_READ_MIN_BYTES = 512;
|
||||
|
||||
export interface TeacherReadAccess {
|
||||
projectPath: string;
|
||||
@@ -12,19 +19,20 @@ export interface TeacherReadAccess {
|
||||
|
||||
const lineParameters = {
|
||||
start_line: { type: 'integer', minimum: 1, description: 'First line, default 1.' },
|
||||
line_count: { type: 'integer', minimum: 1, maximum: 100, description: 'Number of lines, default 60.' },
|
||||
start_column: { type: 'integer', minimum: 1, description: 'Unicode character column, default 1. Copy next from the previous page to continue.' },
|
||||
line_count: { type: 'integer', minimum: 1, maximum: 400, description: 'Maximum lines, default 120. Output is bounded; use next for the exact continuation.' },
|
||||
};
|
||||
export const teacherReadToolDefinitions = [
|
||||
{ type: 'function', function: {
|
||||
name: 'list_project_files', description: 'List files and directories in the current project. Start with path ".".',
|
||||
parameters: { type: 'object', properties: { path: { type: 'string' }, ...lineParameters }, required: ['path'], additionalProperties: false },
|
||||
name: 'list_project_files', description: 'List a bounded project tree. Start with path "."; use relevant subdirectories for deeper exploration. Files unchanged during a page sequence have stable line order.',
|
||||
parameters: { type: 'object', properties: { path: { type: 'string' }, depth: { type: 'integer', minimum: 1, maximum: 6, description: 'Tree depth, default 4. Directories at the depth boundary can be listed separately.' }, ...lineParameters }, required: ['path'], additionalProperties: false },
|
||||
} },
|
||||
{ type: 'function', function: {
|
||||
name: 'read_project_file', description: 'Read UTF-8 source from a current-project relative path, including .makelore/project.json metadata. Read only; no commands or edits.',
|
||||
parameters: { type: 'object', properties: { path: { type: 'string' }, ...lineParameters }, required: ['path'], additionalProperties: false },
|
||||
} },
|
||||
{ type: 'function', function: {
|
||||
name: 'read_conversation', description: 'Read the captured active coding conversation and current teacher topic. Omit message_id to list messages; supply it to read numbered lines.',
|
||||
name: 'read_conversation', description: 'Read the captured active coding conversation and current teacher topic. Omit message_id to list messages; supply it to read continuous original text. Results give start_line/start_column and next; do not reread unchanged ranges.',
|
||||
parameters: { type: 'object', properties: { message_id: { type: 'string' }, ...lineParameters }, additionalProperties: false },
|
||||
} },
|
||||
];
|
||||
@@ -39,26 +47,15 @@ function projectPath(value: unknown): string {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function lines(text: string, args: Record<string, unknown>): string {
|
||||
const start = args.start_line ?? 1, count = args.line_count ?? 60;
|
||||
if (typeof start !== 'number' || !Number.isSafeInteger(start) || start < 1
|
||||
|| typeof count !== 'number' || !Number.isSafeInteger(count) || count < 1 || count > 100)
|
||||
throw new Error('Use start_line >= 1 and line_count from 1 to 100.');
|
||||
const all = text.split(/\r?\n/);
|
||||
const selected = all.slice(start - 1, start - 1 + count);
|
||||
return `Lines ${start}-${start + selected.length - 1} of ${all.length}:\n`
|
||||
+ selected.map((line, index) => `${start + index}: ${line}`).join('\n');
|
||||
}
|
||||
|
||||
export function createTeacherReadTools(access: TeacherReadAccess) {
|
||||
const files = new CodingProjectFileService();
|
||||
const messages = [...access.source.messages, ...teacherHistoryMessages(access.history ?? [])];
|
||||
return {
|
||||
definitions: teacherReadToolDefinitions,
|
||||
async execute(name: string, rawArguments: string, signal: AbortSignal, maxBytes = 2400): Promise<string> {
|
||||
async execute(name: string, rawArguments: string, signal: AbortSignal, maxBytes = TEACHER_READ_PAGE_BYTES): Promise<string> {
|
||||
return (await this.executeResult(name, rawArguments, signal, maxBytes)).content;
|
||||
},
|
||||
async executeResult(name: string, rawArguments: string, signal: AbortSignal, maxBytes = 2400): Promise<{ status: 'success' | 'error'; content: string; truncated: boolean }> {
|
||||
async executeResult(name: string, rawArguments: string, signal: AbortSignal, maxBytes = TEACHER_READ_PAGE_BYTES): Promise<{ status: 'success' | 'error'; content: string; truncated: boolean }> {
|
||||
signal.throwIfAborted();
|
||||
access.assertCurrent();
|
||||
let result: string;
|
||||
@@ -69,27 +66,43 @@ export function createTeacherReadTools(access: TeacherReadAccess) {
|
||||
if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('Expected an object.');
|
||||
switch (name) {
|
||||
case 'list_project_files': {
|
||||
const entries = (await files.directory(access.projectPath, projectPath(args.path)))
|
||||
.filter(entry => !['.makelore', '.git'].includes(entry.name.toLowerCase()));
|
||||
result = lines(entries.map(entry => entry.path + (entry.type === 'directory' ? '/' : '')).join('\n') || '(empty directory)', args);
|
||||
const root = projectPath(args.path);
|
||||
const depth = readInteger(args.depth, 4, 6);
|
||||
async function* tree(): AsyncGenerator<string> {
|
||||
const directories = [{ path: root, level: 1 }];
|
||||
let found = false;
|
||||
for (let i = 0; i < directories.length; i++) {
|
||||
signal.throwIfAborted();
|
||||
access.assertCurrent();
|
||||
const directory = directories[i];
|
||||
for (const entry of await files.directory(access.projectPath, directory.path)) {
|
||||
if (['.makelore', '.git', '.gradle'].includes(entry.name.toLowerCase())) continue;
|
||||
found = true;
|
||||
yield entry.path + (entry.type === 'directory' ? '/' : '') + '\n';
|
||||
if (entry.type === 'directory' && directory.level < depth)
|
||||
directories.push({ path: entry.path, level: directory.level + 1 });
|
||||
}
|
||||
}
|
||||
if (!found) yield '(empty directory)';
|
||||
}
|
||||
({ content: result, truncated } = await readPage(root + ' (tree depth ' + depth + ')',
|
||||
tree(), args, maxBytes));
|
||||
break;
|
||||
}
|
||||
case 'read_project_file': {
|
||||
const file = await files.content(access.projectPath, projectPath(args.path));
|
||||
truncated = file.truncated;
|
||||
result = file.path + '\n' + lines(file.content, args)
|
||||
+ (file.truncated ? '\n[File exceeds the 256 KiB text preview limit; only its beginning is available.]' : '');
|
||||
const target = projectPath(args.path);
|
||||
({ content: result, truncated } = await readPage(target,
|
||||
files.textChunks(access.projectPath, target, signal), args, maxBytes));
|
||||
break;
|
||||
}
|
||||
case 'read_conversation': {
|
||||
if (args.message_id === undefined) {
|
||||
result = lines(messages.map(message => `${message.id} ${message.role}: ${excerptTeacherText(message.text, 180).replaceAll('\n', ' ')}`).join('\n')
|
||||
|| '(no completed text messages in this conversation)', args);
|
||||
} else {
|
||||
const message = messages.find(message => message.id === args.message_id);
|
||||
if (!message) throw new Error('Message is not in the current conversation.');
|
||||
result = message.id + ' ' + message.role + '\n' + lines(message.text, args);
|
||||
}
|
||||
const message = args.message_id === undefined ? undefined : messages.find(item => item.id === args.message_id);
|
||||
if (args.message_id !== undefined && !message) throw new Error('Message is not in the current conversation.');
|
||||
const text = message?.text ?? (messages.map(item =>
|
||||
`${item.id} ${item.role}: ${excerptTeacherText(item.text, 180).replaceAll('\n', ' ')}`).join('\n')
|
||||
|| '(no completed text messages in this conversation)');
|
||||
({ content: result, truncated } = await readPage(message ? message.id + ' ' + message.role : 'conversation message index (snippets)',
|
||||
textChunks(text), args, maxBytes));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
@@ -103,7 +116,8 @@ export function createTeacherReadTools(access: TeacherReadAccess) {
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
access.assertCurrent();
|
||||
const content = excerptTeacherText(result, maxBytes);
|
||||
// Only the generic error can need truncation; source pages already fit exactly.
|
||||
const content = status === 'error' ? excerptTeacherText(result, maxBytes) : result;
|
||||
return { status, content, truncated: truncated || content !== result };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -127,6 +127,37 @@ it('waits for the interrupted parent cleanup and resubmits the same three read r
|
||||
expect(paths.some(item => item.endsWith('/cancel'))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns the complete batch within the byte budget and lets the cloud finish with available evidence', async () => {
|
||||
const f = await fixture();
|
||||
await writeFile(path.join(f.access.projectPath, 'src/game.ts'), '源码🙂'.repeat(20000));
|
||||
const results: { status: string; content: string }[] = [];
|
||||
const transport: TeacherCloudTransport = {
|
||||
events: vi.fn(),
|
||||
json: vi.fn(async (url, body) => {
|
||||
if (url === '/questions') return { request_id: 'question', run_id: 'one' };
|
||||
if (url.endsWith('/tool-results')) {
|
||||
results.push(...(body as { results: typeof results }).results);
|
||||
return { run_id: 'done' };
|
||||
}
|
||||
if (url === '/runs/done') return { status: 'completed', output: '已检查的范围和待补充资料。' };
|
||||
return { status: 'interrupted', interrupt: {
|
||||
source: 'client_read_tools', context_id: requestId,
|
||||
calls: Array.from({ length: 10 }, (_, i) => ({ tool_call_id: 'file-' + i, name: 'read_project_file',
|
||||
arguments: { path: 'src/game.ts', start_column: 1 + i * 3000 } })),
|
||||
} };
|
||||
}),
|
||||
};
|
||||
const text = vi.fn();
|
||||
await prepareCloudTeacher(f.account, f.topic, requestId, f.access, f.progress, f.saveRequest, transport)
|
||||
.run([{ role: 'user', content: '检查' }], new AbortController().signal, text);
|
||||
expect(results).toHaveLength(10);
|
||||
const bytes = results.filter(item => item.status === 'success').reduce((sum, item) => sum + Buffer.byteLength(item.content), 0);
|
||||
expect(bytes).toBeLessThanOrEqual(65536);
|
||||
expect(bytes).toBeGreaterThan(65024);
|
||||
expect(results.at(-1)).toMatchObject({ status: 'error', content: expect.stringContaining('budget exhausted') });
|
||||
expect(text).toHaveBeenCalledWith('已检查的范围和待补充资料。');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ...cleanupBusy, active_run_id: 'other-run' },
|
||||
{ ...cleanupBusy, active_run_status: 'running' },
|
||||
@@ -190,7 +221,7 @@ async function submitCompiledContext(
|
||||
await model.run(compiled.messages, new AbortController().signal, vi.fn());
|
||||
const body = vi.mocked(transport.json).mock.calls.find(([url]) => url === '/questions')?.[1] as {
|
||||
query: string;
|
||||
local_context: { id: string; scope: { project_id: string; source_session_id: string }; tools: string[] };
|
||||
local_context: { id: string; read_protocol: number; scope: { project_id: string; source_session_id: string }; tools: string[] };
|
||||
};
|
||||
expect(JSON.parse(body.query)).toEqual({ messages: compiled.messages });
|
||||
expect(estimateTeacherTokens(compiled.messages)).toBeLessThanOrEqual(model.inputLimit);
|
||||
@@ -211,6 +242,7 @@ it('submits only the published persona, source evidence and current question in
|
||||
expect(compiled.messages[0].content).toContain('你可以通过只读工具');
|
||||
expect(compiled.messages[1].content).toContain(f.access.source.messages[0].text);
|
||||
expect(compiled.messages[2].content).toContain('当前问题:\n下一步怎么想?');
|
||||
expect(body.local_context.read_protocol).toBe(2);
|
||||
expect(body.local_context.tools).toEqual(['list_project_files', 'read_project_file', 'read_conversation']);
|
||||
});
|
||||
|
||||
@@ -512,7 +544,7 @@ it.each(['方法老师', '朋友', '代码顾问'])('returns all three local rea
|
||||
expect.objectContaining({
|
||||
tool_call_id: 'chat',
|
||||
status: 'success',
|
||||
content: expect.stringContaining('2: 调整重力'),
|
||||
content: expect.stringContaining('调整重力'),
|
||||
}),
|
||||
],
|
||||
},
|
||||
@@ -522,7 +554,7 @@ it.each(['方法老师', '朋友', '代码顾问'])('returns all three local rea
|
||||
expect(f.progress).toHaveBeenCalledWith('正在读取项目与会话…');
|
||||
});
|
||||
|
||||
it.each([false, true])('finishes six batches and rejects a seventh as a read limit (extra batch: %s)', async extraBatch => {
|
||||
it.each([false, true])('finishes twelve batches and rejects a thirteenth as a read limit (extra batch: %s)', async extraBatch => {
|
||||
const f = await fixture();
|
||||
const returned: unknown[] = [];
|
||||
const transport: TeacherCloudTransport = {
|
||||
@@ -534,7 +566,7 @@ it.each([false, true])('finishes six batches and rejects a seventh as a read lim
|
||||
returned.push(body);
|
||||
return { run_id: `run-${returned.length}` };
|
||||
}
|
||||
if (!extraBatch && returned.length === 6) return { status: 'completed', output: '已根据六批资料完成检查。' };
|
||||
if (!extraBatch && returned.length === 12) return { status: 'completed', output: '已根据十二批资料完成检查。' };
|
||||
return { status: 'interrupted', interrupt: {
|
||||
source: 'client_read_tools', context_id: requestId,
|
||||
calls: [{ tool_call_id: `read-${returned.length}`, name: 'read_project_file', arguments: { path: 'src/game.ts' } }],
|
||||
@@ -549,10 +581,10 @@ it.each([false, true])('finishes six batches and rejects a seventh as a read lim
|
||||
expect(transport.json).toHaveBeenCalledWith('/questions/' + requestId + '/cancel', {}, expect.anything());
|
||||
} else {
|
||||
await result;
|
||||
expect(text).toHaveBeenCalledWith('已根据六批资料完成检查。');
|
||||
expect(text).toHaveBeenCalledWith('已根据十二批资料完成检查。');
|
||||
expect(transport.json).not.toHaveBeenCalledWith('/questions/' + requestId + '/cancel', expect.anything(), expect.anything());
|
||||
}
|
||||
expect(returned).toHaveLength(6);
|
||||
expect(returned).toHaveLength(12);
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -31,6 +31,90 @@ async function fixture() {
|
||||
}
|
||||
|
||||
describe('teacher read scope', () => {
|
||||
it('discovers nested sources in one bounded tree and can drill into deeper directories', async () => {
|
||||
const f = await fixture();
|
||||
await mkdir(path.join(f.project, 'app/src/main/java/sample'), { recursive: true });
|
||||
await writeFile(path.join(f.project, 'app/src/main/AndroidManifest.xml'), '<manifest/>');
|
||||
await writeFile(path.join(f.project, 'app/src/main/java/sample/MainActivity.kt'), 'class MainActivity');
|
||||
const rawTree = await f.read('list_project_files', { path: '.' });
|
||||
expect(rawTree).toContain('app/src/main/AndroidManifest.xml');
|
||||
const tree = JSON.parse(rawTree);
|
||||
expect(tree.text).toContain('app/src/main/AndroidManifest.xml');
|
||||
expect(tree.text).toContain('app/src/main/java/');
|
||||
expect(tree.text).not.toContain('MainActivity.kt');
|
||||
const subtree = JSON.parse(await f.read('list_project_files', { path: 'app/src/main/java' }));
|
||||
expect(subtree.text).toContain('sample/MainActivity.kt');
|
||||
});
|
||||
it('can page beyond 2000 directory entries instead of silently losing later files', async () => {
|
||||
const f = await fixture();
|
||||
await mkdir(path.join(f.project, 'assets'));
|
||||
for (let i = 0; i < 2001; i++) await writeFile(path.join(f.project, 'assets', `item-${String(i).padStart(4, '0')}.txt`), '');
|
||||
const result = JSON.parse(await f.read('list_project_files', { path: 'assets', start_line: 1995, line_count: 10 }));
|
||||
expect(result.text).toContain('assets/item-2000.txt');
|
||||
expect(result.next).toBeNull();
|
||||
});
|
||||
it('keeps Android source discoverable beside a large Gradle cache', async () => {
|
||||
const f = await fixture();
|
||||
await mkdir(path.join(f.project, '.gradle'));
|
||||
await mkdir(path.join(f.project, 'app/src'), { recursive: true });
|
||||
await writeFile(path.join(f.project, 'app/src/Main.kt'), 'class Main');
|
||||
for (let i = 0; i < 2000; i++) await writeFile(path.join(f.project, '.gradle', `cache-${i}.bin`), '');
|
||||
const result = JSON.parse(await f.read('list_project_files', { path: '.' }));
|
||||
expect(result.text).toContain('app/');
|
||||
expect(result.text).toContain('app/src/Main.kt');
|
||||
expect(result.text).not.toContain('.gradle');
|
||||
});
|
||||
it('pages large UTF-8 originals without losing the middle or stopping at the preview limit', async () => {
|
||||
const f = await fixture();
|
||||
const original = '开始\n' + '中文🙂\\"'.repeat(24000) + '\n末行';
|
||||
await writeFile(path.join(f.project, 'large.ts'), original);
|
||||
let cursor: { start_line: number; start_column: number } | null = { start_line: 1, start_column: 1 };
|
||||
let restored = '';
|
||||
let pages = 0;
|
||||
while (cursor && pages++ < 150) {
|
||||
const result = await f.tools.executeResult('read_project_file', JSON.stringify({ path: 'large.ts', ...cursor }), f.controller.signal);
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.content).not.toContain('中间内容已省略');
|
||||
expect(Buffer.byteLength(result.content)).toBeLessThanOrEqual(8192);
|
||||
const page = JSON.parse(result.content);
|
||||
expect(page.text.length).toBeGreaterThan(0);
|
||||
restored += page.text;
|
||||
cursor = page.next;
|
||||
expect(result.truncated).toBe(cursor !== null);
|
||||
}
|
||||
expect(cursor).toBeNull();
|
||||
expect(pages).toBeGreaterThan(1);
|
||||
expect(restored).toBe(original);
|
||||
});
|
||||
it('stops consuming a multi-megabyte single line after the first bounded page', async () => {
|
||||
const f = await fixture();
|
||||
await writeFile(path.join(f.project, 'minified.js'), '源码🙂'.repeat(600000));
|
||||
const decode = TextDecoder.prototype.decode;
|
||||
let decodedBytes = 0;
|
||||
const spy = vi.spyOn(TextDecoder.prototype, 'decode').mockImplementation(function (input, options) {
|
||||
decodedBytes += input?.byteLength ?? 0;
|
||||
return decode.call(this, input, options);
|
||||
});
|
||||
try {
|
||||
const result = await f.tools.executeResult('read_project_file', '{"path":"minified.js"}', f.controller.signal);
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(Buffer.byteLength(result.content)).toBeLessThanOrEqual(8192);
|
||||
expect(decodedBytes).toBeLessThanOrEqual(128 * 1024);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
it('keeps line positions when Windows newlines cross filesystem chunks', async () => {
|
||||
const f = await fixture();
|
||||
await writeFile(path.join(f.project, 'windows.ts'), 'x'.repeat(65535) + '\r\n中文🙂\r\n最后');
|
||||
const page = JSON.parse(await f.read('read_project_file', { path: 'windows.ts', start_line: 2, line_count: 1 }));
|
||||
expect(page.text).toBe('中文🙂\n');
|
||||
expect(page.next).toEqual({ start_line: 3, start_column: 1 });
|
||||
const last = JSON.parse(await f.read('read_project_file', { path: 'windows.ts', ...page.next }));
|
||||
expect(last.text).toBe('最后');
|
||||
expect(last.next).toBeNull();
|
||||
});
|
||||
it('browses nested source and reads numbered file lines without modifying files', async () => {
|
||||
const f = await fixture();
|
||||
const list = await f.read('list_project_files', { path: '.' });
|
||||
@@ -38,8 +122,8 @@ describe('teacher read scope', () => {
|
||||
expect(list).not.toContain('.makelore');
|
||||
expect(await f.read('list_project_files', { path: 'src' })).toContain('src/game.ts');
|
||||
expect(await f.read('read_project_file', { path: '.makelore/project.json' })).toContain('current-project');
|
||||
expect(await f.read('read_project_file', { path: 'src/game.ts', start_line: 2, line_count: 1 }))
|
||||
.toBe('src/game.ts\nLines 2-2 of 2:\n2: const score = 7;');
|
||||
expect(JSON.parse(await f.read('read_project_file', { path: 'src/game.ts', start_line: 2, line_count: 1 })))
|
||||
.toEqual({ source: 'src/game.ts', start_line: 2, start_column: 1, text: 'const score = 7;', next: null });
|
||||
expect(await f.read('write', { path: 'src/game.ts', content: 'overwrite' })).toContain('Read failed');
|
||||
expect(await readFile(path.join(f.project, 'src/game.ts'), 'utf8')).toBe('const gravity = 0.6;\nconst score = 7;');
|
||||
});
|
||||
@@ -57,8 +141,8 @@ describe('teacher read scope', () => {
|
||||
it('reads only the captured conversation and supports original-message line ranges', async () => {
|
||||
const f = await fixture();
|
||||
expect(await f.read('read_conversation', {})).toContain('active assistant');
|
||||
expect(await f.read('read_conversation', { message_id: 'active', start_line: 2, line_count: 1 }))
|
||||
.toBe('active assistant\nLines 2-2 of 3:\n2: 第二行');
|
||||
expect(JSON.parse(await f.read('read_conversation', { message_id: 'active', start_line: 2, line_count: 1 })))
|
||||
.toEqual({ source: 'active assistant', start_line: 2, start_column: 1, text: '第二行\n', next: { start_line: 3, start_column: 1 } });
|
||||
expect(await f.read('read_conversation', { message_id: 'foreign' })).toContain('Read failed');
|
||||
});
|
||||
it('can recover the original middle of a previous teacher answer for a follow-up', async () => {
|
||||
@@ -71,15 +155,17 @@ describe('teacher read scope', () => {
|
||||
});
|
||||
const result = await tools.execute('read_conversation',
|
||||
JSON.stringify({ message_id: 'teacher:prior:assistant', start_line: 2, line_count: 1 }), f.controller.signal);
|
||||
expect(result).toContain('2: 中间的原文');
|
||||
expect(JSON.parse(result).text).toBe('中间的原文\n');
|
||||
});
|
||||
it('bounds read output and preserves explicit truncation markers', async () => {
|
||||
it('honors small caller budgets and rejects invalid page ranges without leaking paths', async () => {
|
||||
const f = await fixture();
|
||||
await writeFile(path.join(f.project, 'large.ts'), '项目分析内容'.repeat(30000));
|
||||
const result = await f.read('read_project_file', { path: 'large.ts' });
|
||||
expect(Buffer.byteLength(result)).toBeLessThanOrEqual(2400);
|
||||
expect(result).toContain('中间内容已省略');
|
||||
expect(result).toContain('256 KiB');
|
||||
const result = await f.tools.executeResult('read_project_file', JSON.stringify({ path: 'src/game.ts' }), f.controller.signal, 128);
|
||||
expect(Buffer.byteLength(result.content)).toBeLessThanOrEqual(128);
|
||||
for (const args of [{ start_line: 9 }, { start_column: 900 }, { line_count: 401 }, { start_line: 0 }]) {
|
||||
const failure = await f.tools.executeResult('read_project_file', JSON.stringify({ path: 'src/game.ts', ...args }), f.controller.signal);
|
||||
expect(failure.status).toBe('error');
|
||||
expect(failure.content).not.toContain(f.project);
|
||||
}
|
||||
});
|
||||
it('does not read after cancellation or an account change', async () => {
|
||||
const f = await fixture();
|
||||
|
||||
Reference in New Issue
Block a user