fix: preserve complete consultation replies and recover retained history
This commit is contained in:
@@ -12,15 +12,22 @@
|
||||
|
||||
## Scope
|
||||
|
||||
- Diagnose the screenshot showing only a short prose fragment followed by a legacy parse-failure notice and raw structured output.
|
||||
- Diagnose and repair the screenshot showing only a short prose fragment followed by a legacy parse-failure notice and raw structured output.
|
||||
- After diagnosis, user authorized the three repair paths: conservative full-prose recovery, independent auxiliary-field fallback plus explicit manual retry, and passive recovery of retained history.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- Read-only investigation requested. No product edits, history changes, app restart, paid question, cloud access or deployment.
|
||||
- Initial investigation was read-only. The later implementation is explicitly authorized; keep local raw evidence and avoid automatic model replay, live app restart, cloud edits, or deployment. The earlier main merge/push request covered the completed Agent refresh feature.
|
||||
- Latest user constraint: no subagents. Remaining code review, changes and verification are performed by the primary agent; earlier partial subagent work was reviewed locally.
|
||||
- Ownership and planning gates passed in this task checkout. Reused unchanged canonical context from the prior turn; read all current peer task scopes. Current trial Main 9adab45 / Renderer cb48f60 belongs to the separate reply-cleanup task and is distinct from main 4495345. Inspected committed code only; peer working files remain untouched.
|
||||
|
||||
## Outcome
|
||||
|
||||
- The three approved repair paths are implemented and locally verified: conservative full-prose recovery, explicit incomplete-reply handling/manual retry, and passive retained-history recovery. The running client remains unchanged.
|
||||
- Implementation and final validation are recorded below; the following investigation notes preserve how the original diagnosis was established.
|
||||
|
||||
### Initial Diagnosis
|
||||
|
||||
- The screenshot notice maps to the legacy structured-response parse fallback. The UI retains unparsedResponse separately from the recovered prose, so content in the raw box was received even though normal presentation failed.
|
||||
- Independently reproduced an exact-shape failure: an unescaped ASCII quote after the opening prose causes JSON parsing to fail, while stringToken/recoverReply accepts only the prefix as a complete reply. The old parser returns the same short-prefix + screenshot notice combination. The newer shared/teacher-reply fields recovery in committed 9adab45 still accepts the same premature prefix.
|
||||
- This is a demonstrated mechanism, not a confirmed trace of the screenshot request. The exact question/response was not found in the known trial/installed-app project teacher histories. The screenshot shows only the tail of the raw output, so its precise triggering bytes and provider termination reason remain unknown.
|
||||
@@ -28,6 +35,10 @@
|
||||
|
||||
## Verification
|
||||
|
||||
- Final implementation checks: 329 unit checks, 17 headless UI cases, typecheck, scoped ESLint and production compilation passed. Native Electron case added and enumerated but not executed. The inherited dependency-document drift finding remains disclosed below.
|
||||
|
||||
### Initial Investigation Verification
|
||||
|
||||
- Read old shared/teacher-discussion.ts stringToken/recoverReply/parser fallback, UI diagnostics rendering, service/discussion persistence and cloud-runner final-output handling.
|
||||
- Independent agent performed an in-memory execution of both committed parsers with malformed quoted prose and reproduced the truncated prefix. No tests or product files added/modified.
|
||||
- Inspected only relevant local app runtime descriptors/project catalogs and teacher conversation records to seek the screenshot request; no matching request found. No credentials read or emitted.
|
||||
@@ -35,11 +46,11 @@
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- If repair is requested, fix conservative recovery in the current reply-only implementation, preserving exact raw diagnostics; do not restore retired discussion components. Verify the exact request raw prefix/provider finish reason before assigning a unique cause to this screenshot.
|
||||
- The requested repair is complete; integrate and update the running client as a separate next step. Provider byte-level correlation remains unavailable; retain the screenshot-level evidence boundary.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- None. This read-only diagnosis adds evidence and a repair direction, not an accepted product behavior change.
|
||||
- See Repair Follow-ups And Promotion Candidates below. Initial diagnosis introduced no accepted behavior change; the later repair was approved by the user.
|
||||
|
||||
## Agent Log Screenshot Follow-up
|
||||
|
||||
@@ -47,4 +58,30 @@
|
||||
- User supplied an Agent log screenshot containing the visible prefix `{"reply":"它早就不是"刚搭好架子"那种阶段了——` followed by a substantial prose answer and legacy structure payload. The earlier client screenshot shows exactly the prose prefix 它早就不是, the legacy fallback notice, and the later tool payload in the raw-content box.
|
||||
- Independent reviewer replayed the visible prefix in memory against the old discussion parser and committed 9adab45 reply parser. Both cut at precisely the same quote. Together the screenshots locate the observed short-body symptom to malformed-format handling/recovery, strongly contradicting a stream that delivered only the first few characters.
|
||||
- Distinguish upstream malformed structured output from downstream unsafe recovery: model-produced internal quotes need valid encoding, and the client must not accept an ambiguous prefix as complete prose. This is parsing before rendering, rather than visual clipping.
|
||||
- Evidence remains screenshot-level, not a request-correlated byte capture. A log renderer could alter escape display, so do not claim the full transport was byte-for-byte audited or all possible transport issues were excluded. No corrective code has been applied.
|
||||
- Evidence remains screenshot-level, not a request-correlated byte capture. A log renderer could alter escape display, so do not claim the full transport was byte-for-byte audited or all possible transport issues were excluded. No corrective code had been applied at this diagnostic stage; the approved implementation is recorded below.
|
||||
|
||||
## Approved Repair Implementation
|
||||
|
||||
- Same-task ownership and planning gates passed on resume; registry remains this task / feature / owned worktree and branch. Merged the committed current reply-only dependency `40c247a` through `dda2f0a` so the fix applies to the current consultation implementation. No peer working files or canonical documents were edited; protocol-boundary and welcome/child-language changes were not imported.
|
||||
- Prose extraction now requires a credible field boundary. Internal unescaped quotes and literal control characters are repaired only inside the prose field and only when the entire repaired envelope parses. Ambiguous boundaries, duplicate bodies and missing body endings fail visibly; missing content is never guessed. Ordinary Markdown, escaped code and Unicode remain intact.
|
||||
- Complete prose survives malformed or truncated auxiliary data. Replies without trustworthy complete prose get `replyIncomplete`; UI shows an explicit incomplete state and a manual retry, never the half-sentence as a finished answer. Raw diagnostics stay folded and inert, including when escaping was successfully repaired. Technical output instructions require proper JSON string escaping without changing the published persona.
|
||||
- Manual retry reuses original text, intent, references, source, topic and published version; an accepted completed turn gets a new request identity, uncertain resends reuse their identity. Click guarding prevents duplicate sends. Both current text/references and edits made while awaiting the send response survive. Project-only history does not substitute a different source or send the `project` sentinel as a conversation id. Disabled/legacy Agents and check-ins cannot be retried as ordinary questions.
|
||||
- Store reads repair eligible completed history from retained original content locally. Valid unrelated prose is not replaced by a different recovered answer. Failed/cancelled/interrupted/running replies are never promoted to success. The projection preserves raw data, opaque legacy fields, accounting, request status and topic metadata; no model call or repair-only disk write occurs. In mixed history, restart recovery is persisted before applying the display projection. Incomplete assistant content and suggestions are excluded from both compiled model context and read_conversation while the user's original question remains available.
|
||||
|
||||
## Implementation Verification
|
||||
|
||||
- Focused parser, history and Renderer unit suites: 153/153 passed.
|
||||
- Related service/cloud/model/read-tools/guidance/retired-route/activity suites: 176/176 passed (329 unit checks total).
|
||||
- Typecheck and scoped ESLint passed. Production Renderer/Main/Preload/worker compilation passed (`build:vite`); build artifacts remain ignored.
|
||||
- Headless Chrome layout/interaction suite: 17/17 passed, covering 319px and 508px widths, complete screenshot-shaped prose, incomplete manual retry, original input retention, no automatic send, draft retention and legacy-history behavior. Personally inspected narrow-panel recovered/incomplete screenshots. Initial browser run could not launch because the Playwright bundled binary was absent; reran successfully using the already-installed Chrome channel.
|
||||
- Added an Electron integration case for click-only retry and preservation of both main and consultation drafts. Native Electron case was not run because the existing fixture shows/focuses an app window; keep the user's no-focus workflow intact. Browser interaction and backend history tests passed independently; do not claim a native smoke or a real-provider end-to-end run.
|
||||
- Current live client was not replaced/restarted; code is ready for integration and a subsequent app update. Tests use deterministic local fixtures and do not consume paid model calls or modify real conversation history.
|
||||
|
||||
## Documentation Boundary Note
|
||||
|
||||
- Project-document structure check passed. The task-aware drift checker reports the already-committed cleanup task record as foreign relative to the original `4495345` base because this task merged the required reply-only dependency. That record is byte-identical to dependency `40c247a`; no owned-work edits touch it. Do not rewrite or delete the peer record, falsify the recorded base, or claim this inherited checker finding passed. All implementation-era document edits are limited to this task record and product README.
|
||||
|
||||
## Repair Follow-ups And Promotion Candidates
|
||||
|
||||
- Integrate the tested repair branch, then update the user's running client when requested. Exact historical screenshot request was not located locally; fixture tests reproduce the visible triggering bytes, not a provider trace.
|
||||
- Candidate for the canonical current-state/consultation architecture at integration: preserve trustworthy full prose independently of auxiliary data; flag ambiguous incomplete prose explicitly; user-triggered retries preserve original scope and draft; retained raw data permits local history repair without replay. Evidence: focused unit and headless UI checks above. Future impact: any new reply format/history migration must retain the same safety properties. No change to published persona, model billing boundary or retired component policy; no additional product decision required beyond the user's approved repair.
|
||||
|
||||
@@ -178,6 +178,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p
|
||||
- 老师咨询只展示老师正文和可点击的快捷回复卡片,学生也可在“和老师聊聊”输入框自由输入。点击卡片原样发送该回复,保留当前输入草稿;旧话题的数据不会恢复想法板、结构图、流程图或对照表及其操作。
|
||||
- “继续看看👀”由老师结合项目推荐一个具体切入点,快捷回复围绕这个切入点帮助学生接话;客户端不要求多个独立话题,也不固定正文长度、选项数量、前缀或表达风格。老师继续依据已配置职责帮助孩子整理想法、理解关系和承接已确认的共识,区分建议与已确认内容。
|
||||
- Main 对咨询回复统一使用 `{reply, quickReplies}`,本地读取工具的前言和云端非最终片段不当作最终回答;完整解析后展示正文和卡片,主动进展提醒仍直接展示正文。普通 Markdown、JSON 数据和代码示例保持正文。兼容读取旧 `{intro, questions}` 和带 `tool` 的回复,只提取正文与快捷回复,不恢复或更新组件。资源上限只用于防护,不做旧式字数截断;格式错误保留可读正文及原始回答,原文默认折叠、字面显示,不再次送进模型。
|
||||
- 回复中的引号或换行格式有误时,仅在正文边界明确、修复后整体合法的情况下恢复完整文字,不把半句话当成完整回答。附加建议损坏时仍展示完整正文;正文无法确认完整时显示“这次回复未能完整显示”,由学生点击“重新回答”,沿用原问题、引用、来源、话题和发布版本,保留当前草稿,连续点击不重复发送。打开旧话题会从保留的原文中在本地恢复可确认的正文,不调用模型,不因显示修复而改写磁盘记录;原始内容继续折叠保留。不完整的回答和建议不进入后续模型上下文。
|
||||
- 咨询正文支持 Markdown 标题、列表、表格、代码围栏、HTTP(S) 链接/图片和数学公式,长代码与表格在栏内横向滚动,不执行 HTML。云端正文维持 string 合同,未知对象不会猜测转成回答。工具活动只从云端主线程的类型化工具事件及 Main 本地读取过程获得,按本次问题/工具调用身份合并名称和状态,跨云端暂停、续接仍只计一次;暂停读取不算失败,状态以实际读取结果或问题终态为准,单独折叠显示;参数、结果原文和内部错误不混入回复,思考内容不作为正文。客户端仅保存自己实际收到的工具状态,不能补回旧历史或断线期间已经过期的事件。
|
||||
- 云端咨询在 local_context 声明 read_protocol=2;需先部署配套 Yuxi API 和 worker,再升级客户端。云端持久累计读取字节及批次,每轮告知模型剩余额度;预算耗尽后消费最后一批结果,并以 tool_choice=none 要求根据现有证据形成答案和说明缺口。Main 限制实际返回量并拒绝第十三批读取,区分读取达到上限、上下文失效及格式无效。服务端对未声明协议的已安装旧客户端保留原工具参数与六批边界。
|
||||
- 智能体人设、职责、提示词和 Skills 由发布配置决定。Main 不追加固定教学基线、不生成“小麦”、不清空所选智能体的 Skills,只添加真实工具能力、上下文边界和本轮界面协议。智能体名称叫“老师”“朋友”或“代码顾问”不改变调用路径或权限。
|
||||
|
||||
@@ -62,12 +62,12 @@ export function teacherHistoryMessages(history: TeacherRequest[]): TeacherSource
|
||||
id: 'teacher:' + request.id + ':user', role: 'user' as const,
|
||||
text: [...request.references.map(ref => '明确引用:\n' + ref.text), request.text].join('\n\n'),
|
||||
}]),
|
||||
{
|
||||
...(request.replyIncomplete ? [] : [{
|
||||
id: 'teacher:' + request.id + ':assistant', role: 'assistant' as const,
|
||||
text: [request.response, ...(request.suggestedQuestions?.length
|
||||
? ['可以接着聊的问题:\n' + request.suggestedQuestions.map(text => '- ' + text).join('\n')]
|
||||
: [])].join('\n\n'),
|
||||
},
|
||||
}]),
|
||||
]);
|
||||
}
|
||||
// A bounded text estimate for the native model path, not an exact tokenizer or
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { TeacherRequest } from '../../shared/coding-teacher';
|
||||
import { parseTeacherReply } from '../../shared/teacher-reply';
|
||||
|
||||
export function teacherReplyInstructions(): string {
|
||||
return '本轮界面接收一个 JSON 对象 {"reply":string,"quickReplies":string[]}。reply 是回复正文,可使用 Markdown;quickReplies 是可选的后续回复建议,没有建议时用空数组。只使用这两个字段。依据已配置职责帮助学生整理想法、理解关系、承接已确认的共识;区分学生已确认内容与智能体建议、待定想法。回复的语言、风格、长度、前缀和建议内容遵循当前智能体的云端配置。';
|
||||
return '本轮界面接收一个 JSON 对象 {"reply":string,"quickReplies":string[]}。reply 是回复正文,可使用 Markdown;quickReplies 是可选的后续回复建议,没有建议时用空数组。只使用这两个字段。字符串中的英文双引号、反斜杠和换行必须按 JSON 规则转义,确保整个对象是合法 JSON。依据已配置职责帮助学生整理想法、理解关系、承接已确认的共识;区分学生已确认内容与智能体建议、待定想法。回复的语言、风格、长度、前缀和建议内容遵循当前智能体的云端配置。';
|
||||
}
|
||||
|
||||
/** The original response remains available for diagnosis when parsing is incomplete. */
|
||||
@@ -10,11 +10,14 @@ export function applyTeacherReply(request: TeacherRequest, raw: string): void {
|
||||
const parsed = parseTeacherReply(raw);
|
||||
request.response = parsed.reply;
|
||||
request.suggestedQuestions = parsed.quickReplies;
|
||||
if (parsed.incomplete) request.replyIncomplete = true;
|
||||
else delete request.replyIncomplete;
|
||||
if (parsed.parseError) {
|
||||
request.replyParseError = parsed.parseError;
|
||||
request.unparsedResponse = raw;
|
||||
} else {
|
||||
delete request.replyParseError;
|
||||
delete request.unparsedResponse;
|
||||
delete request.discussionError;
|
||||
}
|
||||
if (parsed.parseError || parsed.recovered) request.unparsedResponse = raw;
|
||||
else delete request.unparsedResponse;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,40 @@
|
||||
import { mkdir, readdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { atomicWriteJson, readJsonFile } from '../coding-projects/atomic-json';
|
||||
import type { TeacherTopic, TeacherTopicList } from '../../shared/coding-teacher';
|
||||
import type { TeacherRequest, TeacherTopic, TeacherTopicList } from '../../shared/coding-teacher';
|
||||
import { isTeacherReplyEnvelope, parseTeacherReply } from '../../shared/teacher-reply';
|
||||
import { TeacherError } from './config-client';
|
||||
|
||||
/** Repair only received, completed protocol replies; never resume a model request. */
|
||||
function repairCompletedReply(request: TeacherRequest): void {
|
||||
const raw = request.unparsedResponse;
|
||||
if (request.status !== 'completed' || typeof raw !== 'string'
|
||||
|| !(request.discussionError || request.replyParseError || request.replyIncomplete)
|
||||
|| !isTeacherReplyEnvelope(raw)) return;
|
||||
const parsed = parseTeacherReply(raw);
|
||||
if (parsed.incomplete) {
|
||||
// Keep the previous text and diagnostics as evidence, but do not present a
|
||||
// legacy half-sentence as a complete reply.
|
||||
request.replyIncomplete = true;
|
||||
return;
|
||||
}
|
||||
const previous = request.response.trim();
|
||||
// The retained raw data must extend or confirm the existing prose. A stale or
|
||||
// unrelated archive must not replace a useful answer with different content.
|
||||
if (previous && !parsed.reply.trim().startsWith(previous)
|
||||
&& previous !== '这次回复没有完整解析,请重试。'
|
||||
&& previous !== raw.trim() && !request.replyIncomplete) return;
|
||||
request.response = parsed.reply;
|
||||
request.suggestedQuestions = parsed.quickReplies;
|
||||
delete request.replyIncomplete;
|
||||
if (parsed.parseError) request.replyParseError = parsed.parseError;
|
||||
else {
|
||||
delete request.replyParseError;
|
||||
delete request.discussionError;
|
||||
}
|
||||
// Unlike applying a new reply, reading history never discards the original.
|
||||
}
|
||||
|
||||
export function teacherTopicId(id: string): string {
|
||||
if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(id))
|
||||
throw new TeacherError(400, 'teacher_topic_invalid', '智能体话题标识无效。');
|
||||
@@ -60,16 +91,20 @@ export class TeacherTopicStore {
|
||||
throw new TeacherError(409, 'teacher_topic_invalid', '智能体历史无法读取。');
|
||||
let recovered = false;
|
||||
// Legacy component fields are opaque archives: never validate, revive or delete them.
|
||||
for (const request of topic.requests)
|
||||
for (const request of topic.requests) {
|
||||
if (request.status === 'preparing' || request.status === 'running') {
|
||||
request.status = 'interrupted';
|
||||
request.error = '应用已重启,本次回复中断。';
|
||||
recovered = true;
|
||||
}
|
||||
}
|
||||
if (recovered) {
|
||||
topic.revision++;
|
||||
await this.save(topic);
|
||||
}
|
||||
// Apply display repair after restart recovery is saved, so reading a
|
||||
// mixed topic does not incidentally persist completed-reply repairs.
|
||||
for (const request of topic.requests) repairCompletedReply(request);
|
||||
return topic;
|
||||
})();
|
||||
this.cache.set(id, pending);
|
||||
|
||||
@@ -74,7 +74,9 @@ export interface TeacherRequest {
|
||||
discussionSnapshot?: unknown;
|
||||
discussionError?: string;
|
||||
replyParseError?: string;
|
||||
/** Original answer retained when reply parsing fails; never fed back as context. */
|
||||
/** No trustworthy complete reply could be recovered from the received text. */
|
||||
replyIncomplete?: boolean;
|
||||
/** Original answer retained for failed or repaired parsing; never fed back as context. */
|
||||
unparsedResponse?: string;
|
||||
toolActivity?: TeacherToolActivity[];
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@ export interface TeacherReply {
|
||||
reply: string;
|
||||
quickReplies: string[];
|
||||
parseError?: string;
|
||||
/** No unambiguous, complete prose field could be recovered. */
|
||||
incomplete?: boolean;
|
||||
/** Prose escaping was repaired; retain the original bytes for diagnosis. */
|
||||
recovered?: boolean;
|
||||
}
|
||||
|
||||
// Resource limits, not writing instructions. Ordinary answers below the transport
|
||||
@@ -27,6 +31,26 @@ function stringEnd(raw: string, start: number): number | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** A quote alone is not a field boundary: it may be punctuation in the prose. */
|
||||
function fieldBoundary(raw: string, end: number): boolean {
|
||||
let index = end;
|
||||
while (/\s/.test(raw[index] ?? '') && index < raw.length) index++;
|
||||
if (raw[index] === '}') {
|
||||
while (index < raw.length && (raw[index] === '}' || /\s/.test(raw[index]))) index++;
|
||||
return index === raw.length;
|
||||
}
|
||||
if (raw[index++] !== ',') return false;
|
||||
while (/\s/.test(raw[index] ?? '') && index < raw.length) index++;
|
||||
if (index === raw.length) return true; // Complete value, missing later fields.
|
||||
if (raw[index] !== '"') return false;
|
||||
const keyEnd = stringEnd(raw, index);
|
||||
if (keyEnd === undefined) return false;
|
||||
try { JSON.parse(raw.slice(index, keyEnd)); } catch { return false; }
|
||||
index = keyEnd;
|
||||
while (/\s/.test(raw[index] ?? '') && index < raw.length) index++;
|
||||
return raw[index] === ':';
|
||||
}
|
||||
|
||||
/** Find one complete value without interpreting nested legacy tool data. */
|
||||
function valueEnd(raw: string, start: number): number | undefined {
|
||||
if (raw[start] === '"') return stringEnd(raw, start);
|
||||
@@ -52,10 +76,11 @@ function valueEnd(raw: string, start: number): number | undefined {
|
||||
}
|
||||
|
||||
/** Recover only complete top-level fields; nested reply-looking data stays inert. */
|
||||
function fields(raw: string): { values: Map<string, unknown>; keys: Set<string>; duplicate: boolean; toolStart?: number } {
|
||||
function fields(raw: string): { values: Map<string, unknown>; keys: Set<string>; duplicate: boolean; toolStart?: number; proseStart?: number } {
|
||||
const values = new Map<string, unknown>(), keys = new Set<string>();
|
||||
let duplicate = false, index = raw.indexOf('{') + 1;
|
||||
let toolStart: number | undefined;
|
||||
let proseStart: number | undefined;
|
||||
const whitespace = () => { while (index < raw.length && /\s/.test(raw[index])) index++; };
|
||||
while (index > 0 && index < raw.length) {
|
||||
whitespace();
|
||||
@@ -71,8 +96,9 @@ function fields(raw: string): { values: Map<string, unknown>; keys: Set<string>;
|
||||
keys.add(key);
|
||||
whitespace();
|
||||
if (key === 'tool') toolStart = index;
|
||||
if ((key === 'reply' || key === 'intro') && proseStart === undefined) proseStart = index;
|
||||
const next = valueEnd(raw, index);
|
||||
if (next === undefined) break;
|
||||
if (next === undefined || !fieldBoundary(raw, next)) break;
|
||||
if ((protocolKeys.has(key) || key === 'kind') && key !== 'tool' && !values.has(key)) {
|
||||
try { values.set(key, JSON.parse(raw.slice(index, next))); } catch { break; }
|
||||
}
|
||||
@@ -80,7 +106,33 @@ function fields(raw: string): { values: Map<string, unknown>; keys: Set<string>;
|
||||
whitespace();
|
||||
if (raw[index++] !== ',') break;
|
||||
}
|
||||
return { values, keys, duplicate, toolStart };
|
||||
return { values, keys, duplicate, toolStart, proseStart };
|
||||
}
|
||||
|
||||
/** Repair only prose escaping, never fabricate missing text or consume other fields. */
|
||||
function repairProse(raw: string, start: number | undefined): string | undefined {
|
||||
if (start === undefined || raw[start] !== '"') return undefined;
|
||||
for (let index = start + 1; index < raw.length; index++) {
|
||||
if (raw[index] === '\\') { index++; continue; }
|
||||
if (raw[index] !== '"' || !fieldBoundary(raw, index + 1)) continue;
|
||||
// Stop at the first field boundary even if later auxiliary data is broken.
|
||||
// Searching past it could swallow tool data into a fabricated prose field.
|
||||
let encoded = '"';
|
||||
for (let cursor = start + 1; cursor < index; cursor++) {
|
||||
const character = raw[cursor];
|
||||
if (character === '\\') { encoded += character + raw[++cursor]; continue; }
|
||||
encoded += character === '"' || character.charCodeAt(0) < 32
|
||||
? JSON.stringify(character).slice(1, -1) : character;
|
||||
}
|
||||
encoded += '"';
|
||||
if (encoded === raw.slice(start, index + 1)) return undefined;
|
||||
const repaired = raw.slice(0, start) + encoded + raw.slice(index + 1);
|
||||
try {
|
||||
JSON.parse(repaired);
|
||||
return repaired;
|
||||
} catch { return undefined; }
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface Candidate { start: number; end: number; content: string; incomplete?: boolean }
|
||||
@@ -88,6 +140,13 @@ function isEnvelope(raw: string): boolean {
|
||||
const scanned = fields(raw);
|
||||
if (scanned.keys.has('reply') || scanned.keys.has('quickReplies')
|
||||
|| (scanned.keys.has('intro') && scanned.keys.has('questions'))) return true;
|
||||
if (scanned.keys.has('intro')) {
|
||||
const repaired = repairProse(raw, scanned.proseStart);
|
||||
if (repaired) {
|
||||
const envelope = JSON.parse(repaired) as Record<string, unknown>;
|
||||
if (own(envelope, 'intro') && own(envelope, 'questions')) return true;
|
||||
}
|
||||
}
|
||||
// "questions" and "tool" also occur in ordinary API/code examples. A tool
|
||||
// alone identifies the retired reply protocol only through its legacy kind.
|
||||
if (scanned.toolStart !== undefined && raw[scanned.toolStart] === '{') {
|
||||
@@ -99,6 +158,16 @@ function isEnvelope(raw: string): boolean {
|
||||
|
||||
/** Markdown examples stay prose unless a top-level reply envelope identifies them. */
|
||||
function candidates(raw: string): Candidate[] {
|
||||
if (raw.startsWith('{') && isEnvelope(raw)) {
|
||||
const end = valueEnd(raw, 0);
|
||||
try {
|
||||
if (end === undefined) throw new Error();
|
||||
JSON.parse(raw.slice(0, end));
|
||||
} catch {
|
||||
// Literal newlines/code fences within damaged prose belong to this envelope.
|
||||
return [{ start: 0, end: raw.length, content: raw }];
|
||||
}
|
||||
}
|
||||
const result: Candidate[] = [];
|
||||
const inspectText = (start: number, end: number) => {
|
||||
for (let index = start; index < end; index++) {
|
||||
@@ -116,9 +185,14 @@ function candidates(raw: string): Candidate[] {
|
||||
}
|
||||
} else if (raw[index] === '{') {
|
||||
const objectEnd = valueEnd(raw, index);
|
||||
const stop = objectEnd !== undefined && objectEnd <= end ? objectEnd : end;
|
||||
const content = raw.slice(index, stop);
|
||||
if (isEnvelope(content)) result.push({ start: index, end: stop, content });
|
||||
let stop = objectEnd !== undefined && objectEnd <= end ? objectEnd : end;
|
||||
let content = raw.slice(index, stop);
|
||||
if (isEnvelope(content)) {
|
||||
// A stray quote can also fool bracket scanning. Keep the received tail
|
||||
// with the malformed envelope instead of appending it as normal prose.
|
||||
try { JSON.parse(content); } catch { stop = end; content = raw.slice(index, stop); }
|
||||
result.push({ start: index, end: stop, content });
|
||||
}
|
||||
index = stop - 1;
|
||||
}
|
||||
}
|
||||
@@ -145,6 +219,11 @@ function candidates(raw: string): Candidate[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Historical diagnostics must be identifiable as our envelope, not arbitrary raw text. */
|
||||
export function isTeacherReplyEnvelope(raw: string): boolean {
|
||||
return withinBytes(raw, MAX_TEACHER_REPLY_BYTES) && candidates(raw.trim()).length > 0;
|
||||
}
|
||||
|
||||
function projectReply(envelope: Record<string, unknown>): TeacherReply {
|
||||
const reply = own(envelope, 'reply') ? envelope.reply : envelope.intro;
|
||||
const suggestions = own(envelope, 'quickReplies') ? envelope.quickReplies : envelope.questions;
|
||||
@@ -159,7 +238,7 @@ function projectReply(envelope: Record<string, unknown>): TeacherReply {
|
||||
} else quickReplies = suggestions;
|
||||
}
|
||||
if (typeof reply !== 'string' || (!reply.trim() && !quickReplies.length)) {
|
||||
return { reply: INVALID_REPLY, quickReplies, parseError: INVALID_FORMAT };
|
||||
return { reply: INVALID_REPLY, quickReplies: [], parseError: INVALID_FORMAT, incomplete: true };
|
||||
}
|
||||
return { reply, quickReplies, ...(parseError ? { parseError } : {}) };
|
||||
}
|
||||
@@ -167,31 +246,40 @@ function projectReply(envelope: Record<string, unknown>): TeacherReply {
|
||||
/** Only text and suggested replies survive; legacy tool payloads are never restored. */
|
||||
export function parseTeacherReply(raw: string): TeacherReply {
|
||||
if (!withinBytes(raw, MAX_TEACHER_REPLY_BYTES)) {
|
||||
return { reply: INVALID_REPLY, quickReplies: [], parseError: RESOURCE_LIMIT };
|
||||
return { reply: INVALID_REPLY, quickReplies: [], parseError: RESOURCE_LIMIT, incomplete: true };
|
||||
}
|
||||
const text = raw.trim();
|
||||
if (!text) return { reply: INVALID_REPLY, quickReplies: [], parseError: INVALID_FORMAT };
|
||||
if (!text) return { reply: INVALID_REPLY, quickReplies: [], parseError: INVALID_FORMAT, incomplete: true };
|
||||
const found = candidates(text);
|
||||
if (!found.length) return { reply: text, quickReplies: [] };
|
||||
const candidate = found[0];
|
||||
let result: TeacherReply;
|
||||
const recovered = fields(candidate.content);
|
||||
let recovered = fields(candidate.content);
|
||||
try {
|
||||
const envelope: unknown = JSON.parse(candidate.content);
|
||||
if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) throw new Error();
|
||||
result = projectReply(envelope as Record<string, unknown>);
|
||||
} catch {
|
||||
result = projectReply(Object.fromEntries(recovered.values));
|
||||
result.parseError = INVALID_FORMAT;
|
||||
const repaired = repairProse(candidate.content, recovered.proseStart);
|
||||
if (repaired) {
|
||||
recovered = fields(repaired);
|
||||
result = { ...projectReply(JSON.parse(repaired)), recovered: true };
|
||||
} else {
|
||||
result = projectReply(Object.fromEntries(recovered.values));
|
||||
result.parseError = INVALID_FORMAT;
|
||||
}
|
||||
}
|
||||
if (candidate.incomplete) result.parseError = INVALID_FORMAT;
|
||||
if (recovered.duplicate || found.length > 1) {
|
||||
result = { reply: INVALID_REPLY, quickReplies: [], parseError: INVALID_FORMAT, incomplete: true };
|
||||
}
|
||||
if (candidate.incomplete || recovered.duplicate || found.length > 1) result.parseError = INVALID_FORMAT;
|
||||
// Multiple envelopes are ambiguous; never append their JSON as visible prose.
|
||||
if (found.length === 1) {
|
||||
const suffix = text.slice(candidate.end).trim();
|
||||
const brokenTail = /^[,\s]*"(?:reply|quickReplies|intro|questions|tool)"\s*:/.test(suffix)
|
||||
|| (suffix.length > 0 && /^[}\],\s]+$/.test(suffix));
|
||||
if (brokenTail) result.parseError = INVALID_FORMAT;
|
||||
result.reply = [text.slice(0, candidate.start).trim(), result.reply, brokenTail ? '' : suffix]
|
||||
if (!result.incomplete) result.reply = [text.slice(0, candidate.start).trim(), result.reply, brokenTail ? '' : suffix]
|
||||
.filter(part => part.length > 0).join('\n\n');
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
TeacherDefinition,
|
||||
TeacherCatalog,
|
||||
TeacherReference,
|
||||
TeacherRequest,
|
||||
TeacherSend,
|
||||
TeacherTopic,
|
||||
TeacherTopicList,
|
||||
@@ -108,6 +109,7 @@ export function TeacherChatPanel({
|
||||
? teachers.some((item) => item.teacher_id === topic.definition.config_id)
|
||||
: legacyEnabled;
|
||||
const pending = useRef<TeacherSend | null>(restorePendingSend(saved.pending));
|
||||
const sendInFlight = useRef(false);
|
||||
useEffect(() => {
|
||||
persistDraft(text, references, pending.current);
|
||||
}, [text, references, persistDraft]);
|
||||
@@ -293,11 +295,19 @@ export function TeacherChatPanel({
|
||||
if (version === generation.current) setBusy(false);
|
||||
}
|
||||
};
|
||||
const send = async (action?: Pick<TeacherSend, 'text' | 'intent'>) => {
|
||||
const send = async (action?: Pick<TeacherSend, 'text' | 'intent'>, retry?: TeacherRequest) => {
|
||||
// A historical retry stays with its original topic and published Agent.
|
||||
if (retry && (topicRef.current?.id !== topic?.id || topic?.projectId !== projectId
|
||||
|| (selectedAgentId && topic.definition.config_id !== selectedAgentId)
|
||||
|| !topic.requests.some((request) => request === retry && request.status === 'completed' && request.replyIncomplete))) return;
|
||||
const question = action?.text ?? text;
|
||||
const questionReferences = action ? [] : references;
|
||||
if (!question.trim() || busy || !topicEnabled || topicRef.current?.requests.some((request) => ['preparing', 'running'].includes(request.status))) return;
|
||||
const questionReferences = retry?.references ?? (action ? [] : references);
|
||||
const questionSourceId = retry
|
||||
? retry.sourceConversationId ?? (topic?.sourceConversationId === 'project' ? undefined : topic?.sourceConversationId)
|
||||
: sourceId;
|
||||
if (!question.trim() || busy || sendInFlight.current || !topicEnabled || topicRef.current?.requests.some((request) => ['preparing', 'running'].includes(request.status))) return;
|
||||
const version = generation.current;
|
||||
sendInFlight.current = true;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
let current = topicRef.current;
|
||||
@@ -317,18 +327,19 @@ export function TeacherChatPanel({
|
||||
? pending.current
|
||||
: null;
|
||||
// Copy changes must not create another charge for an uncertain hint request.
|
||||
const retrySuggestions = action?.intent === 'suggestions' && previous?.intent === 'suggestions';
|
||||
const retrySuggestions = !retry && action?.intent === 'suggestions' && previous?.intent === 'suggestions';
|
||||
const input =
|
||||
previous && (retrySuggestions || (
|
||||
previous.text === question &&
|
||||
(previous.intent ?? 'question') === (action?.intent ?? 'question') &&
|
||||
JSON.stringify(previous.references) === JSON.stringify(questionReferences)
|
||||
JSON.stringify(previous.references) === JSON.stringify(questionReferences) &&
|
||||
(!retry || previous.sourceConversationId === (questionSourceId || undefined))
|
||||
))
|
||||
? previous
|
||||
: {
|
||||
requestId: crypto.randomUUID(), text: question, references: questionReferences,
|
||||
...(action?.intent ? { intent: action.intent } : {}),
|
||||
...(sourceId ? { sourceConversationId: sourceId } : {}),
|
||||
...(questionSourceId ? { sourceConversationId: questionSourceId } : {}),
|
||||
};
|
||||
pending.current = input;
|
||||
persistDraft(text, references, input);
|
||||
@@ -337,7 +348,7 @@ export function TeacherChatPanel({
|
||||
consume(next);
|
||||
pending.current = null;
|
||||
if (action) {
|
||||
persistDraft(text, references);
|
||||
persistDraft(draftRef.current.text, draftRef.current.references);
|
||||
} else {
|
||||
setText('');
|
||||
setReferences([]);
|
||||
@@ -348,6 +359,7 @@ export function TeacherChatPanel({
|
||||
if (version === generation.current)
|
||||
setError(e instanceof Error ? e.message : '提问失败,请保留问题后重试');
|
||||
} finally {
|
||||
sendInFlight.current = false;
|
||||
if (version === generation.current) setBusy(false);
|
||||
}
|
||||
};
|
||||
@@ -395,7 +407,10 @@ export function TeacherChatPanel({
|
||||
</li>)}
|
||||
</ul>
|
||||
</details>}
|
||||
{request.intent === 'suggestions' ? <>
|
||||
{request.status === 'completed' && request.replyIncomplete ? <div className="space-y-3">
|
||||
<p role="status" className="text-[13px] leading-7 text-muted-foreground">这次回复未能完整显示</p>
|
||||
{request.intent !== 'check-in' && request.text.trim() && <button type="button" disabled={helpUnavailable} onClick={() => void send({ text: request.text, intent: request.intent }, request)} className="consultation-retry">重新回答</button>}
|
||||
</div> : request.intent === 'suggestions' ? <>
|
||||
{['running', 'preparing'].includes(request.status)
|
||||
? <p role="status" className="flex items-center gap-2 text-[13px] leading-7 text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />我看看你最近做到了哪里…</p>
|
||||
: request.status === 'completed' && <>
|
||||
@@ -406,9 +421,10 @@ export function TeacherChatPanel({
|
||||
</>}
|
||||
{['failed', 'cancelled', 'interrupted'].includes(request.status) && <button type="button" disabled={helpUnavailable} onClick={askForSuggestions} className="consultation-retry">继续看看👀</button>}
|
||||
</> : request.response ? <TeacherReply text={request.response} /> : <div className="text-[13px] leading-7">{['running', 'preparing'].includes(request.status) ? <span role="status" className="inline-flex items-center gap-2 text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />{label}正在想…</span> : ['cancelled', 'interrupted'].includes(request.status) ? '这次先停在这里。想继续时可以再说说。' : ''}</div>}
|
||||
{request.intent !== 'suggestions' && request.status === 'completed' && Boolean(request.suggestedQuestions?.length) && <div className="consultation-follow-ups" aria-label="接着聊">
|
||||
{request.intent !== 'suggestions' && request.status === 'completed' && !request.replyIncomplete && Boolean(request.suggestedQuestions?.length) && <div className="consultation-follow-ups" aria-label="接着聊">
|
||||
{request.suggestedQuestions?.map((question, index) => <button type="button" key={`${request.id}:${index}`} disabled={helpUnavailable} onClick={() => void send({ text: question })} className="consultation-follow-up"><span className="min-w-0 flex-1">{question}</span><ChevronRight className="h-4 w-4 shrink-0 opacity-70" aria-hidden="true" /></button>)}
|
||||
</div>}
|
||||
{request.status === 'completed' && !request.replyIncomplete && request.replyParseError && <p className="text-xs text-muted-foreground">回答正文已保留,部分附加内容未能显示。</p>}
|
||||
{request.unparsedResponse && <details className="min-w-0 text-xs text-muted-foreground">
|
||||
<summary className="cursor-pointer">查看收到的原始内容</summary>
|
||||
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg border p-3">{request.unparsedResponse}</pre>
|
||||
@@ -416,7 +432,7 @@ export function TeacherChatPanel({
|
||||
{request.progress && ['running', 'preparing'].includes(request.status) && <p role="status" className="text-xs text-muted-foreground">{request.progress}</p>}
|
||||
{(request.truncatedMessages ?? 0) > 0 && <p className="text-xs text-muted-foreground">{draftRevision ? '较长的上下文已节选。' : '较长的上下文已节选,智能体可按需读取原文。'}</p>}
|
||||
{request.error && <p className="text-xs text-destructive">{request.error}</p>}
|
||||
{onBringBack && request.response && request.intent !== 'suggestions' && request.status === 'completed' && <div className="text-[11px] text-muted-foreground">
|
||||
{onBringBack && request.response && !request.replyIncomplete && request.intent !== 'suggestions' && request.status === 'completed' && <div className="text-[11px] text-muted-foreground">
|
||||
<button onClick={() => onBringBack(request.response)}>带回主会话草稿</button>
|
||||
</div>}
|
||||
</div>)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Deterministic responses for layout/interaction tests only; never imported by the app.
|
||||
import type { TeacherDefinition, TeacherTopic, TeacherSend } from '../../../shared/coding-teacher';
|
||||
import { parseTeacherReply } from '../../../shared/teacher-reply';
|
||||
const definition: TeacherDefinition = { schema_version: 1, teacher_id: 'coding-teacher', name: '麦洛智能体', description: '', avatar_id: 'avatar-01', welcome_message: '', suggested_questions: [], system_prompt: '测试', skills: [], model: { model_id: 'fixture', reasoning_choice: { mode: 'default' } }, limits: { max_input_tokens: 8000, max_output_tokens: 1500 } };
|
||||
const samples: Record<string, unknown> = {
|
||||
ideas: { kind: 'ideas', title: '我的小狗世界', items: [{ id: 'dog', text: '养一只自己的小狗', state: 'kept' }, { id: 'breed', parentId: 'dog', text: '是一只柯基', state: 'kept' }, { id: 'garden', text: '有一片可以种东西的花园', state: 'kept' }, { id: 'food', parentId: 'garden', text: '种出来的东西可以喂小狗', state: 'suggested' }] },
|
||||
@@ -10,6 +11,17 @@ const samples: Record<string, unknown> = {
|
||||
const scenario = new URLSearchParams(location.search).get('kind') ?? 'ideas';
|
||||
let current: TeacherTopic = { schemaVersion: 1, revision: 1, id: 'discussion-fixture', accountId: 'fixture', projectId: 'fixture', sourceConversationId: 'project', definition, version: 1, createdAt: 'now', updatedAt: 'now', requests: Array.from({ length: 8 }, (_, i) => ({ id: `r${i}`, text: i === 7 ? '我还没想好,我们边聊边想。' : '我希望这个游戏更有意思。', references: [], createdAt: 'now', sourceCursor: { workerGeneration: 0, seq: i }, sourceCapturedAt: 'now', includedSourceMessageIds: [], omittedMessages: 0, status: 'completed', response: i === 7 ? '可以,我们先把已经想到的放在一起。哪里还不确定,就留着继续聊。' : '先从你最在意的一个小地方开始想。', ...(i === 7 ? { suggestedQuestions: ['有什么好玩的想法?'] } : {}) })), ...(samples[scenario] ? { discussion: { id: 'tool', revision: 1, status: 'active', content: structuredClone(samples[scenario]) } } : {}) };
|
||||
const stream = Object.assign(new EventTarget(), { close() {}, onerror: null, onopen: null });
|
||||
if (scenario === 'recovered' || scenario === 'incomplete') {
|
||||
const raw = '{"reply":"它早就不是"刚搭好架子"那种阶段了——骨架、玩法、成长线都在跑。\n现在的阶段是「能玩,正在调手感」。'
|
||||
+ (scenario === 'recovered' ? '","quickReplies":[],"tool":{"kind":"structure","nodes":[]}}' : '');
|
||||
const parsed = parseTeacherReply(raw);
|
||||
current.requests = [{
|
||||
...current.requests[0], text: '现在项目是什么阶段?', sourceConversationId: 'original-source',
|
||||
references: [{ messageId: 'original-message', text: '原来的项目记录', role: 'assistant' }],
|
||||
response: parsed.reply, replyIncomplete: parsed.incomplete, replyParseError: parsed.parseError,
|
||||
suggestedQuestions: parsed.quickReplies, unparsedResponse: raw,
|
||||
}];
|
||||
}
|
||||
if (scenario === 'retry') {
|
||||
const status = new URLSearchParams(location.search).get('status') === 'cancelled' ? 'cancelled' : 'failed';
|
||||
current.requests = [{
|
||||
|
||||
@@ -22,6 +22,7 @@ type HostConnection = {
|
||||
};
|
||||
|
||||
type TeacherCatalogFixture = {
|
||||
nextTeacherReplyIncomplete?: boolean;
|
||||
teacherCatalog: TeacherCatalog;
|
||||
teacherCatalogFailure: boolean;
|
||||
holdTeacherCatalog: boolean;
|
||||
@@ -575,6 +576,12 @@ async function installCodingFirstChatHost(
|
||||
const suggestionRequest = body!.intent === 'suggestions';
|
||||
const teacherResponse = suggestionRequest ? '我们可以从你最近试过的地方聊起。' : '先理解状态如何随点击变化,再修改代码。';
|
||||
consultationTopics[consultationPath]={...currentTopic,revision:Number(currentTopic?.revision??0)+1,requests:[...(currentTopic?.requests as unknown[]??[]),{id:body!.requestId,text:body!.text,intent:body!.intent,references:body!.references??[],createdAt:now,sourceCursor:{workerGeneration:1,seq:1},sourceCapturedAt:now,includedSourceMessageIds:[],omittedMessages:0,truncatedMessages:1,status:'completed',response:teacherResponse,...(suggestionRequest?{suggestedQuestions:['怎样观察别人玩游戏?','我该先试哪个想法?']}:{})}]};
|
||||
if (state.nextTeacherReplyIncomplete) {
|
||||
state.nextTeacherReplyIncomplete = false;
|
||||
const reply = (consultationTopics[consultationPath].requests as Record<string, unknown>[]).at(-1)!;
|
||||
Object.assign(reply, { response: '它早就不是', replyIncomplete: true, replyParseError: '格式不完整',
|
||||
sourceConversationId: body!.sourceConversationId, unparsedResponse: '{"reply":"它早就不是"刚搭好架子' });
|
||||
}
|
||||
return respond(consultationTopics[consultationPath],202);
|
||||
}
|
||||
if (path.endsWith(`${topicBase}/${topicId}`)) return respond(currentTopic);
|
||||
@@ -1894,6 +1901,50 @@ test('project consultations preserve student drafts and switch between work and
|
||||
});
|
||||
|
||||
|
||||
test('incomplete consultation replies retry only on click and preserve both drafts', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
let page = await getStableWindow(app);
|
||||
const connection = await page.evaluate(async () => ({
|
||||
token: await window.electron.ipcRenderer.invoke('hostapi:token') as string,
|
||||
baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string,
|
||||
}));
|
||||
await installCodingFirstChatHost(app, connection, true);
|
||||
await settleSnapshot(app);
|
||||
await disableCodingEventSource(page);
|
||||
await app.evaluate(() => {
|
||||
(globalThis as typeof globalThis & { __makelorePiFirstChatE2E: TeacherCatalogFixture })
|
||||
.__makelorePiFirstChatE2E.nextTeacherReplyIncomplete = true;
|
||||
});
|
||||
try {
|
||||
await page.reload();
|
||||
page = await getStableWindow(app);
|
||||
await page.getByTestId('ai-module-option-programming').click();
|
||||
await page.evaluate(() => { window.location.hash = '/chat'; });
|
||||
const mainDraft = page.getByTestId('coding-message-composer').getByRole('textbox', { includeHidden: true });
|
||||
await mainDraft.fill('主对话草稿');
|
||||
await page.getByRole('button', { name: '与代码智能体聊天', exact: true }).click();
|
||||
const teacher = page.getByTestId('teacher-chat-panel');
|
||||
const draft = teacher.getByRole('textbox', { name: '向智能体提问' });
|
||||
await draft.fill('现在项目是什么阶段?');
|
||||
await teacher.getByRole('button', { name: '提问', exact: true }).click();
|
||||
await expect(teacher.getByRole('status')).toHaveText('这次回复未能完整显示');
|
||||
await expect(teacher.getByTestId('teacher-reply')).toHaveCount(0);
|
||||
await expect(teacher.getByText('查看收到的原始内容').locator('..')).not.toHaveAttribute('open');
|
||||
const sends = async () => (await readState(app)).captured.filter(item => item.path.endsWith('/messages') && item.method === 'POST');
|
||||
expect(await sends()).toHaveLength(1);
|
||||
await draft.fill('咨询里还没写完的草稿');
|
||||
await teacher.getByRole('button', { name: '重新回答' }).click();
|
||||
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
|
||||
await expect(draft).toHaveValue('咨询里还没写完的草稿');
|
||||
await expect(mainDraft).toHaveValue('主对话草稿');
|
||||
const inputs = await sends();
|
||||
expect(inputs).toHaveLength(2);
|
||||
expect(inputs[1].path).toBe(inputs[0].path);
|
||||
expect(inputs[1].body).toEqual({ ...inputs[0].body, requestId: expect.any(String) });
|
||||
expect(inputs[1].body!.requestId).not.toBe(inputs[0].body!.requestId);
|
||||
} finally { await releaseSnapshot(app); }
|
||||
});
|
||||
|
||||
test('manual agent refresh updates the catalog and retries failures without replacing the open topic or drafts', async ({ launchElectronApp }) => {
|
||||
const electronApp = await launchElectronApp({ skipSetup: true });
|
||||
let page = await getStableWindow(electronApp);
|
||||
|
||||
@@ -47,6 +47,40 @@ test('retry stickers stay readable and preserve drafts in a 319px consultation',
|
||||
});
|
||||
|
||||
for (const width of [319, 508]) {
|
||||
test(`quoted replies recover fully and incomplete replies retry without losing the draft at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1180, height: 800 });
|
||||
const sent = () => page.evaluate(() => (window as unknown as { __teacherReplyInputs: unknown[] }).__teacherReplyInputs);
|
||||
await page.goto(`${url}?kind=recovered&width=${width}`);
|
||||
const panel = page.getByTestId('teacher-chat-panel');
|
||||
const body = page.getByTestId('teacher-reply');
|
||||
await expect(body).toContainText('它早就不是"刚搭好架子"那种阶段了');
|
||||
await expect(body).toContainText('现在的阶段是「能玩,正在调手感」。');
|
||||
await expect(page.getByText('查看收到的原始内容').locator('..')).not.toHaveAttribute('open');
|
||||
await expect(page.getByRole('button', { name: '重新回答' })).toHaveCount(0);
|
||||
expect(await sent()).toEqual([]);
|
||||
expect(await body.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
|
||||
await panel.screenshot({ path: test.info().outputPath(`recovered-${width}.png`) });
|
||||
|
||||
await page.goto(`${url}?kind=incomplete&width=${width}`);
|
||||
await expect(page.getByRole('status')).toHaveText('这次回复未能完整显示');
|
||||
await expect(page.getByTestId('teacher-reply')).toHaveCount(0);
|
||||
expect(await sent()).toEqual([]);
|
||||
const input = page.getByRole('textbox', { name: '向智能体提问' });
|
||||
await input.fill('我还在写的新想法');
|
||||
const retry = page.getByRole('button', { name: '重新回答' });
|
||||
const bounds = (await retry.boundingBox())!;
|
||||
expect(bounds.height).toBeGreaterThanOrEqual(44);
|
||||
expect(await panel.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
|
||||
await panel.screenshot({ path: test.info().outputPath(`incomplete-${width}.png`) });
|
||||
await retry.click();
|
||||
await expect(page.getByText('好,我们就从这一个问题聊起。')).toBeVisible();
|
||||
await expect(input).toHaveValue('我还在写的新想法');
|
||||
expect(await sent()).toEqual([{
|
||||
requestId: expect.any(String), text: '现在项目是什么阶段?',
|
||||
sourceConversationId: 'original-source', references: [{ messageId: 'original-message', text: '原来的项目记录', role: 'assistant' }],
|
||||
}]);
|
||||
});
|
||||
|
||||
test(`empty-composer hint stays readable and keeps the send position stable at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1180, height: 800 });
|
||||
await page.goto(`${url}?kind=ordinary&width=${width}`);
|
||||
|
||||
@@ -158,6 +158,122 @@ describe('teacher side chat', () => {
|
||||
expect(details?.querySelector('script')).toBeNull();
|
||||
});
|
||||
|
||||
it('marks an incomplete completed reply instead of displaying a half sentence as a finished answer', async () => {
|
||||
const raw = '{"reply":"它早就不是"刚搭好架子"那种阶段了——还有后面的回答';
|
||||
const bringBack = vi.fn();
|
||||
api.read.mockResolvedValue({ ...first, requests: [request({
|
||||
text: '现在项目是什么阶段?', response: '它早就不是', replyIncomplete: true,
|
||||
replyParseError: 'format error', unparsedResponse: raw, suggestedQuestions: ['尚未确认的建议'],
|
||||
})] });
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" onBringBack={bringBack} />);
|
||||
await ready();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('这次回复未能完整显示');
|
||||
expect(screen.queryByTestId('teacher-reply')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '尚未确认的建议' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '带回主会话草稿' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '重新回答' })).toBeEnabled();
|
||||
const details = screen.getByText('查看收到的原始内容').closest('details');
|
||||
expect(details).not.toHaveAttribute('open');
|
||||
expect(details?.querySelector('pre')?.textContent).toBe(raw);
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
expect(bringBack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps complete Markdown visible when only auxiliary fields could not be recovered', async () => {
|
||||
api.read.mockResolvedValue({ ...first, requests: [request({
|
||||
response: '**它早就不是"刚搭好架子"那种阶段了。** 后面的回答也已经收到。',
|
||||
replyParseError: 'invalid quickReplies', unparsedResponse: '{"reply":"完整回答","quickReplies":',
|
||||
})] });
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" />);
|
||||
await ready();
|
||||
expect(screen.getByTestId('teacher-reply')).toHaveTextContent('它早就不是"刚搭好架子"那种阶段了。 后面的回答也已经收到。');
|
||||
expect(screen.getByText('回答正文已保留,部分附加内容未能显示。')).toBeVisible();
|
||||
expect(screen.queryByText('这次回复未能完整显示')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '重新回答' })).not.toBeInTheDocument();
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['question', 'suggestions', 'guided-help'] as const)('retries an incomplete %s only on click with its original input while preserving the current draft', async intent => {
|
||||
const originalReferences = [{ messageId: 'original-message', text: '原来的引用', role: 'assistant' as const }];
|
||||
const currentQuote = { messageId: 'current-message', text: '草稿里的新引用', role: 'assistant' as const };
|
||||
const incomplete = request({
|
||||
intent, text: '现在项目是什么阶段?', sourceConversationId: 'original-source', references: originalReferences,
|
||||
response: '它早就不是', replyIncomplete: true,
|
||||
});
|
||||
const before = { ...first, requests: [incomplete] };
|
||||
api.read.mockResolvedValue(before);
|
||||
let finish!: (topic: TeacherTopic) => void;
|
||||
api.send.mockImplementation(() => new Promise<TeacherTopic>(resolve => { finish = resolve; }));
|
||||
render(<TeacherChatPanel projectId="p" sourceId="different-source" quote={currentQuote} />);
|
||||
await ready();
|
||||
const input = screen.getByLabelText('向智能体提问');
|
||||
fireEvent.change(input, { target: { value: '还没说完的新想法' } });
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
const retry = screen.getByRole('button', { name: '重新回答' });
|
||||
fireEvent.click(retry);
|
||||
fireEvent.click(retry);
|
||||
expect(api.send).toHaveBeenCalledTimes(1);
|
||||
const sent = api.send.mock.calls[0][2] as TeacherSend;
|
||||
expect(sent).toEqual({ requestId: expect.any(String), text: incomplete.text, intent, references: originalReferences, sourceConversationId: 'original-source' });
|
||||
expect(sent.requestId).not.toBe(incomplete.id);
|
||||
expect(retry).toBeDisabled();
|
||||
fireEvent.change(input, { target: { value: '等待时又补充了想法' } });
|
||||
await act(async () => finish({ ...before, revision: 2, requests: [...before.requests, request({ id: sent.requestId, text: sent.text, response: '这次的完整回答。' })] }));
|
||||
expect(screen.getByText('这次的完整回答。')).toBeVisible();
|
||||
expect(input).toHaveValue('等待时又补充了想法');
|
||||
expect(screen.getByText('草稿里的新引用')).toBeVisible();
|
||||
expect(JSON.parse(localStorage.getItem('makelore-consultation-draft:signed-out:p:teacher')!)).toMatchObject({ text: '等待时又补充了想法', references: [currentQuote] });
|
||||
});
|
||||
|
||||
it('keeps the retry id and draft after an uncertain send failure and uses the original topic source for older replies', async () => {
|
||||
const incomplete = request({ text: '原来的问题', response: '', replyIncomplete: true });
|
||||
const before = { ...first, sourceConversationId: 'older-source', requests: [incomplete] };
|
||||
api.read.mockResolvedValue(before);
|
||||
api.send.mockRejectedValueOnce(new Error('暂时没连上')).mockImplementation(async (_base, _id, sent: TeacherSend) => ({
|
||||
...before, revision: 2, requests: [incomplete, request({ id: sent.requestId, text: sent.text, response: '完整回答' })],
|
||||
}));
|
||||
render(<TeacherChatPanel projectId="p" sourceId="new-source" />);
|
||||
await ready();
|
||||
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '草稿不能丢' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新回答' }));
|
||||
await screen.findByRole('alert');
|
||||
expect(screen.getByLabelText('向智能体提问')).toHaveValue('草稿不能丢');
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新回答' }));
|
||||
await screen.findByText('完整回答');
|
||||
expect(api.send).toHaveBeenCalledTimes(2);
|
||||
expect(api.send.mock.calls[0][2]).toEqual(api.send.mock.calls[1][2]);
|
||||
expect(api.send.mock.calls[1][2]).toMatchObject({ text: '原来的问题', sourceConversationId: 'older-source', references: [] });
|
||||
expect(screen.getByLabelText('向智能体提问')).toHaveValue('草稿不能丢');
|
||||
});
|
||||
|
||||
it('retries a project-only historical question without attaching another conversation or the project sentinel', async () => {
|
||||
const incomplete = request({ text: '原来的问题', replyIncomplete: true });
|
||||
const before = { ...first, sourceConversationId: 'project', requests: [incomplete] };
|
||||
api.read.mockResolvedValue(before);
|
||||
api.send.mockResolvedValue(before);
|
||||
render(<TeacherChatPanel projectId="p" sourceId="different-source" />);
|
||||
await ready();
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新回答' }));
|
||||
await waitFor(() => expect(api.send).toHaveBeenCalledTimes(1));
|
||||
expect(api.send.mock.calls[0][2]).not.toHaveProperty('sourceConversationId');
|
||||
});
|
||||
|
||||
it('does not retry an incomplete reply from a disabled Agent or turn a check-in into a normal question', async () => {
|
||||
const archived = { ...first, definition: { ...definition, config_id: 'unavailable-agent' }, requests: [
|
||||
request({ response: '', replyIncomplete: true }),
|
||||
request({ id: 'check-in', intent: 'check-in', text: '', response: '', replyIncomplete: true }),
|
||||
] };
|
||||
api.read.mockResolvedValue(archived);
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" />);
|
||||
await ready();
|
||||
expect(screen.getAllByText('这次回复未能完整显示')).toHaveLength(2);
|
||||
expect(screen.getAllByRole('button', { name: '重新回答' })).toHaveLength(1);
|
||||
expect(screen.getByRole('button', { name: '重新回答' })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新回答' }));
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders formulas and preserves code escapes while rejecting executable links and HTML', async () => {
|
||||
api.read.mockResolvedValue({ ...first, requests: [request({
|
||||
response: '$E=mc^2$\n\n```js\nconst value = "\\n";\n```\n\n[危险](javascript:alert)\n\n<script>alert(1)</script>\n\n\n\n',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { TeacherTopicStore } from '../../electron/coding-teacher/store';
|
||||
import { applyTeacherReply } from '../../electron/coding-teacher/reply';
|
||||
import { compileTeacherContext, teacherHistoryMessages } from '../../electron/coding-teacher/context';
|
||||
import { createTeacherReadTools } from '../../electron/coding-teacher/read-tools';
|
||||
import type { TeacherDefinition, TeacherRequest, TeacherSourceContext, TeacherTopic } from '../../shared/coding-teacher';
|
||||
@@ -206,3 +207,162 @@ describe('passive teacher reply history compatibility', () => {
|
||||
await f.unchanged();
|
||||
});
|
||||
});
|
||||
|
||||
describe('local repair of retained teacher replies', () => {
|
||||
const completeReply = '它早就不是"刚搭好架子"那种阶段了——骨架、玩法、成长线都在跑。\n现在更需要打磨细节。';
|
||||
const quoteRaw = '{"reply":"' + completeReply
|
||||
+ '","quickReplies":["看看接下来做什么"],"tool":{"kind":"structure","nodes":[{"id":"core","label":"完整结构"}]}}';
|
||||
|
||||
it('repairs the screenshot-style quoted prose in a cached read without rewriting or replaying history', async () => {
|
||||
const original = topic(archivedCases[4]);
|
||||
original.requests[0] = request({ ...original.requests[0], response: '它早就不是',
|
||||
unparsedResponse: quoteRaw, replyParseError: '旧解析错误', replyIncomplete: true,
|
||||
usage: { inputTokens: 20, outputTokens: 40, cacheReadTokens: 5, cacheWriteTokens: 3 } });
|
||||
const f = await fixture(original);
|
||||
const loaded = await f.store.read(original.id);
|
||||
const repaired = loaded.requests[0];
|
||||
expect(repaired).toEqual({ ...original.requests[0], response: completeReply,
|
||||
suggestedQuestions: ['看看接下来做什么'], discussionError: undefined,
|
||||
replyParseError: undefined, replyIncomplete: undefined });
|
||||
expect(loaded).toEqual({ ...original, requests: [repaired] });
|
||||
expect(repaired.unparsedResponse).toBe(quoteRaw);
|
||||
expect(repaired.status).toBe('completed');
|
||||
expect(repaired.discussionContext).toEqual(original.requests[0].discussionContext);
|
||||
expect(repaired.discussionSnapshot).toEqual(original.requests[0].discussionSnapshot);
|
||||
expect(await f.store.read(original.id)).toBe(loaded);
|
||||
expect(await new TeacherTopicStore(f.root).read(original.id)).toEqual(loaded);
|
||||
await f.unchanged();
|
||||
|
||||
// A later ordinary save can retain the repaired projection, with the exact
|
||||
// original response and all opaque data still available for diagnosis.
|
||||
await f.store.save(loaded);
|
||||
const persisted = JSON.parse(await readFile(f.file, 'utf8'));
|
||||
expect(persisted.requests[0].unparsedResponse).toBe(quoteRaw);
|
||||
expect(persisted.requests[0].response).toBe(completeReply);
|
||||
expect(await new TeacherTopicStore(f.root).read(original.id)).toEqual(persisted);
|
||||
});
|
||||
|
||||
it('keeps complete prose when auxiliary suggestions or legacy payloads remain invalid', async () => {
|
||||
const original = topic();
|
||||
const raw = '{"reply":"这是一段完整回答。","quickReplies":[42],"tool":';
|
||||
original.requests[0] = request({ response: '这是一段完整回答。', unparsedResponse: raw,
|
||||
replyParseError: '旧解析错误', replyIncomplete: true });
|
||||
const f = await fixture(original);
|
||||
const loaded = await f.store.read(original.id);
|
||||
expect(loaded.requests[0]).toMatchObject({ status: 'completed', response: '这是一段完整回答。',
|
||||
suggestedQuestions: [], unparsedResponse: raw, replyParseError: expect.any(String) });
|
||||
expect(loaded.requests[0].replyIncomplete).toBeUndefined();
|
||||
expect(await new TeacherTopicStore(f.root).read(original.id)).toEqual(loaded);
|
||||
await f.unchanged();
|
||||
});
|
||||
|
||||
it('repairs display after saving restart recovery without rewriting a completed reply on disk', async () => {
|
||||
const original = topic();
|
||||
original.requests[0] = request({ response: '它早就不是', unparsedResponse: quoteRaw });
|
||||
original.requests.push(request({ id: 'running-turn', status: 'running', response: '' }));
|
||||
const f = await fixture(original);
|
||||
const loaded = await f.store.read(original.id);
|
||||
expect(loaded.requests[0].response).toBe(completeReply);
|
||||
expect(loaded.requests[1].status).toBe('interrupted');
|
||||
const persisted = JSON.parse(await readFile(f.file, 'utf8'));
|
||||
expect(persisted.requests[0]).toEqual(original.requests[0]);
|
||||
expect(persisted.requests[1].status).toBe('interrupted');
|
||||
expect(persisted.revision).toBe(original.revision + 1);
|
||||
expect((await new TeacherTopicStore(f.root).read(original.id)).requests[0].response).toBe(completeReply);
|
||||
});
|
||||
|
||||
it('keeps the question but excludes incomplete prose and suggestions from model context and read tools', async () => {
|
||||
const original = topic();
|
||||
original.requests[0] = request({ response: '它早就不是',
|
||||
unparsedResponse: '{"reply":"它早就不是"刚搭好架子', suggestedQuestions: ['尚未确认的建议'] });
|
||||
const f = await fixture(original);
|
||||
const loaded = await f.store.read(original.id);
|
||||
expect(loaded.requests[0].replyIncomplete).toBe(true);
|
||||
expect(teacherHistoryMessages(loaded.requests)).toEqual([
|
||||
{ id: 'teacher:completed-turn:user', role: 'user', text: original.requests[0].text },
|
||||
]);
|
||||
const context = JSON.stringify(compileTeacherContext(definition, source, loaded.requests, '重新看看', []).messages);
|
||||
expect(context).toContain(original.requests[0].text);
|
||||
expect(context).not.toMatch(/它早就不是|尚未确认的建议/);
|
||||
const tools = createTeacherReadTools({ projectPath: f.root, source, history: loaded.requests, assertCurrent: vi.fn() });
|
||||
const signal = new AbortController().signal;
|
||||
const index = await tools.execute('read_conversation', '{}', signal);
|
||||
expect(index).toContain('teacher:completed-turn:user');
|
||||
expect(index).not.toContain('teacher:completed-turn:assistant');
|
||||
const assistant = await tools.executeResult('read_conversation', JSON.stringify({ message_id: 'teacher:completed-turn:assistant' }), signal);
|
||||
expect(assistant.status).toBe('error');
|
||||
expect(assistant.content).not.toContain('它早就不是');
|
||||
await f.unchanged();
|
||||
});
|
||||
|
||||
it('marks truly truncated prose incomplete without replacing the saved text or raw evidence', async () => {
|
||||
const original = topic();
|
||||
original.requests[0] = request({ response: '它早就不是',
|
||||
unparsedResponse: '{"reply":"它早就不是"刚搭好架子', replyParseError: '旧解析错误' });
|
||||
const f = await fixture(original);
|
||||
const loaded = await f.store.read(original.id);
|
||||
expect(loaded.requests[0]).toEqual({ ...original.requests[0], replyIncomplete: true });
|
||||
expect(await new TeacherTopicStore(f.root).read(original.id)).toEqual(loaded);
|
||||
await f.unchanged();
|
||||
});
|
||||
|
||||
it.each(['failed', 'cancelled', 'interrupted'] as const)('does not salvage a %s request as success', async status => {
|
||||
const original = topic();
|
||||
original.requests[0] = request({ status, response: '它早就不是', unparsedResponse: quoteRaw,
|
||||
error: '该请求未完成', replyParseError: '旧解析错误' });
|
||||
const f = await fixture(original);
|
||||
expect(await f.store.read(original.id)).toEqual(original);
|
||||
await f.unchanged();
|
||||
});
|
||||
|
||||
it.each(['preparing', 'running'] as const)('keeps restart recovery for %s without accepting its received bytes', async status => {
|
||||
const original = topic();
|
||||
original.requests[0] = request({ status, response: '它早就不是', unparsedResponse: quoteRaw,
|
||||
replyParseError: '旧解析错误' });
|
||||
const f = await fixture(original);
|
||||
const loaded = await f.store.read(original.id);
|
||||
expect(loaded.requests[0]).toEqual({ ...original.requests[0], status: 'interrupted',
|
||||
error: '应用已重启,本次回复中断。' });
|
||||
expect(loaded.revision).toBe(original.revision + 1);
|
||||
expect(await new TeacherTopicStore(f.root).read(original.id)).toMatchObject({ requests: loaded.requests });
|
||||
});
|
||||
|
||||
it('does not replace useful prose with different content from a stale raw archive', async () => {
|
||||
const original = topic();
|
||||
original.requests[0] = request({ response: '已经保存的完整回答。', unparsedResponse: quoteRaw });
|
||||
const f = await fixture(original);
|
||||
expect(await f.store.read(original.id)).toEqual(original);
|
||||
await f.unchanged();
|
||||
});
|
||||
|
||||
it('also repairs the legacy intro/questions envelope while leaving its tool snapshot opaque', async () => {
|
||||
const original = topic();
|
||||
const raw = '{"intro":"它早就不是"刚搭好架子"了。","questions":["继续"],"tool":{"kind":"ideas"}}';
|
||||
original.requests[0] = request({ response: '它早就不是', unparsedResponse: raw });
|
||||
const f = await fixture(original);
|
||||
const loaded = await f.store.read(original.id);
|
||||
expect(loaded.requests[0]).toMatchObject({ response: '它早就不是"刚搭好架子"了。',
|
||||
suggestedQuestions: ['继续'], unparsedResponse: raw });
|
||||
expect(loaded.requests[0].discussionError).toBeUndefined();
|
||||
expect(loaded.requests[0].discussionSnapshot).toEqual(original.requests[0].discussionSnapshot);
|
||||
await f.unchanged();
|
||||
});
|
||||
|
||||
it('propagates incomplete results and retains diagnostics for recovered new replies', () => {
|
||||
const current = request({ replyIncomplete: true, replyParseError: '旧解析错误' });
|
||||
applyTeacherReply(current, '{"reply":"还没有说完');
|
||||
expect(current.replyIncomplete).toBe(true);
|
||||
expect(current.replyParseError).toBeTruthy();
|
||||
expect(current.unparsedResponse).toBe('{"reply":"还没有说完');
|
||||
|
||||
applyTeacherReply(current, quoteRaw);
|
||||
expect(current.response).toBe(completeReply);
|
||||
expect(current.replyIncomplete).toBeUndefined();
|
||||
expect(current.replyParseError).toBeUndefined();
|
||||
expect(current.discussionError).toBeUndefined();
|
||||
expect(current.unparsedResponse).toBe(quoteRaw);
|
||||
const recovered = structuredClone(current);
|
||||
applyTeacherReply(current, quoteRaw);
|
||||
expect(current).toEqual(recovered);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,11 +122,61 @@ describe('teacher text and suggestions transport', () => {
|
||||
|
||||
it('marks multiple envelopes or duplicate fields as ambiguous without showing legacy JSON', () => {
|
||||
const parsed = parseTeacherReply(JSON.stringify(envelope) + '\n{"reply":"第二份","tool":{"kind":"ideas"}}');
|
||||
expect(parsed).toMatchObject({ ...envelope, parseError: expect.any(String) });
|
||||
expect(parsed).toMatchObject({ incomplete: true, quickReplies: [], parseError: expect.any(String) });
|
||||
expect(parsed.reply).not.toContain('tool');
|
||||
expect(parseTeacherReply('{"reply":"第一份","reply":"第二份"}').parseError).toBeTruthy();
|
||||
});
|
||||
|
||||
it('recovers the complete screenshot reply around internal unescaped quotes', () => {
|
||||
const reply = '它早就不是"刚搭好架子"那种阶段了——骨架、玩法、成长线都在跑。\n现在的阶段是「能玩,正在调手感」。';
|
||||
const raw = '{"reply":"' + reply + '","quickReplies":[],"tool":{"kind":"structure","nodes":[{"id":"core","label":"旧结构"}]}}';
|
||||
expect(parseTeacherReply(raw)).toEqual({ reply, quickReplies: [], recovered: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
'他说"再看一下",然后按 "继续"。',
|
||||
'第一行\n第二行\r\n制表符\t与 "引号" 一起保留。',
|
||||
'示例代码:\n```js\nconst text = "hello";\nif (ready) { show(text); }\n```\n然后继续说明。',
|
||||
])('repairs only prose escaping without rewriting its content: %s', reply => {
|
||||
expect(parseTeacherReply('{"reply":"' + reply + '","quickReplies":["继续"]}'))
|
||||
.toEqual({ reply, quickReplies: ['继续'], recovered: true });
|
||||
});
|
||||
|
||||
it('leaves correctly encoded quotes, backslashes, code and Unicode untouched', () => {
|
||||
const reply = '它早就不是"刚搭好架子"了。\\路径\\\n```json\n{"reply":"代码例子"}\n```\n🍎 继续。';
|
||||
expect(parseTeacherReply(JSON.stringify({ reply, quickReplies: [] })))
|
||||
.toEqual({ reply, quickReplies: [] });
|
||||
});
|
||||
|
||||
it('does not label a quoted prefix or a genuinely interrupted string as complete', () => {
|
||||
for (const raw of [
|
||||
'{"reply":"它早就不是"刚搭好架子',
|
||||
'{"reply":"它早就不是"刚搭好架子"那种阶段了',
|
||||
'{"reply":"它早就不是"',
|
||||
'{"reply":"半句", ordinary words are still part of broken prose',
|
||||
'{"reply":"引号 \\q 无效","quickReplies":[]}',
|
||||
]) {
|
||||
expect(parseTeacherReply(raw)).toMatchObject({ incomplete: true, quickReplies: [], parseError: expect.any(String) });
|
||||
expect(parseTeacherReply(raw).reply).not.toMatch(/它早就不是|半句|ordinary words|无效/);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps complete prose when suggestions fail without swallowing their bytes', () => {
|
||||
for (const tail of ['["断在按钮里', 'null}', '[13]}', '["坏" }']) {
|
||||
const parsed = parseTeacherReply('{"reply":"完整回答,包含 \\"正确引号\\"。","quickReplies":' + tail);
|
||||
expect(parsed).toMatchObject({ reply: '完整回答,包含 "正确引号"。', quickReplies: [], parseError: expect.any(String) });
|
||||
expect(parsed.incomplete).toBeUndefined();
|
||||
expect(parsed.reply).not.toContain('quickReplies');
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses to guess across ambiguous prose/auxiliary boundaries or duplicate bodies', () => {
|
||||
const raw = '{"reply":"代码里有 "text", "tool": {"reply":"不能冒充正文"} 后面还有正文。","quickReplies":[]}';
|
||||
expect(parseTeacherReply(raw)).toMatchObject({ incomplete: true });
|
||||
expect(parseTeacherReply('{"reply":"第一句","reply":"第二句"}')).toMatchObject({ incomplete: true });
|
||||
expect(parseTeacherReply(raw).reply).not.toContain('不能冒充正文');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'{"reply":"正文保留。"},"tool":{"kind":"ideas","items":[]}',
|
||||
'{"reply":"正文保留。"}}',
|
||||
|
||||
Reference in New Issue
Block a user