diff --git a/.project-docs/30-worklog/tasks/20260924-teacher-context-budget-3a9e6c81.md b/.project-docs/30-worklog/tasks/20260924-teacher-context-budget-3a9e6c81.md new file mode 100644 index 00000000..4e722d34 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260924-teacher-context-budget-3a9e6c81.md @@ -0,0 +1,47 @@ +# Task: Fix teacher context budget rejection + +## Identity + +- Task ID: 20260924-teacher-context-budget-3a9e6c81 +- Mode: Feature +- Branch: codex/20260924-teacher-context-budget-3a9e6c81-teacher-context-budget +- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260924-teacher-context-budget-3a9e6c81 +- Base commit: eb3dc85a2f0ed8457b7f24684c2da3ab192c2e46 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Diagnose and fix teacher context-budget rejection for a short question, preserving published configuration, student billing, cloud/legacy execution and current read-only tool boundaries. Own client context budgeting, runner integration, focused regressions, README and this record. + +## Intent And Constraints + +- Official check/start/status passed in the isolated feature worktree above. Read entry, own record, relevant teacher decisions/architecture/domain/evidence/reflection/commitments and 108 peer records. Completed teacher/packaging scopes overlap historically; historical placeholder records remain unknown/read-only with no concrete dependency. Planning Gate Passed; canonical memory, other tasks, main and user project files stay read-only. +- Apply diagnosing-bugs: establish a red runner test, inspect only relevant local topic metadata, reproduce the actual legacy configuration, then correct the budgeting seam and test real preparation/compilation/submission. No subagents, paid model calls, deployment, installation or automatic topic migration. +- Confirmed local metadata: current 方法老师 topic has no runtime=yuxi, no requests or discussion, input budget 8000, output 1500, system prompt 2751 UTF-8 bytes. No user conversation text or secret was printed or copied. The screenshot is a legacy local-topic preflight failure, not proof of a cloud model limit. + +## Outcome + +- Fixed native token budgeting: text, reasoning, tool schemas and calls share an explicit approximate 2 UTF-8 bytes/token estimate; tool-result limits convert back to bytes under the existing 2400-byte cap. Published token ceilings, model limits, student billing and final provider authority remain intact; no configuration or topic migration. +- Context compaction now measures the selected runner's complete envelope. Cloud compilation uses exact JSON query bytes instead of a fixed 256-byte escaping reserve, so ordinary code quotes/newlines cannot pass compilation then fail submission. Excerpts preserve head/tail and source IDs without mutating original read-tool material. +- Fixed configuration/current-discussion overflow is distinguished from oversized questions/references. Teacher behavior, current question, explicit references and discussion instructions remain complete. Updated README explains the approximate native versus exact transport boundary. +- No Renderer workflow changed. Before: a short first question could be rejected with advice to shorten it or start a topic. After: the same 8000-budget legacy shape and cloud long-code case submit successfully; truly oversized fixed inputs identify configuration as the cause. + +- Reproduced two reachable failures before changing product code: legacy model preparation plus empty-source first discussion needs 9013 byte-based estimated units but allows only 4456 after tool reserves (synthetic 2700-byte prompt matching observed shape); cloud compilation accepts long ordinary quoted code then JSON serialization exceeds its byte budget. + +## Verification + +- Green: 9 focused teacher/context/read/model/structured-discussion/Renderer files, 289 tests passed. Includes actual CodingTeacherService + native runner + real temporary project-file read/continuation at the existing 8000 budget with a 2700-byte prompt and long source; actual service + cloud transport compaction; escaped-code cloud envelope and unchanged protocol; negative fixed-config overflow; six read rounds, cancellation, request identity and scope remain covered. +- pnpm 10.33.4 frozen offline install, standard pnpm run typecheck and scoped ESLint passed. Production Vite build passed for Renderer/Main/Preload/utility. Expanded Main tsc reports 66 diagnostics; a CompilerHost replay substituting exact base versions of all changed Electron files also reports the same 66, with zero new diagnostics. No full-suite or real-model success is claimed. +- Existing Electron UI fixtures replace the Host API and bypass this budget seam; this Main-only change is covered at real service/runner boundaries instead of treating a mocked UI pass as reproduction evidence. No installed application, live Yuxi instance or user topic was modified. + +- Red loops: pnpm exec vitest run tests/unit/coding-teacher-model.test.ts -t 'existing 8000 budget' --maxWorkers=1; pnpm exec vitest run tests/unit/coding-teacher-cloud.test.ts -t 'long code context' --maxWorkers=1. Both fail with the exact screenshot message at their respective real preflight seam. Empty-source cloud question with the discussion protocol passes. +- Native model limits are tokens; existing implementation equates them to UTF-8 bytes, unlike model tokenization. DeepSeek documentation distinguishes character estimates and actual usage (https://api-docs.deepseek.com/zh-cn/quick_start/token_usage/). Keep native token estimates explicitly approximate and model/usage authoritative; retain the separate exact cloud query byte guard. + +## Follow-ups + +- Merge, rebuilt installer and installed-app verification remain pending. The observed 方法老师 topic is still a legacy definition; selecting/publishing a Yuxi teacher is a separate Operations action and existing topic versions remain fixed. + +## Promotion Candidates + +- Target: teacher architecture/domain/current-state at Integration Gate. Proposal: record distinct approximate native token and exact cloud JSON-byte budgeting, with source excerpt fitting and configuration-specific errors. Evidence: red/green runner cases, 289 tests, type/lint/build and unchanged actual-topic metadata. Future impact: new prompt/protocol content must use the runner's measurement at compilation; no silent topic migration or budget override. No semantic conflict or further product-policy approval identified; feature mode does not promote canonical documents. diff --git a/README.md b/README.md index d3a2b806..209f102d 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - 老师教学基线由 Main 的 `electron/coding-teacher/behavior-prompt.ts` 接入 `compileTeacherContext`,适用于普通咨询、快捷求助、主动关心及运营预览。面向 6–14 岁学生,通过创作培养自主构思、产品与结构化思考、AI 协作、有依据的判断和改进;按当前困难提供适量建议与解释,不强制固定步骤,不做演示或项目执行。云端 Yuxi 定义仍拥有身份、Skills、模型和知识库;Main 将教学基线、角色和每轮展示协议随已编译上下文传入现有云端提问合同,旧版本地老师沿用发布的文本补充、模型和预算;无需另装教学 Skill。朋友继续使用独立体验提示词,不加载这份教学基线或老师 Skills。 - 每轮格式由 Main 的对应意图协议决定;`discussion.ts` 为支持组件的请求注入唯一 `{reply, quickReplies, tool}` 协议。运营教学补充不另写字段协议或要求始终纯文字。工具内讨论保留类型、稳定 ID、未修改内容和采纳状态;暂停/未进入时 `tool:null`,没有实质变化时也可保留原内容。结构图、流程和对照目前没有独立的采纳/来源字段,待定、建议与预测只能在展示文字中明确,不能据此推导已确认共识。 - 当前输入是按预算截取的所选操作对话已完成分支文本、明确引用、师生历史和适用的当前组件,并支持上述只读原文恢复;没有跨全部操作会话自动读取,也未向操作 Agent 注入师生共识。“先这些”仍仅结束本轮整理,保留各条目状态,不确认全部候选、不自动制作。提示词不宣称这些系统能力已接通,也不声称看过未提供的画面或试玩过作品。Yuxi 话题经云端原生执行,旧版本地老师由 Main 调用计费网关;均不创建本地 Pi 编程 worker。 +- 老师输入裁剪区分两种计量:本地模型以约每 2 个 UTF-8 字节估算 1 Token,并计入工具与读取预留;这不是精确分词或扣费依据,模型上限与实际 usage 仍由服务端拥有。云端沿用 query 字节上限,以完整 JSON 转义后的大小裁剪来源节选。老师指令、当前问题和明确引用不会被静默裁掉;固定配置或当前整理本身超限时明确提示调整配置,避免让学生反复缩短短问题或新建话题。完整来源仍可由只读工具按需读取。 - `tests/fixtures/teacher-guidance-examples.json` 提供宠物、种植、射击、蛋糕的人工多轮输入输出样例,由真实解析器、上下文编译器与讨论状态机校验;样例不加载到运行时,也不代表真实模型教学效果已验收。云端定义由 Yuxi 维护并由运营显式同步下发;本地代码修改不会自动改写云端配置或学生已有话题的固定发布版本。 - 咨询话题及未发送草稿按当前云端账号、项目与角色分别保存;切换操作对话仍延续该项目下的咨询,每轮发送时绑定当时的操作对话来源。创建话题时固定发布版本,每轮重新检查启停。关闭面板或切换会话不取消已接受的问题,退出账号会中止。请求先落盘再调用模型,同一请求标识不重复发送,重启后未完成请求标记中断。云端断线按游标和持久状态恢复;停止或退出账号会取消原云端问题,下次提问先停止旧的未完成问题再绑定新上下文。只接收云端主线程回答,子智能体文本不混入正文。没有操作对话时也可咨询,但无操作文本上下文。 - 删除操作对话会停止正在使用该来源的回复,保留项目级咨询历史;归档来源需先恢复后才能引用。原有来源会话级老师 API 与存储保持兼容,原路径历史不自动迁移到新项目级咨询栏。运营草稿试聊仍使用 `niancode://coding-teacher/preview?draft_revision=N` 打开独立示例上下文,不读取学生项目,要求桌面当前账号具有运营权限。 diff --git a/electron/coding-teacher/cloud-runner.ts b/electron/coding-teacher/cloud-runner.ts index c5e4f2fe..7da3de42 100644 --- a/electron/coding-teacher/cloud-runner.ts +++ b/electron/coding-teacher/cloud-runner.ts @@ -169,9 +169,10 @@ export function prepareCloudTeacher( ? undefined : createTeacherReadTools(access); return { - // Leave room for routine JSON escaping. The exact wire size is checked below - // so unusually escape-heavy material cannot silently exceed the input budget. - inputLimit: Math.max(0, topic.definition.limits.max_input_tokens - 256), + // The cloud query contract is byte-bounded. Compile against the exact JSON + // envelope instead of reserving a fixed amount for unpredictable escaping. + inputLimit: topic.definition.limits.max_input_tokens, + measureInput: (messages: TeacherModelMessage[]) => Buffer.byteLength(JSON.stringify({ messages }), 'utf8'), async run( messages: TeacherModelMessage[], signal: AbortSignal, @@ -186,7 +187,7 @@ export function prepareCloudTeacher( throw new TeacherError( 422, 'teacher_context_too_long', - '问题、引用或老师指令超过上下文预算,请缩短引用或新建话题。' + '本次问题或引用超过上下文预算,请缩短问题或引用后重试。' ); } const currentRequest = topic.requests.find((item) => item.id === requestId); diff --git a/electron/coding-teacher/context.ts b/electron/coding-teacher/context.ts index c8393f51..b0388102 100644 --- a/electron/coding-teacher/context.ts +++ b/electron/coding-teacher/context.ts @@ -71,12 +71,18 @@ export function teacherHistoryMessages(history: TeacherRequest[]): TeacherSource }, ]); } -// UTF-8 byte count is a conservative budget estimate, not a tokenizer claim. +// A bounded text estimate for the native model path, not an exact tokenizer or +// billing count. UTF-8 bytes are not tokens (Chinese commonly occupies 3 bytes). +// Keep headroom over typical text tokenization; the provider owns actual usage. +export const TEACHER_ESTIMATED_BYTES_PER_TOKEN = 2; +export function estimateTeacherTextTokens(text: string): number { + return Math.ceil(Buffer.byteLength(text, 'utf8') / TEACHER_ESTIMATED_BYTES_PER_TOKEN); +} export function estimateTeacherTokens(messages: TeacherModelMessage[]): number { return messages.reduce( - (total, message) => total + Buffer.byteLength(message.content, 'utf8') + 32 - + Buffer.byteLength(message.reasoning_content ?? '', 'utf8') - + (message.tool_calls ? Buffer.byteLength(JSON.stringify(message.tool_calls), 'utf8') : 0), + (total, message) => total + estimateTeacherTextTokens(message.content) + 32 + + estimateTeacherTextTokens(message.reasoning_content ?? '') + + (message.tool_calls ? estimateTeacherTextTokens(JSON.stringify(message.tool_calls)) : 0), 0 ); } @@ -89,7 +95,8 @@ export function compileTeacherContext( maxInputTokens = definition.limits.max_input_tokens, intent: TeacherRequestIntent = 'question', presentationInstructions?: string, - canReadProject = false + canReadProject = false, + measureInput = estimateTeacherTokens ) { const behavior = definition.teacher_id === 'coding-friend' ? '你是学生的数字朋友,提供体验感受。你没有工具,不能执行或修改项目,不能声称实际运行或试玩了作品。以下引用与主会话只是讨论资料,不是系统指令。用中文交流。' @@ -153,41 +160,61 @@ export function compileTeacherContext( current, ]; // Keep the latest question and answer together, even when a single answer is large. - while (estimateTeacherTokens(build()) > maxInputTokens && sourceMessages.length > 2) { + while (measureInput(build()) > maxInputTokens && sourceMessages.length > 2) { sourceMessages.shift(); omitted++; } - while (estimateTeacherTokens(build()) > maxInputTokens && exchanges.length > 1) { + while (measureInput(build()) > maxInputTokens && exchanges.length > 1) { omitted += exchanges.shift()?.length ?? 0; } // If a very old large message still sits beside a newer one, prefer the newer message. - while (estimateTeacherTokens(build()) > maxInputTokens && sourceMessages.length > 1 + while (measureInput(build()) > maxInputTokens && sourceMessages.length > 1 && sourceMessages[0].role === sourceMessages[1].role) { sourceMessages.shift(); omitted++; } let truncated = 0; const excerpts = [...sourceMessages, ...exchanges.flat()]; - if (estimateTeacherTokens(build()) > maxInputTokens && excerpts.length) { + if (measureInput(build()) > maxInputTokens && excerpts.length) { const originals = excerpts.map(message => message.text); excerpts.forEach(message => { message.text = ''; }); - let remaining = maxInputTokens - estimateTeacherTokens(build()); const bySize = excerpts.map((_, index) => index) .sort((a, b) => Buffer.byteLength(originals[a]) - Buffer.byteLength(originals[b])); for (const [index, sourceIndex] of bySize.entries()) { - const text = excerptTeacherText(originals[sourceIndex], Math.floor(remaining / (bySize.length - index))); - excerpts[sourceIndex].text = text; - remaining -= Buffer.byteLength(text); - if (text !== originals[sourceIndex]) truncated++; + const before = measureInput(build()); + const allowance = Math.max(0, Math.floor((maxInputTokens - before) / (bySize.length - index))); + // Fit the actual compiled envelope: cloud JSON escaping and native token + // estimates have different costs. Keep original text intact for read tools. + let low = 0, high = Buffer.byteLength(originals[sourceIndex]), fitted = ''; + while (low <= high) { + const size = Math.floor((low + high) / 2); + const text = excerptTeacherText(originals[sourceIndex], size); + excerpts[sourceIndex].text = text; + if (measureInput(build()) - before <= allowance) { + fitted = text; + low = size + 1; + } else high = size - 1; + } + excerpts[sourceIndex].text = fitted; + if (fitted !== originals[sourceIndex]) truncated++; } } const messages = build(); - if (estimateTeacherTokens(messages) > maxInputTokens) + if (measureInput(messages) > maxInputTokens) { + const fixed = [system, ...(presentationInstructions + ? [{ role: 'system' as const, content: presentationInstructions }] : [])]; + if (measureInput(fixed) > maxInputTokens) + throw new TeacherError( + 422, + 'teacher_configuration_too_long', + '老师配置或当前整理内容超过上下文预算,请联系运营调整老师配置或预算。' + ); throw new TeacherError( 422, 'teacher_context_too_long', - '问题、引用或老师指令超过上下文预算,请缩短引用或新建话题。' + '本次问题或引用超过上下文预算,请缩短问题或引用后重试。' ); + } return { messages, omittedMessages: omitted, diff --git a/electron/coding-teacher/model-runner.ts b/electron/coding-teacher/model-runner.ts index 2e9a3b69..cc47a6de 100644 --- a/electron/coding-teacher/model-runner.ts +++ b/electron/coding-teacher/model-runner.ts @@ -11,7 +11,7 @@ import { TeacherError, type TeacherAccount, } from './config-client'; -import { estimateTeacherTokens, type TeacherModelMessage, type TeacherToolCall } from './context'; +import { estimateTeacherTextTokens, estimateTeacherTokens, TEACHER_ESTIMATED_BYTES_PER_TOKEN, type TeacherModelMessage, type TeacherToolCall } from './context'; import { createTeacherReadTools, type TeacherReadAccess, type TeacherReadTools } from './read-tools'; interface TeacherModelConfig { @@ -74,8 +74,9 @@ export async function prepareTeacherModel( capability.limits?.contextWindow ? capability.limits.contextWindow - outputLimit : Infinity ); const tools = access ? createTeacherReadTools(access) : undefined; - const toolBudget = tools ? Buffer.byteLength(JSON.stringify(tools.definitions), 'utf8') + 64 : 0; + const toolBudget = tools ? estimateTeacherTextTokens(JSON.stringify(tools.definitions)) + 64 : 0; return { + measureInput: estimateTeacherTokens, // Leave room for a read result and its native tool-call envelope. inputLimit: inputLimit - toolBudget - (tools ? Math.min(2400, Math.floor(inputLimit / 4)) : 0), run: (messages: TeacherModelMessage[], signal: AbortSignal, onText: (text: string) => void) => { @@ -97,7 +98,7 @@ export async function streamTeacherReply( options?: { tools?: TeacherReadTools; inputLimit: number; finalOnly?: boolean; assertCurrent(): void } ): Promise { const tools = options?.tools; - const toolBudget = tools ? Buffer.byteLength(JSON.stringify(tools.definitions), 'utf8') + 64 : 0; + const toolBudget = tools ? estimateTeacherTextTokens(JSON.stringify(tools.definitions)) + 64 : 0; const reads: TeacherModelMessage[][] = []; let usage: PublicUsage | undefined; // Six read rounds, then one final text response. No recursive agent or Pi session. @@ -129,8 +130,11 @@ export async function streamTeacherReply( } const batch: TeacherModelMessage[] = [{ role: 'assistant', content: result.text, tool_calls: result.calls, ...(result.reasoning ? { reasoning_content: result.reasoning } : {}) }]; + // Tools bound their UTF-8 result bytes; convert the remaining token estimate + // back to bytes while keeping the existing per-result byte cap. const resultBudget = Math.min(2400, Math.floor(((options?.inputLimit ?? Infinity) - - toolBudget - estimateTeacherTokens([...messages, ...batch]) - 64 * result.calls.length) / result.calls.length)); + - toolBudget - estimateTeacherTokens([...messages, ...batch]) - 64 * result.calls.length) + * TEACHER_ESTIMATED_BYTES_PER_TOKEN / result.calls.length)); if (resultBudget < 128) { throw new TeacherError(422, 'teacher_context_too_long', '老师读取的内容超过上下文预算,请缩小问题范围或联系运营增加预算。'); } diff --git a/electron/coding-teacher/service.ts b/electron/coding-teacher/service.ts index 207434a5..4617199e 100644 --- a/electron/coding-teacher/service.ts +++ b/electron/coding-teacher/service.ts @@ -448,7 +448,8 @@ export class CodingTeacherService { model.inputLimit, intent, structuredReply ? discussionInstructions(topic, discussionContext) : undefined, - scope.projectId !== 'preview' + scope.projectId !== 'preview', + model.measureInput ); // Reading context and resolving model credentials can yield while a source // is being deleted. Project consultations must recheck the actual source. diff --git a/tests/unit/coding-teacher-cloud.test.ts b/tests/unit/coding-teacher-cloud.test.ts index 6acaaa8c..baa83d83 100644 --- a/tests/unit/coding-teacher-cloud.test.ts +++ b/tests/unit/coding-teacher-cloud.test.ts @@ -85,7 +85,7 @@ async function submitCompiledContext( ); const compiled = compileTeacherContext( f.topic.definition, f.access.source, [], intent === 'check-in' ? '' : '下一步怎么想?', [], - model.inputLimit, intent, presentationInstructions, true + model.inputLimit, intent, presentationInstructions, true, model.measureInput ); await model.run(compiled.messages, new AbortController().signal, vi.fn()); const body = vi.mocked(transport.json).mock.calls.find(([url]) => url === '/questions')?.[1] as { @@ -130,6 +130,32 @@ it('submits the active discussion protocol, current tool content and selected fo expect(protocol).toContain('比较两种重力'); }); +it('submits a short first question with the normal discussion protocol and published budget', async () => { + const f = await fixture(); + const { body } = await submitCompiledContext(f, 'question', discussionInstructions(f.topic)); + expect(body.query).toContain('下一步怎么想?'); +}); + +it('excerpts long code context to fit the serialized cloud query', async () => { + const f = await fixture(); + f.access.source.messages[0].text = 'const title = "game";\n'.repeat(500); + const { body, compiled } = await submitCompiledContext(f); + expect(compiled.truncatedMessages).toBeGreaterThan(0); + expect(body.query).toContain('下一步怎么想?'); +}); + +it('fits code excerpts beside the unchanged discussion protocol in the original cloud budget', async () => { + const f = await fixture(); + const original = 'const config = { "title": "小游戏" };\n'.repeat(500); + f.access.source.messages[0].text = original; + const protocol = discussionInstructions(f.topic); + const { body, compiled } = await submitCompiledContext(f, 'question', protocol); + expect(compiled.messages.at(-2)?.content).toBe(protocol); + expect(compiled.truncatedMessages).toBeGreaterThan(0); + expect(f.access.source.messages[0].text).toBe(original); + expect(JSON.parse(body.query).messages.at(-1).content).toContain('下一步怎么想?'); +}); + it('submits a nonempty proactive check-in without inventing a user message', async () => { const f = await fixture(); f.access.source.messages = []; diff --git a/tests/unit/coding-teacher-model.test.ts b/tests/unit/coding-teacher-model.test.ts index 9c82b713..60581fd4 100644 --- a/tests/unit/coding-teacher-model.test.ts +++ b/tests/unit/coding-teacher-model.test.ts @@ -7,6 +7,9 @@ import type { TeacherDefinition } from '../../shared/coding-teacher'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; +import { compileTeacherContext, estimateTeacherTokens } from '../../electron/coding-teacher/context'; +import { discussionInstructions } from '../../electron/coding-teacher/discussion'; +import type { TeacherTopic } from '../../shared/coding-teacher'; const account: cloud.TeacherAccount = { id: '11111111-1111-4111-8111-111111111111', @@ -51,6 +54,38 @@ function setup(canDisable = true) { afterEach(() => vi.restoreAllMocks()); describe('teacher published reasoning wire contract', () => { + it('accepts a short discussion question with the existing 8000 budget and teaching prompt', async () => { + const fetch = setup(); + const config = definition({ mode: 'disabled' }); + config.system_prompt = '帮助学生理解设计,结合实际进展给出建议。'.repeat(45); + const source = { messages: [], cursor: { workerGeneration: 1, seq: 1 }, capturedAt: '2026-09-24' }; + const topic = { definition: config } as TeacherTopic; + const prepared = await prepareTeacherModel(account, config, { + projectPath: tmpdir(), source, assertCurrent: () => undefined, + }, { finalOnly: true }); + const protocol = discussionInstructions(topic); + const fixed = compileTeacherContext(config, source, [], '帮我看看我的项目有啥问题', [], Infinity, 'question', protocol, true); + expect(estimateTeacherTokens(fixed.messages)).toBeLessThanOrEqual(prepared.inputLimit); + const compiled = compileTeacherContext(config, source, [], '帮我看看我的项目有啥问题', [], + prepared.inputLimit, 'question', protocol, true); + await prepared.run(compiled.messages, new AbortController().signal, vi.fn()); + expect(fetch).toHaveBeenCalledOnce(); + }); + + it('reports oversized fixed instructions as a configuration problem without dispatching', async () => { + const fetch = setup(); + const config = definition({ mode: 'disabled' }); + config.limits.max_input_tokens = 3000; + const source = { messages: [], cursor: { workerGeneration: 1, seq: 1 }, capturedAt: 'now' }; + const prepared = await prepareTeacherModel(account, config, { + projectPath: tmpdir(), source, assertCurrent: () => undefined, + }); + expect(() => compileTeacherContext(config, source, [], '你好', [], prepared.inputLimit, + 'question', discussionInstructions({ definition: config } as TeacherTopic), true)) + .toThrow('老师配置或当前整理内容超过上下文预算'); + expect(fetch).not.toHaveBeenCalled(); + }); + it.each(['我先看一下文件。', '{"reply":"读取前的草案","tool":null}'])('delivers only the final structured answer after a tool round containing %s', async (preamble) => { const fetch = setup(); const root = await mkdtemp(path.join(tmpdir(), 'teacher-final-model-')); diff --git a/tests/unit/coding-teacher-read-tools.test.ts b/tests/unit/coding-teacher-read-tools.test.ts index 02b9ff2c..196b4d68 100644 --- a/tests/unit/coding-teacher-read-tools.test.ts +++ b/tests/unit/coding-teacher-read-tools.test.ts @@ -43,8 +43,7 @@ describe('teacher read scope', () => { 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;'); }); - it.each(['../other-project.txt', '.makelore/conversations.json', ' .makelore/conversations.json ', 'src/../.makelore/conversations.json']) - ('refuses out-of-scope path %s', async target => { + it.each(['../other-project.txt', '.makelore/conversations.json', ' .makelore/conversations.json ', 'src/../.makelore/conversations.json'])('refuses out-of-scope path %s', async target => { const f = await fixture(); expect(await f.read('read_project_file', { path: target })).toContain('Read failed'); }); @@ -118,7 +117,7 @@ describe('teacher read rounds', () => { await writeFile(path.join(f.project, 'src/game.ts'), 'x'.repeat(10000)); const fetch = vi.fn(async (_url: string | URL, init?: RequestInit) => { const body = JSON.parse(String(init?.body)); - expect(estimateTeacherTokens(body.messages) + Buffer.byteLength(JSON.stringify(body.tools)) + 64).toBeLessThanOrEqual(8000); + expect(estimateTeacherTokens(body.messages) + Math.ceil(Buffer.byteLength(JSON.stringify(body.tools)) / 2) + 64).toBeLessThanOrEqual(8000); if (body.tool_choice === 'none') return new Response(streamEvent({ choices: [{ delta: { content: '已完成阅读' }, finish_reason: 'stop' }] })); return toolResponse(); }); diff --git a/tests/unit/coding-teacher.test.ts b/tests/unit/coding-teacher.test.ts index 7d4140b3..fb4f8044 100644 --- a/tests/unit/coding-teacher.test.ts +++ b/tests/unit/coding-teacher.test.ts @@ -61,7 +61,7 @@ afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); vi.useRealTimers(); }); -async function fixture({ durableSource = false, sourceContext = context, liveModel = false, cloudTeacher = false, mockCloud = false, modelInputLimit = definition.limits.max_input_tokens } = {}) { +async function fixture({ durableSource = false, sourceContext = context, liveModel = false, cloudTeacher = false, mockCloud = false, modelInputLimit = definition.limits.max_input_tokens, modelSystemPrompt = definition.system_prompt } = {}) { const root = await mkdtemp(path.join(tmpdir(), 'coding-teacher-')); roots.push(root); const projects = new CodingProjectService( @@ -114,7 +114,7 @@ async function fixture({ durableSource = false, sourceContext = context, liveMod yuxi: { agent_slug: 'teacher', agent_version: 2 } } }] }) : undefined, version: async (_account, v) => ({ version: v, - payload: { ...definition, name: '老师 v' + v, limits: { ...definition.limits, max_input_tokens: modelInputLimit } }, + payload: { ...definition, name: '老师 v' + v, system_prompt: modelSystemPrompt, limits: { ...definition.limits, max_input_tokens: modelInputLimit } }, }), preview: async (_account, revision) => { if (revision !== 2) throw new TeacherError(409, 'teacher_draft_changed', '草稿变化'); @@ -170,7 +170,9 @@ describe('cloud coding teacher', () => { expect((await f.service.list(f.scope)).lastSelectedTopicId).toBe(selected.id); }); it('wires the Yuxi topic through scoped credentials and local tools without a local prompt or model loop', async () => { - const f = await fixture({ cloudTeacher: true }); + const f = await fixture({ cloudTeacher: true, sourceContext: { + ...context, messages: [{ id: 'long-code', role: 'assistant', text: 'const title = "game";\n'.repeat(500) }], + } }); await writeFile(path.join(f.created.project.path, 'counter.ts'), 'let count = 42;'); vi.spyOn(teacherCloud, 'assertTeacherAccount').mockReturnValue(undefined); vi.spyOn(teacherCloud, 'teacherCloudRequest').mockResolvedValue({ @@ -198,7 +200,9 @@ describe('cloud coding teacher', () => { const saved = (await f.service.read(f.scope, topic.id)).requests[0]; expect(saved.response).toBe('计数器从 42 开始。'); expect(saved.cloudRequestId).toBe('cloud-question'); - expect(JSON.stringify(requests[0].body)).toContain('创建计数器'); + expect(JSON.stringify(requests[0].body)).toContain('const title'); + expect(saved.truncatedMessages).toBeGreaterThan(0); + expect(Buffer.byteLength((requests[0].body as { query: string }).query)).toBeLessThanOrEqual(8000); expect(requests.find(item => item.path.endsWith('/tool-results'))?.body).toMatchObject({ context_id: id, results: [{ tool_call_id: 'read-counter', status: 'success', content: expect.stringContaining('let count = 42;') }], }); @@ -1102,7 +1106,7 @@ describe('teacher context and wire contract', () => { expect(compiled.messages[0].content).toContain(TEACHER_BEHAVIOR_PROMPT); expect(compiled.includedSourceMessageIds).toEqual(['source-user']); expect(compiled.omittedMessages).toBe(1); - expect(() => compileTeacherContext(definition, context, [], 'x'.repeat(9000), [])).toThrow( + expect(() => compileTeacherContext(definition, context, [], 'x'.repeat(18000), [])).toThrow( '超过上下文预算' ); }); @@ -1297,7 +1301,14 @@ it.each(['teacher', 'friend'] as const)('routes project-level %s config and topi describe('structured teacher service integration', () => { it.each(['suggestions', 'discussion'] as const)('parses only final %s JSON after the real local runner reads a project file', async (format) => { - const f = await fixture({ liveModel: true, modelInputLimit: 24000 }); + const f = await fixture({ + liveModel: true, modelInputLimit: 8000, + modelSystemPrompt: '帮助学生理解设计,结合实际进展给出建议。'.repeat(45), + sourceContext: { ...context, messages: [ + { id: 'current-question', role: 'user', text: '创建计数器' }, + { id: 'long-code', role: 'assistant', text: 'const title = "game";\n'.repeat(500) }, + ] }, + }); const scope = { ...f.scope, sourceId: 'project', role: 'teacher' as const }; await writeFile(path.join(f.created.project.path, 'counter.ts'), 'let count = 42;'); vi.spyOn(teacherCloud, 'assertTeacherAccount').mockReturnValue(undefined);