diff --git a/.project-docs/30-worklog/tasks/20260928-agent-reply-truncation-c812b59f.md b/.project-docs/30-worklog/tasks/20260928-agent-reply-truncation-c812b59f.md new file mode 100644 index 00000000..382396c0 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260928-agent-reply-truncation-c812b59f.md @@ -0,0 +1,87 @@ +# Task: Diagnose truncated consultation reply and raw-content fallback + +## Identity + +- Task ID: 20260928-agent-reply-truncation-c812b59f +- Mode: Feature +- Branch: codex/20260928-agent-reply-truncation-c812b59f-agent-reply-truncation +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20260928-agent-reply-truncation-c812b59f +- Base commit: 4495345fb0a9ea1abef8e603629ce9b51d73eb05 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- 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 + +- 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. +- Cloud structured replies use completed run.output; missing intermediate stream deltas do not explain a complete raw body being cut by this recovery path. Do not claim all provider truncation/format causes are excluded. + +## 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. +- Project-document structure, task ownership drift and whitespace checks passed. + +## Follow-ups + +- 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 + +- 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 + +- Same-task start/status matched; reused unchanged project context and reviewed the new welcome-diagnosis/latest cleanup peer scope. Read-only gate passed; no product or live state edits. +- 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 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. diff --git a/.project-docs/30-worklog/tasks/20260928-latest-main-merge-b6a42f90.md b/.project-docs/30-worklog/tasks/20260928-latest-main-merge-b6a42f90.md new file mode 100644 index 00000000..b83f5ee7 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260928-latest-main-merge-b6a42f90.md @@ -0,0 +1,60 @@ +# Task: Merge current completed Makelore changes into main and push + +## Identity + +- Task ID: 20260928-latest-main-merge-b6a42f90 +- Mode: Feature +- Branch: codex/20260928-latest-main-merge-b6a42f90-latest-main-merge +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20260928-latest-main-merge-b6a42f90 +- Base commit: a21a1f077cc11976213290a6b843bb74be97ab44 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Integrate current completed product work into origin/main, adapt overlaps, verify, fast-forward local main and push without force. + +## Intent And Constraints + +- User explicitly requested all latest project code merged and pushed. No subagents, paid model calls, app restart or cleanup. Preserve foreign working trees and original source records. Canonical memory reconciliation is not included; its integration lock is held by another task. + +## Outcome + +- Merged completed product sources with merge ancestry preserved: reply cleanup/recovery `d530abe` (including `40c247a`) via `d032425`; cloud-owned teaching policy and 0–3 new shortcuts `54a40d2` via `65a0c21`; welcome dismissal `c96bb45` via `44b16f8`. Base `a21a1f0` already contains continuous account/Agent conversations, manual refresh and hover UI. +- Adapted recovery to per-turn continuous storage, including older pages and read-only history tools; uncertain legacy index excerpts cannot leak partial answers. Display repair leaves completed turn files unchanged, including when another turn needs restart recovery. +- Incomplete replies retry only on deliberate click, retaining the original problem/references and both drafts. Continuous retries require the original project/source; each newly accepted turn still resolves the current published version. Legacy pinned-version behavior remains compatible. +- Persisted rejection of excessive new shortcuts so a later history repair cannot restore them; historical cards remain preserved. Removed retired discussion rendering/state updates while retaining opaque source archives. Welcome dismissal follows current account-wide scope. +- Refreshed README and combined unit/browser/native-E2E fixtures. Product merge `cafebca05710b7bf1a6fa62ec51be4992a73a18c` was fast-forwarded into local main and pushed normally; `git ls-remote origin refs/heads/main`, origin/main and local main matched with a clean checkout. No app restart, model call, dependency change or worktree cleanup. + +## Verification + +- Passed: 405 focused unit tests in 14 files, covering parser, history, service, continuous conversations, UI, cloud/model/read contracts, refresh, welcome and preview. Command: `corepack pnpm exec vitest run tests/unit/teacher-reply.test.ts tests/unit/teacher-reply-history.test.ts tests/unit/coding-teacher-ui.test.tsx tests/unit/teacher-conversation.test.ts tests/unit/teacher-conversation-panel.test.tsx tests/unit/coding-teacher.test.ts tests/unit/teacher-companion.test.tsx tests/unit/coding-teacher-cloud.test.ts tests/unit/coding-teacher-model.test.ts tests/unit/coding-teacher-read-tools.test.ts tests/unit/teacher-guidance.test.ts tests/unit/teacher-retired-discussion-route.test.ts tests/unit/teacher-cloud-activity.test.ts tests/unit/agent-browser-panel.test.tsx --maxWorkers=2`. +- Passed: 28 headless browser cases via installed Chrome, including legacy/continuous quote recovery and retry at 319px/508px, draft retention, rich reply containment, archived component suppression and preview layout. Command: `MAKELORE_LAYOUT_BROWSER_CHANNEL=chrome corepack pnpm exec playwright test tests/e2e/teacher-reply-layout.spec.ts tests/e2e/teacher-preview-layout.spec.ts --max-failures=1`. +- Passed: `corepack pnpm run typecheck`, scoped ESLint for changed implementation/tests, `corepack pnpm run build:vite`, and `git diff --check`. Native Electron E2E additions parse/list successfully but were not executed because the fixture opens a foreground app window; the current client was left running unchanged. +- Passed: required project-document structure; every selected source is an ancestor of the merge candidate. Re-fetched remote main remains `a21a1f0`. +- Drift checker reports the four committed source-task documents as foreign relative to the task's original base. These are imported by the explicitly requested source merges, not edited by this task. Byte comparison against `d530abe`, `54a40d2`, `40c247a` and `c96bb45` confirms all four unchanged; only this task's own record is authored here. No canonical document changes, altered baseline or ownership override used to suppress this inherited checker finding. + +## Follow-ups + +- Product publication complete; source and integration worktrees retained. +- Full native Electron interaction execution and release packaging remain outside this merge validation; no claim of a rebuilt running client. + +## Promotion Candidates + +- A later canonical-memory integration can reconcile retired discussion UI descriptions with the current README and continuous-chat/reply-recovery contracts; this task does not hold that integration lock or edit those canonical sources. + +## Project Context Loaded + +- Task ID/mode/branch/worktree/base: verified against official start/status; feature `20260928-latest-main-merge-b6a42f90`, branch `codex/20260928-latest-main-merge-b6a42f90-latest-main-merge`, this absolute worktree, fetched base `a21a1f077cc11976213290a6b843bb74be97ab44`. +- Managed app worktree creation was unavailable because the chat root is the repository parent; official skill start created and registered isolation from the explicit remote base. +- Read: entry order, memory-index in 05-agent-entry, own record, positioning, latest current-state/teacher ADR/decision index, architecture, domain/success criteria, relevant evidence/reflection/commitment/stale indexes, and all registered peer Scope/Intent/Promotion sections. +- Project goal: cloud-configured domain Agents support student projects; Main owns calls, source scope and persistence. Current remote main already implements account/Agent continuous chats, per-turn project/source/version, shared manual refresh and hover UI. No fixed local persona, deleted component UI or old topic controls should reappear. +- Local current product sources: reply cleanup and truncation fix `d530abe` (includes `40c247a`); technical prompt boundary `54a40d2` supersedes older Alice audit `89c913b`; welcome-entry fix `c96bb45`. Other feature branches are ancestors of main, diagnostic-only, or superseded experiments. Old dirty teacher-components and trial launcher are known historical work and remain excluded/read-only; child-language task has only an unfinished task record and no product code. +- Overlap: remote continuous-chat storage/send/history needs the same repair, retry and retired-component behavior as legacy topics. Resolve mechanically while preserving the accepted single-chat/per-turn rules. The old retry's pinned-topic assumption must not reverse the newer per-turn current-version contract. +- Canonical docs contain historical component descriptions; README will describe the combined implementation. Existing canonical integration lock and source task records remain untouched. No new semantic decision needed; gate Passed. + +## Plan + +1. Merge the committed reply, prompt-boundary and welcome changes into fetched remote main; resolve overlapping code and adapt continuous-chat paths. +2. Add focused regressions for the combined storage, context and UI paths; run relevant tests, typecheck, lint and build plus headless UI where available. +3. Verify committed source coverage, record outcomes, fast-forward clean local main, push normally and compare remote identity. Keep all worktrees. diff --git a/.project-docs/30-worklog/tasks/20260928-merge-reply-cleanup-eb8d39b3.md b/.project-docs/30-worklog/tasks/20260928-merge-reply-cleanup-eb8d39b3.md index 473b941c..5b4c53b3 100644 --- a/.project-docs/30-worklog/tasks/20260928-merge-reply-cleanup-eb8d39b3.md +++ b/.project-docs/30-worklog/tasks/20260928-merge-reply-cleanup-eb8d39b3.md @@ -20,21 +20,27 @@ ## Outcome -- Source cleanup merged locally with continuous-chat conflict resolutions; latest remote advanced during verification and is being incorporated before publication. +- Source cleanup `40c247a` merged with the current account/Agent continuous-chat behavior in `581ebc8`, then incorporated complete remote main `077184a` before publication. Preserve its reply recovery, cloud-owned teaching policy, 0–3 new shortcuts, welcome dismissal, pagination and per-turn source/version behavior. +- Additional merge safeguards: old component mutation route removed from the continuous API; no new per-project component state is created or imported; existing malformed legacy component archives are retained verbatim. Old component instructions cannot persist through a reused pre-upgrade cloud checkpoint: first reply-protocol turn rotates the internal thread and carries bounded public reply history, without changing the visible chat. +- Unconfirmed legacy hint requests retain their old text, ID and original project/source. Migrated per-project drafts keep that original scope; cross-project or cross-source hint retry waits for the original scope instead of issuing another charge. Existing send-in-flight and incomplete-reply retry safeguards remain intact. +- No package, app restart/update, cloud configuration publication, paid model call or desktop focus change. Source/live-trial and all foreign worktrees remain intact. Publication verification is recorded below after the normal push. ## Verification -- Initial merge: typecheck and production Vite build passed; lint passed with 8 existing warnings. Focused backend/context/parser/browser tests 186 passed; backend overlap suites 169 passed; UI suites 127 passed and after extra regressions UI suite 61 passed; headless layout 15 passed. These overlapping runs are not an additive unique-test count. -- Project structure checker passed. Drift checker reports the source cleanup task record as foreign because the merge imports it unchanged; no source record or canonical file was authored here. -- Remote main moved from a21a1f0 to 077184a and already includes source 40c247a plus independently integrated reply recovery, protocol boundary and welcome fixes. Preserve that full main and add only necessary overlap safeguards before final revalidation. +- Final `corepack pnpm run typecheck`, `corepack pnpm run lint:check` and `corepack pnpm run build:vite` passed. Lint has 8 existing unrelated warnings; build has existing chunk-size/dynamic-import warnings. No dependencies or build outputs committed. +- Final backend focused validation: 7 suites / 261 tests passed; UI/companion/chat: 4 suites / 144 tests passed; continuous storage/retired routes: 2 suites / 17 tests passed. These runs overlap and are not an additive unique-test count. +- Headless Chrome: all 28 teacher reply/preview layout cases verified. First run passed 27; one initial Vite scan observed an in-progress shared-type merge marker, then passed when rerun after resolution. Native Pi Electron E2E fixture parses and lists 19 tests; not launched because it opens a foreground app. +- Full `corepack pnpm test`: 261 files / 2639 tests passed, 3 tests skipped; 3 failures in 2 unrelated Pi files. Independently reproduced exactly those same 3 failures on a disposable archive of original remote `077184a` (12 passed / 3 failed in the selected two files): two event-projector old error-message assertions and the managed-worker opener runtime-v6/v7 assertion. Baseline log: `/tmp/makelore-baseline-077184a-21n6od02/baseline-tests.log`. The script's separately gated pressure suite was then run manually and passed 1/1. +- `git diff --check`, required project-document structure and semantic comparison against latest remote passed. Task drift checker reports 5 imported source-task documents as foreign relative to the original base; byte comparison against `077184a` confirms every imported document unchanged. Only this task's own record is authored here; no canonical writes, ownership override, baseline alteration or source-document edits were used to suppress the inherited diagnostic. ## Follow-ups -- None recorded. +- Push the verified merge normally to remote main and record exact publication identity. Packaging and installation belong to the user's colleague. +- The 3 pre-existing Pi test assertions remain outside this teacher merge scope. Native Electron foreground interactions and signed package verification are not claimed. ## Promotion Candidates -- None recorded. +- A later canonical-memory integration should reflect current text/quick-reply-only teacher behavior and first-upgrade cloud-thread rotation. Evidence is the merged implementation, focused regressions and baseline comparison; existing cloud-defined policy and continuous-chat decisions remain unchanged. This feature task does not own the canonical integration lock. ## Project Context Loaded diff --git a/.project-docs/30-worklog/tasks/20260928-teacher-protocol-boundary-f2c8a601.md b/.project-docs/30-worklog/tasks/20260928-teacher-protocol-boundary-f2c8a601.md new file mode 100644 index 00000000..601f1778 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260928-teacher-protocol-boundary-f2c8a601.md @@ -0,0 +1,62 @@ +# Task: Keep teacher runtime instructions scoped to entry facts and UI protocol + +## Identity + +- Task ID: 20260928-teacher-protocol-boundary-f2c8a601 +- Mode: Feature +- Branch: codex/20260928-teacher-protocol-boundary-f2c8a601-teacher-protocol-boundary +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20260928-teacher-protocol-boundary-f2c8a601 +- Base commit: 998796d77cba146ae2a007cbebfdaa1255a3413e +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Continue the accepted teacher/client boundary on top of completed legacy-component cleanup commit `998796d77cba146ae2a007cbebfdaa1255a3413e`. +- Keep entry markers factual and the reply protocol technical. Remove locally appended teaching strategy from active help, guided help, background checks, and reply-format instructions. +- Enforce the current 0–3 shortcut capacity for newly generated answers while preserving archived replies and exact diagnostics. +- Update focused tests and README; preserve card styling and existing interaction/scheduling machinery. + +## Intent And Constraints + +- User assigned main merge and push to another conversation. This task stays in its isolated feature branch and prepares a tested commit for integration. +- Cloud configuration owns Alice's teaching purpose, focus, tone, length, and shortcut prefixes; the host owns invocation facts, available context/tools, and interface protocol. +- Keep old component cleanup intact. Do not merge the older `89c913b` patch wholesale because it modifies a superseded protocol. +- No cloud configuration publishing, live app restart, scheduler redesign, or teacher-to-operator consensus handoff in this task. +- Gate passed before edits: task ownership/status verified; entry, task-relevant memory, and peer scopes reviewed. Relevant canonical documents are unchanged between the previously read baseline and `998796d`; shared memory remains read-only. + +## Outcome + +- Active-help and guided-help messages now identify the student's entry intent; background checks identify a program-triggered check without a fabricated student question. They no longer mandate a teaching strategy. +- Reply instructions now declare only the two fields, Markdown body support, optional 0–3 nonempty shortcut strings, and delegation of content/style to published configuration. +- Main rejects excess newly generated shortcuts without silently truncating or rewriting them: prose is retained, cards are withheld, and the exact raw answer and format diagnostic are saved. 0/1/3-card replies preserve prefixes and duplicates. The shared compatibility parser and historical store remain unchanged. +- The UI now shows recorded reply-format errors beside the preserved answer; raw content stays collapsed and inert. Existing colors, layout, click handling and drafts are unchanged. +- Regression coverage includes local/cloud service persistence, history read/list/save, all entry intents, current/legacy envelopes, and UI behavior. README reflects the responsibility boundary and current capacity. +- No merge, push, running-app restart, cloud config change, or canonical memory edit performed. + +## Verification + +- Dependency installation: pinned pnpm 10.33.4, frozen lockfile, offline cache; no tracked dependency change. +- Focused final suite: 269/269 passed across teacher-guidance, teacher-reply, teacher-reply-history, coding-teacher, coding-teacher-cloud, coding-teacher-model and coding-teacher-ui. +- `corepack pnpm run typecheck`: passed. +- ESLint for all changed TypeScript/TSX files, including the Electron E2E spec: passed. +- `corepack pnpm run build:vite`: passed after the final UI change. +- `git diff --check` and task-aware project-doc drift check: passed. +- Independent read-only review found no remaining teaching-policy directives in the scoped runtime files and confirmed lossless historical handling. +- Added a focused Electron E2E case for visible format errors, absent rejected shortcut buttons, collapsed exact raw text, and preserved drafts. Playwright test collection passed using an existing Electron distribution override; the native test was not executed to avoid launching another app window during concurrent work. Component/service assertions covering these behaviors passed. +- Real published-model responses were not tested. Existing topics retain their pinned configuration versions; code tests do not establish that an updated cloud prompt is active. + +## Follow-ups + +- Integrate this commit after the legacy-component cleanup; merging/pushing belongs to the user's other conversation. +- Validate actual Alice output with a new topic using the published cloud version. Local tests cannot prove model behavior or that existing pinned topics picked up a new prompt. +- Three-operation-round checks and shared confirmed context remain separate work. + +## Promotion Candidates + +- Target: canonical teacher decision/current-state documents during integration. + - Proposal: runtime help/check-in markers convey entry facts only; teaching strategy is entirely in the selected published agent configuration. Current newly generated replies accept 0–3 shortcuts; historical card counts remain lossless. + - Evidence: context/reply code and focused tests in this task; user's accepted client/agent boundary in this conversation. + - Future impact: future personas can use the same interface without inheriting Alice's teaching policy; prompt changes are evaluated against the actual published topic version. + - Semantic conflicts: supersedes cleanup task's remaining locally mandated single discussion focus and unrestricted new-card count; retains historical compatibility and UI cleanup. + - Human confirmation: not additionally required for these already accepted boundaries; no canonical edits in this feature task. diff --git a/.project-docs/30-worklog/tasks/20260928-teacher-welcome-diagnosis-4fb809c2.md b/.project-docs/30-worklog/tasks/20260928-teacher-welcome-diagnosis-4fb809c2.md new file mode 100644 index 00000000..0f92c2d0 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260928-teacher-welcome-diagnosis-4fb809c2.md @@ -0,0 +1,50 @@ +# Task: Explain the first-click teacher welcome bubble + +## Identity + +- Task ID: 20260928-teacher-welcome-diagnosis-4fb809c2 +- Mode: Feature +- Branch: codex/20260928-teacher-welcome-diagnosis-4fb809c2-teacher-welcome-diagnosis +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20260928-teacher-welcome-diagnosis-4fb809c2 +- Base commit: 4495345fb0a9ea1abef8e603629ce9b51d73eb05 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Diagnose and fix the first-click teacher welcome bubble at baseline 4495345. Entering consultation consumes the static welcome and preserves true unread proactive messages. + +## Intent And Constraints + +- User first asked why the screenshot bubble appears, then explicitly requested the proposed fix. +- Concurrent and Planning Gates passed in this owned checkout; task identity, branch, absolute path and base verified on resume. Relevant project memory and peer scopes reviewed. Adjacent cleanup and truncation tasks remain isolated. +- Preserve published greeting content inside the consultation panel, account/project scope, real unread check-ins, cloud configuration, model requests and conversation state. +- No main merge, live trial restart, cloud publication or paid model request in this task. + +## Outcome + +- Cause: welcome_message displayed after config load until explicit dismissal; avatar entry only opened the panel, leaving the welcome bubble visible and changing its CTA to 看看智能体说的. +- The hook now hides static welcome immediately when consultation is open, then records welcome-dismissed for the current account/project. This also handles entering before async config completes, closing before config completes, same-agent selection and reload. +- The effect runs after scope initialization and changes only welcomeDismissed. Real invitations and seen receipts are untouched; a real check-in still requires explicit viewing/dismissal. +- Updated current README and focused unit/Electron coverage. Independent review found no actionable issue. + +## Verification + +- New unit tests first reproduced three failures on the prior source; unread check-in preservation already passed. +- corepack pnpm 10.33.4: teacher-companion.test.tsx, 33 passed. +- pnpm run typecheck, scoped ESLint, pnpm run build:vite and git diff --check passed. +- Electron welcome test passed: avatar entry, close/reopen, renderer reload, panel greeting retained, no model/topic POST. +- Electron proactive test passed: entry/close does not consume unread message; explicit CTA consumes it, original focus/draft/layout/timer assertions retained. Initial modified test had an invalid remount-focus expectation when opening an already-open panel; restored its original close-then-CTA sequence and reran successfully. Final total: 2 relevant Electron scenarios passed. +- Verification uses the existing mocked Host fixture with isolated test profiles; no real provider or trial application updated. + +## Follow-ups + +- Integrate this small source change with the current main/trial version through its owning integration task. Build outputs, test profiles and dependency symlink are not committed. + +## Promotion Candidates + +- Target: canonical classroom interaction/current-state docs during an integration task. +- Proposal: entering consultation consumes and persists the static welcome per account/project; actual unread proactive messages remain until explicitly viewed/dismissed. +- Evidence: explicit user request, hook change, 33 unit tests and two Electron scenarios. +- Future impact: prevents redundant first-entry invitations without hiding real messages. +- Semantic conflicts: supersedes the older welcome-until-manual-dismissal behavior only. No further human confirmation needed for this accepted behavior; canonical promotion remains integration-only. diff --git a/README.md b/README.md index a0a2e1ca..7d1f6c91 100644 --- a/README.md +++ b/README.md @@ -173,11 +173,12 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - 鼠标悬停或键盘聚焦顶部智能体入口时,以奶油色圆角卡片显示该智能体的发布介绍;离开、Escape 或点击入口后收起,没有介绍则不显示空卡片。咨询栏不再显示重复的头像、名称标题条,也不显示介绍及新话题、展开、关闭三个图标;老师身份保留在顶部入口及消息署名中。智能体生成的追加问题以淡蓝、奶油黄、淡紫卡片依次承载,正文 16px、行高 1.8,保留整卡点击发送与忙碌时禁用。回复下方不再显示重复的输入引导或省略早期消息条数;输入框提示为“和老师聊聊”。再次点击当前入口或在咨询栏按 Escape 可收起,切换其他入口仍直接打开对应讨论;拖动边缘调宽保留,已下发智能体仅保留只读旧记录入口。悬浮介绍不会创建话题、调用模型或确认未读消息,作品原生预览同时避让介绍卡和真实主动气泡。 - 主动发言采用紧贴小头像的短气泡;只有存在真实主动消息或运营欢迎语时才显示这组浮层,收起消息时头像和气泡一起消失,顶部入口仍保留。长消息在气泡中最多显示三行,点击接回原智能体对话查看全文。不提供“智能体偶尔来看看”开关或本地模拟巡看控制,既有自动跟进与真实消息接收逻辑保留。作品原生预览对整组气泡和头像测量避让,避免图片被原生页面遮住。 - 新的单会话入口停止旧版前端定时跟进派发;项目主动观察由独立功能衔接,不能重新创建可见话题。未下发目录的旧版入口仍保留原跟进合同:前端每 5 分钟请求一次智能体跟进;窗口隐藏、来源归档、主对话正在执行、智能体正在回复或学生正在智能体栏写草稿时延后。Main 再检查在线启停、来源归属、项目级冷却和已完成文本指纹,未变上下文按下述十五分钟冷却处理。跟进沿用运营模型、已发布 Skills 与当前智能体话题,生成符合所选配置的具体建议或引导;以主动智能体发言持久化,不伪造学生消息。模型调用沿用智能体计费规则。 -- 进入项目时,智能体头像旁先显示运营发布的欢迎语气泡,不调用模型、不声称已检查项目;每个账号/项目主动收起后不重复弹出。真实的未读主动建议优先替换欢迎语。主动发言在头像旁直接显示气泡,不抢焦点、不盖住作品;作品区按气泡与原生预览的实际交叠范围避让,已有工具栏高度计入计算,气泡位于独立智能体栏时不额外留白,窗口/分栏尺寸变化和收起气泡时实时归还空间;“和智能体聊聊”接回同一段咨询,“等会儿聊”收起并保留历史。已有未处理气泡时不堆叠新邀请;咨询已展开时也显示气泡,直到学生主动点击查看或收起,期间的其他回复不会吞掉未读邀请。五分钟检查计时在刷新和项目切换后保留;智能体咨询里的新讨论也算进展,即使操作对话没有变化也会继续跟进;上下文完全未变时放缓到十五分钟一次,不永久停止。事件连接丢失时,每十五秒只读同步未完成回复,收到结果后停止轮询,不重复调用模型。失败如实提示,不以本地文案替代模型回复。已接受请求恢复使用原身份,退出账号和来源删除仍沿用 Main 中止边界。 -- 智能体支持自由提问;输入框为空时,底部显示暖黄色小按钮“继续看看👀”,输入文字后收起,清空后重现,发送按钮保持原位。只有学生点击才调用模型,结合当前操作对话与咨询历史推荐一个具体切入点,并生成围绕它的快捷回复。点击快捷回复会主动开始讨论;回复下方不再附加固定的二次求助链接;失败或中断后,可点暖黄色小按钮“继续看看👀”重试。这些快捷求助保留已有输入草稿,网络结果不确定时复用请求身份,已确认终态的请求不重复执行;解析失败可重新求助,不用固定问题伪装模型结果。服务端下发的推荐问题只填入草稿,由用户确认发送。界面不提供独立示范页面、“记一下”、共识或自动待办,智能体的回复下不再展示“我去试一试”和“复制”按钮,学生通过已有的操作对话/作品入口继续创作。 +- 进入项目时,智能体头像旁先显示运营发布的欢迎语气泡,不调用模型、不声称已检查项目;点击智能体进入咨询时立即收起欢迎语,并按账号记住已查看,关闭、重新打开、切换项目或刷新后不重复弹出;主动收起也保留同样记录。真实的未读主动建议优先替换欢迎语,打开咨询不会自动将这些消息标为已读。主动发言在头像旁直接显示气泡,不抢焦点、不盖住作品;作品区按气泡与原生预览的实际交叠范围避让,已有工具栏高度计入计算,气泡位于独立智能体栏时不额外留白,窗口/分栏尺寸变化和收起气泡时实时归还空间;“和智能体聊聊”接回同一段咨询,“等会儿聊”收起并保留历史。已有未处理气泡时不堆叠新邀请;咨询已展开时仍显示真实未读主动消息气泡,直到学生主动点击查看或收起,期间的其他回复不会吞掉未读邀请。五分钟检查计时在刷新和项目切换后保留;智能体咨询里的新讨论也算进展,即使操作对话没有变化也会继续跟进;上下文完全未变时放缓到十五分钟一次,不永久停止。事件连接丢失时,每十五秒只读同步未完成回复,收到结果后停止轮询,不重复调用模型。失败如实提示,不以本地文案替代模型回复。已接受请求恢复使用原身份,退出账号和来源删除仍沿用 Main 中止边界。 +- 智能体支持自由提问;输入框为空时,底部显示暖黄色小按钮“继续看看👀”,输入文字后收起,清空后重现,发送按钮保持原位。只有学生点击才调用模型,程序说明学生主动求助且尚未提出具体问题,智能体结合可用上下文按云端配置回应。点击快捷回复会主动开始讨论;回复下方不再附加固定的二次求助链接;失败或中断后,可点暖黄色小按钮“继续看看👀”重试。这些快捷求助保留已有输入草稿,网络结果不确定时复用请求身份,已确认终态的请求不重复执行;解析失败可重新求助,不用固定问题伪装模型结果。服务端下发的推荐问题只填入草稿,由用户确认发送。界面不提供独立示范页面、“记一下”、共识或自动待办,智能体的回复下不再展示“我去试一试”和“复制”按钮,学生通过已有的操作对话/作品入口继续创作。 - 老师咨询只展示老师正文和可点击的快捷回复卡片,学生也可在“和老师聊聊”输入框自由输入。点击卡片原样发送该回复,保留当前输入草稿;旧话题的数据不会恢复想法板、结构图、流程图或对照表及其操作。 -- “继续看看👀”由老师结合项目推荐一个具体切入点,快捷回复围绕这个切入点帮助学生接话;客户端不要求多个独立话题,也不固定正文长度、选项数量、前缀或表达风格。老师继续依据已配置职责帮助孩子整理想法、理解关系和承接已确认的共识,区分建议与已确认内容。 -- Main 对咨询回复统一使用 `{reply, quickReplies}`,本地读取工具的前言和云端非最终片段不当作最终回答;完整解析后展示正文和卡片,主动进展提醒仍直接展示正文。普通 Markdown、JSON 数据和代码示例保持正文。兼容读取旧 `{intro, questions}` 和带 `tool` 的回复,只提取正文与快捷回复,不恢复或更新组件。资源上限只用于防护,不做旧式字数截断;格式错误保留可读正文及原始回答,原文默认折叠、字面显示,不再次送进模型。 +- 客户端只提供入口事实、可用上下文和界面协议。讨论切入点、教学方式、是否提问、正文长度、前缀和表达风格由所选智能体的云端配置决定;不在“继续看看👀”、引导开口或后台检查时附加教学策略。当前新回复允许 0–3 条快捷回复,没有时用空数组,不要求凑满。 +- Main 对咨询回复统一使用 `{reply, quickReplies}`,本地读取工具的前言和云端非最终片段不当作最终回答;完整解析后展示正文和卡片,主动进展提醒仍直接展示正文。普通 Markdown、JSON 数据和代码示例保持正文。兼容读取旧 `{intro, questions}` 和带 `tool` 的回复,只提取正文与快捷回复,不恢复或更新组件。新输出超过 3 条快捷回复时保留完整正文、提示格式错误并保留原始回答,不展示超额卡片或静默截取;历史中已有的更多卡片在读取与后续保存时仍原样保留。独立资源上限只用于防护,不做旧式字数截断;格式错误的原文默认折叠、字面显示,不再次送进模型。 +- 回复中的引号或换行格式有误时,仅在正文边界明确、修复后整体合法的情况下恢复完整文字,不把半句话当成完整回答。附加建议损坏时仍展示完整正文;正文无法确认完整时显示“这次回复未能完整显示”,由学生点击“重新回答”,沿用原问题、引用与来源;持续聊天须回到原项目和操作对话后重试,新一轮按当前发布版本执行,旧话题仍使用绑定版本,保留当前草稿,连续点击不重复发送。打开聊天或旧话题会从保留的原文中在本地恢复可确认的正文,不调用模型,不因显示修复而改写磁盘记录;原始内容继续折叠保留。不完整的回答和建议不进入后续模型上下文。 - 咨询正文支持 Markdown 标题、列表、表格、代码围栏、HTTP(S) 链接/图片和数学公式,长代码与表格在栏内横向滚动,不执行 HTML。云端正文维持 string 合同,未知对象不会猜测转成回答。工具活动只从云端主线程的类型化工具事件及 Main 本地读取过程获得,按本次问题/工具调用身份合并名称和状态,跨云端暂停、续接仍只计一次;暂停读取不算失败,状态以实际读取结果或问题终态为准,单独折叠显示;参数、结果原文和内部错误不混入回复,思考内容不作为正文。客户端仅保存自己实际收到的工具状态,不能补回旧历史或断线期间已经过期的事件。 - 云端咨询在 local_context 声明 read_protocol=2;需先部署配套 Yuxi API 和 worker,再升级客户端。云端持久累计读取字节及批次,每轮告知模型剩余额度;预算耗尽后消费最后一批结果,并以 tool_choice=none 要求根据现有证据形成答案和说明缺口。Main 限制实际返回量并拒绝第十三批读取,区分读取达到上限、上下文失效及格式无效。服务端对未声明协议的已安装旧客户端保留原工具参数与六批边界。 - 智能体人设、职责、提示词和 Skills 由发布配置决定。Main 不追加固定教学基线、不生成“小麦”、不清空所选智能体的 Skills,只添加真实工具能力、上下文边界和本轮界面协议。智能体名称叫“老师”“朋友”或“代码顾问”不改变调用路径或权限。 @@ -186,7 +187,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - 智能体输入裁剪区分两种计量:本地模型以约每 2 个 UTF-8 字节估算 1 Token,并计入工具与读取预留;这不是精确分词或扣费依据,模型上限与实际 usage 仍由服务端拥有。云端沿用 query 字节上限,以完整 JSON 转义后的大小裁剪来源节选。智能体指令、当前问题和明确引用不会被静默裁掉;固定配置或回复协议超限时明确提示调整配置,避免让学生反复缩短短问题或新建话题。完整来源仍可由只读工具按需读取。 - `tests/fixtures/teacher-guidance-examples.json` 保留人工样例作为旧回复的读取兼容资料,仅验证正文和快捷回复投影,不加载到运行时,也不代表真实模型教学效果已验收。云端定义由 Yuxi 维护并由运营显式同步下发;本地代码修改不会自动改写云端配置或学生已接受问题的固定发布版本。 - 下发智能体的图标随发布版本保存,顶栏、咨询面板、欢迎区和消息使用定义中的 `icon_url`;未设置或图片加载失败时使用内置头像。运营在 Yuxi 更换图标后需同步下发配置,联系人使用当前下发的名称和图标;每轮执行仍记录实际发布版本。相对图片地址由服务端使用 Yuxi 公网入口补全。 -- 咨询聊天和文字草稿按当前云端账号、已下发智能体保存;切换项目或操作对话仍延续同一个聊天,每轮发送冻结当时的项目、来源和发布版本并检查启停。引用保留原项目/来源,切换后必须移除失配引用再发送。历史按50轮分页,流式事件只传当前轮;本机每轮独立原子落盘,索引保存消息摘要,旧轮次可通过只读工具按ID读取。可确认归属的旧话题按来源ID幂等迁入,原文件保留;“旧记录”入口只读查看升级前项目/来源话题。项目、Pi来源或版本变化时切换内部云线程并带入有预算的近期交流,同一段由Yuxi原生历史压缩管理上下文。此版聊天历史在本机保存,不提供跨设备同步。关闭面板或切换会话不取消已接受的问题,退出账号会中止。请求先落盘再调用模型,同一请求标识不重复发送,重启后未完成请求标记中断。云端断线按游标和持久状态恢复;停止或退出账号会取消原云端问题,下次提问先停止旧的未完成问题再绑定新上下文。只接收云端主线程回答,子智能体文本不混入正文。没有操作对话时也可咨询,但无操作文本上下文。 +- 咨询聊天和文字草稿按当前云端账号、已下发智能体保存;切换项目或操作对话仍延续同一个聊天,每轮发送冻结当时的项目、来源和发布版本并检查启停。引用保留原项目/来源,切换后必须移除失配引用再发送;旧组件仅保留原始归档,不恢复交互。历史按50轮分页,流式事件只传当前轮;本机每轮独立原子落盘,索引保存消息摘要,旧轮次可通过只读工具按ID读取。可确认归属的旧话题按来源ID幂等迁入,原文件保留;“旧记录”入口只读查看升级前项目/来源话题。项目、Pi来源或版本变化,以及首次从旧组件协议升级时,切换内部云线程并带入有预算的近期正文和快捷回复,同一段由Yuxi原生历史压缩管理上下文。此版聊天历史在本机保存,不提供跨设备同步。关闭面板或切换会话不取消已接受的问题,退出账号会中止。请求先落盘再调用模型,同一请求标识不重复发送,重启后未完成请求标记中断。云端断线按游标和持久状态恢复;停止或退出账号会取消原云端问题,下次提问先停止旧的未完成问题再绑定新上下文。只接收云端主线程回答,子智能体文本不混入正文。没有操作对话时也可咨询,但无操作文本上下文。 - 删除操作对话会停止正在使用该来源的回复,保留项目级咨询历史;归档来源需先恢复后才能引用。原有来源会话级智能体 API 与存储保持兼容,原路径历史不自动迁移到新项目级咨询栏。运营草稿试聊仍使用 `niancode://coding-teacher/preview?draft_revision=N` 打开独立示例上下文,不读取学生项目,要求桌面当前账号具有运营权限。 - 旧版客户端生成的朋友话题在“以往讨论”中标记为仅查看,不再创建或继续调用,也不自动绑定某个云端智能体;可选择下发项进入唯一聊天。统一咨询保留原有项目草稿;旧版朋友未发送的文字和引用可从“查看旧版朋友草稿”只读查看,不自动提交。旧文件和原发布版本保留,不复制或改写历史。已有 `coding-teacher` 云端契约及历史目录名保留,避免无必要的数据迁移。 diff --git a/electron/coding-teacher/context.ts b/electron/coding-teacher/context.ts index c6b41795..6f0e2e98 100644 --- a/electron/coding-teacher/context.ts +++ b/electron/coding-teacher/context.ts @@ -63,12 +63,12 @@ export function teacherHistoryMessages(history: TeacherRequest[]): TeacherSource text: [...(request.projectId ? ['当时的项目:' + (request.projectName ?? request.projectId)] : []), ...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 @@ -113,7 +113,7 @@ export function compileTeacherContext( const current: TeacherModelMessage = { role: intent === 'check-in' ? 'system' : 'user', content: intent === 'check-in' - ? '本轮是一次项目进展提醒,不是用户提问。按照已配置的人设和职责,结合来源操作对话中已完成的文字和咨询历史回应,表达方式遵循已配置的要求,不要求用户立即回答。仅依据已有证据,不重复上次提醒,不声称实际运行或试玩过作品。' + ? '本轮入口:程序触发的项目进展检查,学生没有在本轮主动提问。回应方式遵循当前智能体的云端配置。' : [ ...references.map( (ref) => @@ -123,10 +123,10 @@ export function compileTeacherContext( ref.text ), '当前问题:\n' + question, - ...(intent === 'suggestions' || intent === 'guided-help' - ? [ - '本轮学生希望你帮忙找到交流起点。依据当前项目、来源操作对话和咨询历史,推荐一个具体切入点,并说明为何值得从这里聊;快捷回复围绕这个切入点帮助学生接话。没有可用上下文时,坦诚从构思切入,不假定学生已完成功能,不编造作品表现或项目进展。', - ] + ...(intent === 'suggestions' + ? ['本轮入口:学生通过“帮我看看”主动请求帮助,尚未提出具体问题。'] + : intent === 'guided-help' + ? ['本轮入口:学生主动求助,表示暂时说不清想问什么。'] : []), ].join('\n\n'), }; diff --git a/electron/coding-teacher/conversation-store.ts b/electron/coding-teacher/conversation-store.ts index f5a0b67d..32cd7bb7 100644 --- a/electron/coding-teacher/conversation-store.ts +++ b/electron/coding-teacher/conversation-store.ts @@ -5,9 +5,9 @@ import type { TeacherDefinition, TeacherHistoryPage, TeacherRequest, TeacherTopi import { excerptTeacherText, teacherHistoryMessages } from './context'; import { atomicWriteJson, readJsonFile } from '../coding-projects/atomic-json'; import { TeacherError } from './config-client'; -import { teacherTopicId } from './store'; +import { repairCompletedReply, teacherTopicId } from './store'; -interface TurnIndex { id: string; createdAt: string; origin?: string; messages: ReturnType } +interface TurnIndex { replyProjectionVersion?: 1; id: string; createdAt: string; origin?: string; messages: ReturnType } interface Manifest { topic: Omit; turns: TurnIndex[]; @@ -19,8 +19,13 @@ const isMissing = (error: unknown) => (error as NodeJS.ErrnoException)?.code === const originKey = (request: TeacherRequest) => request.origin ? [request.origin.projectId, request.origin.topicId, request.origin.requestId].join(':') : undefined; const order = (a: TurnIndex, b: TurnIndex) => a.createdAt.localeCompare(b.createdAt) || (a.origin ?? a.id).localeCompare(b.origin ?? b.id); -const indexTurn = (turn: TeacherRequest): TurnIndex => ({ id: turn.id, createdAt: turn.createdAt, origin: originKey(turn), - messages: teacherHistoryMessages([turn]).map(message => ({ ...message, text: excerptTeacherText(message.text, 180).replaceAll('\n', ' ') })) }); +const indexTurn = (turn: TeacherRequest): TurnIndex => { + const projection = structuredClone(turn); + repairCompletedReply(projection); + return { id: turn.id, createdAt: turn.createdAt, origin: originKey(turn), replyProjectionVersion: 1, + messages: teacherHistoryMessages([projection]).map(message => ({ ...message, + text: excerptTeacherText(message.text, 180).replaceAll('\n', ' ') })) }; +}; /** A small index and one atomic file per turn. Only the latest page is held live. */ export class TeacherConversationStore { @@ -65,17 +70,23 @@ export class TeacherConversationStore { turn.status = 'interrupted'; turn.error = '应用已重启,本次回复中断。'; await atomicWriteJson(this.turnPath(turn.id), turn); + const index = manifest.turns.findIndex(item => item.id === turn.id); + if (index >= 0) manifest.turns[index] = indexTurn(turn); recovered = true; } if (recovered) { this.live.revision++; - await this.save(this.live); + // Only restart metadata changes here; completed display repairs stay in memory. + await this.save(this.live, ''); } } private async turn(id: string) { const turn = await readJsonFile(this.turnPath(id)) as TeacherRequest; if (turn.id !== id || !Array.isArray(turn.references)) throw new TeacherError(409, 'teacher_history_invalid', '智能体消息无法读取,请保留本机记录。'); + repairCompletedReply(turn); + const index = this.manifest?.turns.findIndex(item => item.id === turn.id) ?? -1; + if (this.manifest && index >= 0) this.manifest.turns[index] = indexTurn(turn); return turn; } async exists() { await this.load(); return Boolean(this.manifest); } @@ -114,7 +125,11 @@ export class TeacherConversationStore { const entries = structuredClone(this.manifest?.turns ?? []); const owners = new Map(entries.flatMap(turn => turn.messages.map(message => [message.id, turn.id] as const))); return { - messages: entries.flatMap(turn => turn.messages), + // Older indexes may contain the unsafe quoted prefix. Keep them lazy: + // expose an ID for reading, not an unverified assistant excerpt. + messages: entries.flatMap(turn => turn.messages.map(message => + turn.replyProjectionVersion === 1 || message.role !== 'assistant' ? message + : { ...message, text: '(按消息 ID 读取完整回复)' })), read: async (id: string) => { const owner = owners.get(id); return owner ? teacherHistoryMessages([await this.turn(owner)]).find(message => message.id === id) : undefined; diff --git a/electron/coding-teacher/reply.ts b/electron/coding-teacher/reply.ts index 0356e4f8..323a99ff 100644 --- a/electron/coding-teacher/reply.ts +++ b/electron/coding-teacher/reply.ts @@ -1,20 +1,32 @@ import type { TeacherRequest } from '../../shared/coding-teacher'; import { parseTeacherReply } from '../../shared/teacher-reply'; +const MAX_CURRENT_QUICK_REPLIES = 3; + export function teacherReplyInstructions(): string { - return '本轮界面接收一个 JSON 对象 {"reply":string,"quickReplies":string[]}。reply 是回复正文,可使用 Markdown;quickReplies 是可选的后续回复建议,没有建议时用空数组。只使用这两个字段。依据已配置职责帮助学生整理想法、理解关系、承接已确认的共识;区分学生已确认内容与智能体建议、待定想法。回复的语言、风格、长度、前缀和建议内容遵循当前智能体的云端配置。'; + return `本轮界面接收一个 JSON 对象 {"reply":string,"quickReplies":string[]}。reply 是回复正文,可使用 Markdown;quickReplies 是可选的快捷回复,最多 ${MAX_CURRENT_QUICK_REPLIES} 条,每条为非空字符串,没有快捷回复时用空数组。只使用这两个字段。字符串中的英文双引号、反斜杠和换行必须按 JSON 规则转义,确保整个对象是合法 JSON。回复的内容、语言、风格、长度和前缀遵循当前智能体的云端配置。`; } /** The original response remains available for diagnosis when parsing is incomplete. */ export function applyTeacherReply(request: TeacherRequest, raw: string): void { const parsed = parseTeacherReply(raw); + // Apply the current UI contract only to new replies; archived replies keep + // their original cards through the compatibility parser and topic store. + if (parsed.quickReplies.length > MAX_CURRENT_QUICK_REPLIES) { + request.replyQuickRepliesRejected = true; + parsed.quickReplies = []; + parsed.parseError ??= '快捷回复超过当前界面最多 3 条的限制,正文已保留,快捷回复未展示。'; + } else delete request.replyQuickRepliesRejected; 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; } diff --git a/electron/coding-teacher/service.ts b/electron/coding-teacher/service.ts index 1e19eec7..01502db7 100644 --- a/electron/coding-teacher/service.ts +++ b/electron/coding-teacher/service.ts @@ -479,11 +479,13 @@ export class CodingTeacherService { const previous = topic.requests.at(-1); const reuse = previous && !previous.origin && previous.projectId === scope.projectId && previous.sourceConversationId === input.sourceConversationId + && previous.presentation === 'reply-v1' && previous.teacherVersion === selected.version && previous.runtimeThreadId; newConversationSegment = !reuse; // Yuxi binds both project and Pi source to a thread. Preserve its native // summary middleware within a segment; explicitly carry bounded public - // history only when a new scope/release starts another segment. + // history only when a new scope, release, or reply protocol starts another + // segment. Older checkpoints may still contain retired component instructions. topic.conversation!.runtimeThreadId = reuse || randomUUID(); topic.conversation!.segmentTurns = reuse ? topic.conversation!.segmentTurns + 1 : 1; } diff --git a/electron/coding-teacher/store.ts b/electron/coding-teacher/store.ts index 69a8ba29..c68ac768 100644 --- a/electron/coding-teacher/store.ts +++ b/electron/coding-teacher/store.ts @@ -1,9 +1,44 @@ 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. */ +export 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 (request.replyQuickRepliesRejected) { + parsed.quickReplies = []; + parsed.parseError ??= request.replyParseError; + } + 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 +95,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); diff --git a/shared/coding-teacher.ts b/shared/coding-teacher.ts index 59f443bf..95b0f3e5 100644 --- a/shared/coding-teacher.ts +++ b/shared/coding-teacher.ts @@ -80,7 +80,11 @@ 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; + /** New-output shortcut rejection survives later historical prose repair. */ + replyQuickRepliesRejected?: boolean; + /** Original answer retained for failed or repaired parsing; never fed back as context. */ unparsedResponse?: string; toolActivity?: TeacherToolActivity[]; diff --git a/shared/teacher-reply.ts b/shared/teacher-reply.ts index 62eec581..b78fe92a 100644 --- a/shared/teacher-reply.ts +++ b/shared/teacher-reply.ts @@ -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; keys: Set; duplicate: boolean; toolStart?: number } { +function fields(raw: string): { values: Map; keys: Set; duplicate: boolean; toolStart?: number; proseStart?: number } { const values = new Map(), keys = new Set(); 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; keys: Set; 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; keys: Set; 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; + 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): 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): 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): 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); } 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; diff --git a/src/pages/Chat/TeacherChatPanel.tsx b/src/pages/Chat/TeacherChatPanel.tsx index 94fd03e4..215616ed 100644 --- a/src/pages/Chat/TeacherChatPanel.tsx +++ b/src/pages/Chat/TeacherChatPanel.tsx @@ -6,6 +6,7 @@ import type { TeacherDefinition, TeacherCatalog, TeacherReference, + TeacherRequest, TeacherSend, TeacherTopic, TeacherTopicList, @@ -134,6 +135,7 @@ export function TeacherChatPanel({ ? teachers.some((item) => item.teacher_id === topic.definition.config_id) : legacyEnabled; const pending = useRef(restorePendingSend(saved.pending, continuous ? saved.projectId ?? projectId : undefined)); + const sendInFlight = useRef(false); useEffect(() => { persistDraft(text, references, pending.current); }, [text, references, persistDraft]); @@ -378,11 +380,21 @@ export function TeacherChatPanel({ if (version === generation.current) setBusy(false); } }; - const send = async (action?: Pick) => { + const retryScopeMatches = (request: TeacherRequest) => !continuous + || (request.projectId === projectId && (request.sourceConversationId ?? '') === sourceId); + const send = async (action?: Pick, retry?: TeacherRequest) => { + // A historical retry stays with its original topic and published Agent. + if (retry && (topicRef.current?.id !== topic?.id || !topic || !retryScopeMatches(retry) + || (!continuous && 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; - if (continuous && questionReferences.length && (referenceScope.projectId !== projectId || referenceScope.sourceId !== sourceId)) { + const questionReferences = retry?.references ?? (action ? [] : references); + const questionSourceId = retry + ? retry.sourceConversationId ?? (continuous || 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; + if (continuous && !retry && questionReferences.length && (referenceScope.projectId !== projectId || referenceScope.sourceId !== sourceId)) { setError('引用来自另一个项目或操作对话,请切回来源,或移除引用后再提问。'); return; } if (continuous && pending.current && !topicRef.current?.requests.some(request => request.id === pending.current?.requestId) @@ -390,7 +402,13 @@ export function TeacherChatPanel({ && pending.current.projectId && pending.current.projectId !== projectId) { setError('上次提问仍绑定原项目,请切回原项目确认结果后重试。'); return; } + if (continuous && !retry && action?.intent === 'suggestions' && pending.current?.intent === 'suggestions' + && !topicRef.current?.requests.some(request => request.id === pending.current?.requestId) + && pending.current.sourceConversationId !== (questionSourceId || undefined)) { + setError('上次提问仍绑定原操作对话,请切回原操作对话确认结果后重试。'); return; + } const version = generation.current; + sendInFlight.current = true; setBusy(true); setError(''); let current = topicRef.current; @@ -410,20 +428,21 @@ 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 && (!continuous || previous.projectId === projectId) && (retrySuggestions || ( + previous && (!continuous || (previous.projectId === projectId + && previous.sourceConversationId === (questionSourceId || undefined))) && (retrySuggestions || ( previous.text === question && - (!continuous || previous.sourceConversationId === (sourceId || undefined)) && (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, ...(continuous ? { projectId } : {}), ...(action?.intent ? { intent: action.intent } : {}), - ...(sourceId ? { sourceConversationId: sourceId } : {}), + ...(questionSourceId ? { sourceConversationId: questionSourceId } : {}), }; pending.current = input; persistDraft(text, references, input); @@ -433,7 +452,7 @@ export function TeacherChatPanel({ consume(next); pending.current = null; if (action) { - persistDraft(text, references); + persistDraft(draftRef.current.text, draftRef.current.references); } else { setText(''); setReferences([]); @@ -444,6 +463,7 @@ export function TeacherChatPanel({ if (version === generation.current) setError(e instanceof Error ? e.message : '提问失败,请保留问题后重试'); } finally { + sendInFlight.current = false; if (version === generation.current) setBusy(false); } }; @@ -508,7 +528,11 @@ export function TeacherChatPanel({ )} } - {request.intent === 'suggestions' ? <> + {request.status === 'completed' && request.replyIncomplete ?
+

这次回复未能完整显示

+ {request.intent !== 'check-in' && request.text.trim() && } + {!retryScopeMatches(request) &&

回到原项目和操作对话后,可以重新回答。

} +
: request.intent === 'suggestions' ? <> {['running', 'preparing'].includes(request.status) ?

我看看你最近做到了哪里…

: request.status === 'completed' && <> @@ -519,9 +543,10 @@ export function TeacherChatPanel({ } {['failed', 'cancelled', 'interrupted'].includes(request.status) && } : request.response ? :
{['running', 'preparing'].includes(request.status) ? {label}正在想… : ['cancelled', 'interrupted'].includes(request.status) ? '这次先停在这里。想继续时可以再说说。' : ''}
} - {request.intent !== 'suggestions' && request.status === 'completed' && Boolean(request.suggestedQuestions?.length) &&
+ {request.intent !== 'suggestions' && request.status === 'completed' && !request.replyIncomplete && Boolean(request.suggestedQuestions?.length) &&
{request.suggestedQuestions?.map((question, index) => )}
} + {request.status === 'completed' && !request.replyIncomplete && request.replyParseError &&

回答正文已保留,部分附加内容未能显示。

} {request.unparsedResponse &&
查看收到的原始内容
{request.unparsedResponse}
@@ -529,7 +554,7 @@ export function TeacherChatPanel({ {request.progress && ['running', 'preparing'].includes(request.status) &&

{request.progress}

} {(request.truncatedMessages ?? 0) > 0 &&

{draftRevision ? '较长的上下文已节选。' : '较长的上下文已节选,智能体可按需读取原文。'}

} {request.error &&

{request.error}

} - {onBringBack && request.response && request.intent !== 'suggestions' && request.status === 'completed' &&
+ {onBringBack && request.response && !request.replyIncomplete && request.intent !== 'suggestions' && request.status === 'completed' &&
} diff --git a/src/pages/Chat/use-teacher-companion.ts b/src/pages/Chat/use-teacher-companion.ts index 44302560..2f356390 100644 --- a/src/pages/Chat/use-teacher-companion.ts +++ b/src/pages/Chat/use-teacher-companion.ts @@ -286,6 +286,13 @@ export function useTeacherCompanion(options: Options) { return () => { alive = false; window.clearInterval(timer); window.removeEventListener('focus', refresh); }; }, [account, state.agents]); + useEffect(() => { + if (!options.teacherOpen || !options.projectId) return; + // Entering consultation consumes the greeting, but leaves real invitations unread. + writeLocal(storageKey + ':welcome-dismissed', true); + setState((current) => current.welcomeDismissed ? current : { ...current, welcomeDismissed: true }); + }, [options.teacherOpen, options.projectId, storageKey]); + const topic = stateScope.current === scope ? state.topic : null; const topicId = topic?.id; const topicProjectId = topic?.projectId; @@ -315,7 +322,7 @@ export function useTeacherCompanion(options: Options) { const selectedAgentId = visibleState.selectedAgent?.teacher_id ?? selectedDefinition?.config_id; const selectedAgent = visibleState.selectedAgent === null ? undefined : visibleState.agents.find((item) => item.teacher_id === selectedAgentId) ?? visibleState.selectedAgent; - const welcome = topic?.role !== 'friend' && !visibleState.loading && visibleState.enabled && !visibleState.welcomeDismissed + const welcome = topic?.role !== 'friend' && !options.teacherOpen && !visibleState.loading && visibleState.enabled && !visibleState.welcomeDismissed ? selectedDefinition?.welcome_message.trim() || null : null; return { unreadAgentIds, diff --git a/tests/e2e/fixtures/teacher-reply-api.ts b/tests/e2e/fixtures/teacher-reply-api.ts index f1dda371..8b4e9084 100644 --- a/tests/e2e/fixtures/teacher-reply-api.ts +++ b/tests/e2e/fixtures/teacher-reply-api.ts @@ -1,7 +1,7 @@ // Deterministic responses for layout/interaction tests only; never imported by the app. import type { TeacherDefinition, TeacherTopic, TeacherSend } from '../../../shared/coding-teacher'; -const definition: TeacherDefinition = { schema_version: 1, config_id: 'coding-teacher', 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 } }; -export const fixtureAgent = { teacher_id: definition.config_id!, version: 1, is_default: true, definition }; +import { parseTeacherReply } from '../../../shared/teacher-reply'; +const definition: TeacherDefinition = { config_id: 'coding-teacher', 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 = { 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' }] }, structure: { kind: 'structure', title: '小狗和花园怎么连起来?', nodes: [{ id: 'world', label: '我的小狗世界' }, { id: 'dog', label: '照顾小狗', parentId: 'world' }, { id: 'breed', label: '一只柯基', parentId: 'dog', relation: '它的样子' }, { id: 'garden', label: '种植花园', parentId: 'world' }, { id: 'food', label: '收获食物,喂给小狗', parentId: 'garden', relation: '收获后' }] }, @@ -9,8 +9,22 @@ const samples: Record = { comparison: { kind: 'comparison', title: '哪一种更像你想要的小狗?', columns: [{ id: 'click', label: '点了才回应' }, { id: 'active', label: '会主动找我' }], rows: [{ id: 'home', label: '刚回到家的时候', cells: [{ columnId: 'click', text: '等我点一下,才跳起来。' }, { columnId: 'active', text: '看见我就跑过来,还带着玩具。' }] }, { id: 'busy', label: '我忙着种东西的时候', cells: [{ columnId: 'click', text: '安静等着,不打扰我。' }, { columnId: 'active', text: '有时来蹭一蹭,可能也会打断我。' }] }] }, }; 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, conversation: { agentId: fixtureAgent.teacher_id, segmentTurns: 1 }, createdAt: 'now', updatedAt: 'now', requests: Array.from({ length: 8 }, (_, i) => ({ id: `r${i}`, text: i === 7 ? '我还没想好,我们边聊边想。' : '我希望这个游戏更有意思。', references: [], projectId: 'fixture', sourceConversationId: 'source', teacherVersion: 1, createdAt: `2026-09-28T10:00:${String(i).padStart(2, '0')}Z`, 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]) } } : {}) }; +export const fixtureAgent = { teacher_id: definition.teacher_id, version: 1, is_default: true, definition }; +const continuous = new URLSearchParams(location.search).get('chat') !== 'legacy'; +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: [], projectId: 'fixture', sourceConversationId: 'source', teacherVersion: 1, createdAt: `2026-09-28T10:00:${String(i).padStart(2, '0')}Z`, 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]) } } : {}) }; +if (continuous) current.conversation = { agentId: definition.teacher_id, segmentTurns: 1, discussions: {} }; 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], projectId: 'fixture', 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 = [{ @@ -31,20 +45,24 @@ const sentInputs: TeacherSend[] = []; Object.defineProperty(window, '__teacherReplyInputs', { value: sentInputs }); const snapshot = () => { current.revision++; stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(current) })); }; export const teacherApi = { - catalog: async () => ({ items: [fixtureAgent] }), - config: async () => ({ enabled: true, definition }), conversation: async () => ({ topic: structuredClone(current), before: null }), - seenConversation: async (_agent: string, requestId: string) => { - current.conversation!.seenThrough = requestId; + seenConversation: async (_agentId: string, requestId: string) => { + if (current.conversation) current.conversation.seenThrough = requestId; current.revision++; return structuredClone(current); }, + sendConversation: async (_agentId: string, input: TeacherSend): Promise => teacherApi.send('/fixture', current.id, input), + catalog: async () => ({ items: [{ teacher_id: definition.teacher_id, version: 1, is_default: true, definition }] }), + config: async () => ({ enabled: true, definition }), legacyHistory: async () => ({ items: [], lastSelectedTopicId: null }), + list: async () => ({ items: [{ id: current.id, title: '我的小狗世界' }], lastSelectedTopicId: current.id }), + read: async () => structuredClone(current), + create: async () => structuredClone(current), events: async () => stream, - sendConversation: async (_agent: string, input: TeacherSend) => { + send: async (_base: string, _id: string, input: TeacherSend) => { if ('presentation' in input || 'discussion' in input) throw new Error('Retired component fields must not be sent'); sentInputs.push(structuredClone(input)); - current.requests.push({ id: input.requestId, text: input.text, intent: input.intent, references: [], projectId: input.projectId, sourceConversationId: input.sourceConversationId, teacherVersion: 1, createdAt: `2026-09-28T10:01:${String(sentInputs.length).padStart(2, '0')}Z`, status: 'running', sourceCursor: { workerGeneration: 0, seq: 1 }, sourceCapturedAt: 'now', includedSourceMessageIds: [], omittedMessages: 0, response: '', presentation: 'reply-v1' }); + current.requests.push({ id: input.requestId, text: input.text, intent: input.intent, references: input.references ?? [], projectId: input.projectId, sourceConversationId: input.sourceConversationId, teacherVersion: 1, createdAt: `2026-09-28T10:01:${String(sentInputs.length).padStart(2, '0')}Z`, status: 'running', sourceCursor: { workerGeneration: 0, seq: 1 }, sourceCapturedAt: 'now', includedSourceMessageIds: [], omittedMessages: 0, response: '', presentation: 'reply-v1' }); snapshot(); timer = setTimeout(() => { const request = current.requests.at(-1)!; diff --git a/tests/e2e/fixtures/teacher-reply-layout.tsx b/tests/e2e/fixtures/teacher-reply-layout.tsx index 5836d7ce..dcfb3fa9 100644 --- a/tests/e2e/fixtures/teacher-reply-layout.tsx +++ b/tests/e2e/fixtures/teacher-reply-layout.tsx @@ -4,11 +4,13 @@ import { TeacherChatPanel } from '../../../src/pages/Chat/TeacherChatPanel'; import '../../../src/styles/globals.css'; import '../../../src/pages/Chat/classroom-workspace.css'; import 'katex/dist/katex.min.css'; +const chat = new URLSearchParams(location.search).get('chat'); +const continuous = chat !== 'legacy'; const width = Number(new URLSearchParams(location.search).get('width')) || 508; createRoot(document.getElementById('root')!).render(
智能体回复交互测试 · 真实页面组件 / 模拟回复

我的小狗世界

作品区域

-
+
); diff --git a/tests/e2e/pi-coding-first-chat.spec.ts b/tests/e2e/pi-coding-first-chat.spec.ts index 0207e08b..58b94c8b 100644 --- a/tests/e2e/pi-coding-first-chat.spec.ts +++ b/tests/e2e/pi-coding-first-chat.spec.ts @@ -21,7 +21,15 @@ type HostConnection = { token: string; }; +type ConsultationReply = { + response: string; + suggestedQuestions: string[]; + replyParseError?: string; + unparsedResponse?: string; +}; + type TeacherCatalogFixture = { + nextTeacherReplyIncomplete?: boolean; teacherCatalog: TeacherCatalog; teacherCatalogFailure: boolean; holdTeacherCatalog: boolean; @@ -96,9 +104,10 @@ async function installCodingFirstChatHost( managedCapabilities = false, removedModel = false, audioPreview?: { executionId: string; path: string; dataUrl: string }, + consultationReply?: ConsultationReply, ): Promise { await electronApp.evaluate(async (_, payload) => { - const { connection, featureComplete, managedCapabilities, removedModel, audioPreview } = payload; + const { connection, featureComplete, managedCapabilities, removedModel, audioPreview, consultationReply } = payload; const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron'); type MainState = TeacherCatalogFixture & { captured: CapturedRequest[]; @@ -574,6 +583,13 @@ async function installCodingFirstChatHost( const suggestions = body!.intent === 'suggestions'; const response = suggestions ? '我们可以从你最近试过的地方聊起。' : body!.intent === 'guided-help' ? '你最近做的哪一步,让你停下来想了一会儿?' : '先理解状态如何随点击变化,再修改代码。'; consultationTopics[agentId] = {...current,revision:Number(current.revision)+1,requests:[...(current.requests as unknown[]),{id:body!.requestId,projectId:body!.projectId,sourceConversationId:body!.sourceConversationId,text:body!.text,intent:body!.intent,references:body!.references??[],createdAt:new Date().toISOString(),sourceCursor:{workerGeneration:1,seq:1},sourceCapturedAt:now,includedSourceMessageIds:[],omittedMessages:0,truncatedMessages:1,status:'completed',response,...(suggestions?{suggestedQuestions:['怎样观察别人玩游戏?','我该先试哪个想法?']}:{})}]}; + const last = (consultationTopics[agentId].requests as Record[]).at(-1)!; + Object.assign(last, consultationReply); + if (state.nextTeacherReplyIncomplete) { + state.nextTeacherReplyIncomplete = false; + Object.assign(last, { response: '它早就不是', replyIncomplete: true, replyParseError: '格式不完整', + unparsedResponse: '{"reply":"它早就不是"刚搭好架子' }); + } return respond(consultationTopics[agentId],202); } } @@ -592,6 +608,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[]).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); @@ -836,7 +858,7 @@ async function installCodingFirstChatHost( if (path === '/api/coding/runtime/diagnostics') return respond({ runtime: { revision: { provider: 1, resources: 1 }, workers: [{ conversationId: conversation.id, generation: 1, state: 'running', stage: 'running' }] } }); return respond({ success: false, error: `Unhandled E2E route: ${method} ${path}` }, 404); }); - }, { connection: hostConnection, featureComplete, managedCapabilities, removedModel, audioPreview }); + }, { connection: hostConnection, featureComplete, managedCapabilities, removedModel, audioPreview, consultationReply }); } test('saved Game Audio offers click-only playable local preview in Electron', async ({ launchElectronApp }, testInfo) => { @@ -1914,6 +1936,101 @@ 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('project consultations show reply protocol errors without exposing rejected shortcuts', async ({ launchElectronApp }) => { + const electronApp = await launchElectronApp({ skipSetup: true }); + let page = await getStableWindow(electronApp); + 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 })); + const response = '可以先观察玩家在哪里停下来,再聊聊你想改的地方。'; + const quickReplies = ['看懂规则|怎样观察玩家?', '想想作用|我想聊聊按钮。', '试试结果|怎样看出变化?', ' **不要渲染**']; + const replyParseError = '快捷回复超过当前界面最多 3 条的限制,正文已保留,快捷回复未展示。'; + const unparsedResponse = '\n' + JSON.stringify({ reply: response, quickReplies }, null, 2) + '\n'; + await installCodingFirstChatHost(electronApp, connection, true, false, false, undefined, { + response, suggestedQuestions: [], replyParseError, unparsedResponse, + }); + await settleSnapshot(electronApp); + await disableCodingEventSource(page); + try { + await page.reload(); page = await getStableWindow(electronApp); + await page.getByTestId('ai-module-option-programming').click(); + await page.evaluate(() => { window.location.hash = '/chat'; }); + await expect(page.getByTestId('project-conversations')).toBeVisible(); + const composer = page.getByTestId('coding-message-composer').getByRole('textbox', { includeHidden: true }); + await composer.fill('保留操作草稿'); + await page.getByRole('button', { name: '与朋友聊天', exact: true }).click(); + const teacher = page.getByTestId('teacher-chat-panel'); + const teacherComposer = teacher.getByRole('textbox', { name: '向智能体提问' }); + await teacherComposer.fill('还没说完的困惑'); + await teacher.getByRole('button', { name: '提问', exact: true }).click(); + const reply = teacher.locator('.consultation-messages > div').filter({ has: page.getByText(response, { exact: true }) }); + await expect(reply.getByText(response, { exact: true })).toBeVisible(); + await expect(reply.getByRole('status')).toHaveText('回答正文已保留,部分附加内容未能显示。'); + await expect(reply.getByRole('status')).toBeVisible(); + await expect(teacher.locator('.consultation-follow-up')).toHaveCount(0); + for (const question of quickReplies) { + await expect(teacher.getByRole('button', { name: question, exact: true })).toHaveCount(0); + } + const raw = reply.locator('details').filter({ has: page.getByText('查看收到的原始内容', { exact: true }) }); + await expect(raw).not.toHaveAttribute('open'); + await expect(raw.locator('pre')).toBeHidden(); + await raw.locator('summary').click(); + await expect(raw.locator('pre')).toBeVisible(); + expect(await raw.locator('pre').textContent()).toBe(unparsedResponse); + await expect(raw.locator('button, strong')).toHaveCount(0); + await expect(teacherComposer).toHaveValue(''); + await expect(composer).toHaveValue('保留操作草稿'); + const requests = (await readState(electronApp)).captured; + expect(requests.filter(item => item.path.endsWith('/messages') && item.method === 'POST')).toHaveLength(1); + expect(requests.filter(item => item.path.endsWith('/prompt') && item.method === 'POST')).toHaveLength(0); + } finally { + await releaseSnapshot(electronApp); + } +}); + + test('manual agent refresh updates the catalog and retries failures without replacing the ongoing chat or drafts', async ({ launchElectronApp }) => { const electronApp = await launchElectronApp({ skipSetup: true }); let page = await getStableWindow(electronApp); @@ -2057,6 +2174,41 @@ test('settled prompt releases the composer without a client receipt and keeps th } finally { await releaseSnapshot(electronApp); } }); +test('teacher avatar consumes the welcome bubble through close, reopen and reload without a model request', async ({ launchElectronApp }) => { + const electronApp = await launchElectronApp({ skipSetup: true }); + let page = await getStableWindow(electronApp); + 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(electronApp, connection, true); + await settleSnapshot(electronApp); + await disableCodingEventSource(page); + try { + await page.reload(); page = await getStableWindow(electronApp); + await page.getByTestId('ai-module-option-programming').click(); + await page.evaluate(() => { window.location.hash = '/chat'; }); + const teacherButton = page.getByRole('button', { name: '与代码智能体聊天', exact: true }); + const invitation = page.getByTestId('teacher-invitation'); + const teacher = page.getByTestId('teacher-chat-panel'); + await expect(invitation).toHaveAttribute('data-bubble-kind', 'welcome'); + await teacherButton.click(); + await expect(teacher).toBeVisible(); + await expect(teacher.getByText('一起理解代码', { exact: true })).toBeVisible(); + await expect(invitation).toHaveCount(0); + await page.screenshot({ path: test.info().outputPath('teacher-avatar-consumed-welcome.png') }); + await teacherButton.click(); + await expect(teacher).toHaveCount(0); + await expect(invitation).toHaveCount(0); + await teacherButton.click(); + await expect(teacher).toBeVisible(); + await expect(invitation).toHaveCount(0); + await teacherButton.click(); + await expect(teacher).toHaveCount(0); + await page.reload(); + await expect(teacherButton).toBeVisible(); + await expect(invitation).toHaveCount(0); + expect((await readState(electronApp)).captured.filter(item => /\/(agent-topics|messages|teacher-check-in|prompt)$/.test(item.path) && item.method === 'POST')).toHaveLength(0); + } finally { await releaseSnapshot(electronApp); } +}); + test('teacher stays in the top-right header without legacy check-ins or taking over the student draft', async ({ launchElectronApp }) => { const electronApp = await launchElectronApp({ skipSetup: true }); let page = await getStableWindow(electronApp); diff --git a/tests/e2e/teacher-reply-layout.spec.ts b/tests/e2e/teacher-reply-layout.spec.ts index 9fa1bb0f..69b53bce 100644 --- a/tests/e2e/teacher-reply-layout.spec.ts +++ b/tests/e2e/teacher-reply-layout.spec.ts @@ -47,6 +47,40 @@ test('retry stickers stay readable and preserve drafts in a 319px consultation', }); for (const width of [319, 508]) { + for (const chat of ['legacy', 'continuous']) test(`quoted ${chat} 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}&chat=${chat}`); + 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}&chat=${chat}`); + 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: '现在项目是什么阶段?', ...(chat === 'continuous' ? { projectId: 'fixture' } : {}), + 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}`); diff --git a/tests/unit/coding-teacher-cloud.test.ts b/tests/unit/coding-teacher-cloud.test.ts index 230d008a..a27cfb2f 100644 --- a/tests/unit/coding-teacher-cloud.test.ts +++ b/tests/unit/coding-teacher-cloud.test.ts @@ -418,8 +418,8 @@ it('submits a nonempty proactive check-in without inventing a user message', asy f.access.source.messages = []; const { body, compiled } = await submitCompiledContext(f, 'check-in'); expect(compiled.messages.map(message => message.role)).toEqual(['system', 'system']); - expect(compiled.messages.at(-1)?.content).toContain('本轮是一次项目进展提醒,不是用户提问'); - expect(body.query).toContain('仅依据已有证据'); + expect(compiled.messages.at(-1)?.content).toContain('程序触发的项目进展检查,学生没有在本轮主动提问'); + expect(body.query).toContain('回应方式遵循当前智能体的云端配置'); }); it('gives a server-defined friend the same scoped read tools', async () => { diff --git a/tests/unit/coding-teacher-ui.test.tsx b/tests/unit/coding-teacher-ui.test.tsx index b0b513a5..6df59675 100644 --- a/tests/unit/coding-teacher-ui.test.tsx +++ b/tests/unit/coding-teacher-ui.test.tsx @@ -162,6 +162,145 @@ 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(); + 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(); + 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(resolve => { finish = resolve; })); + render(); + 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(); + 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(); + 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(); + 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('shows the format error alongside the preserved reply while keeping excess shortcuts noninteractive', async () => { + const response = '先看看摸头会带来什么变化。'; + const quickReplies = ['看懂规则|摸头会改变什么?', '想想作用|我们聊聊摸头。', '试试结果|怎样看出变化?', '换个话题|我想聊别的地方。']; + const replyParseError = '快捷回复超过当前界面最多 3 条的限制,正文已保留,快捷回复未展示。'; + const raw = '\n' + JSON.stringify({ reply: response, quickReplies }, null, 2) + '\n'; + api.read.mockResolvedValue({ ...first, requests: [suggestionsRequest({ + presentation: 'reply-v1', response, suggestedQuestions: [], replyParseError, unparsedResponse: raw, + })] }); + render(); + await ready(); + + expect(screen.getByText(response)).toBeVisible(); + expect(screen.getByText('回答正文已保留,部分附加内容未能显示。')).toBeVisible(); + for (const shortcut of quickReplies) { + expect(screen.queryByRole('button', { name: shortcut, exact: true })).not.toBeInTheDocument(); + } + const details = screen.getByText('查看收到的原始内容').closest('details'); + expect(details).not.toHaveAttribute('open'); + expect(details?.querySelector('pre')?.textContent).toBe(raw); + expect(screen.getByLabelText('向智能体提问')).toBeEnabled(); + 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\n\n![本地图片](file:///C:/secret.png)\n\n![坏图片](https://example.com/missing.png)', @@ -656,6 +795,22 @@ describe('teacher side chat', () => { describe('legacy discussion history compatibility', () => { + it('shows every historical shortcut even when the old reply has more than three', async () => { + const shortcuts = ['看懂规则|摸头会改变什么?', '想想作用|我们聊聊摸头。', '试试结果|怎样看出变化?', '换个话题|我想聊别的地方。']; + api.read.mockResolvedValue({ ...first, requests: [request({ + presentation: 'discussion-v1', response: '这些是之前留下的讨论入口。', suggestedQuestions: shortcuts, + })] }); + render(); + await ready(); + + expect(screen.getByText('这些是之前留下的讨论入口。')).toBeVisible(); + for (const shortcut of shortcuts) { + expect(screen.getByRole('button', { name: shortcut, exact: true })).toBeEnabled(); + } + expect(screen.queryByText('查看收到的原始内容')).not.toBeInTheDocument(); + expect(api.send).not.toHaveBeenCalled(); + }); + const contents = [ { kind: 'ideas', title: '旧想法组件', items: [{ id: 'dog', text: '养只小狗', state: 'kept' }] }, { kind: 'structure', title: '旧结构组件', nodes: [{ id: 'dog', label: '照顾小狗' }] }, @@ -754,7 +909,7 @@ describe('merged cloud teacher classroom', () => { } })); api.catalog.mockResolvedValue({ items: [agent] }); api.sendConversation.mockRejectedValue(new Error('结果暂未确认')); - const props = { projectId: 'p', sourceId: 'current-source', selectedAgent: agent }; + const props = { projectId: 'p', sourceId: 'original-source', selectedAgent: agent }; const view = render(); const hint = () => screen.getByText('继续看看👀', { selector: 'form .consultation-hint' }); await waitFor(() => expect(hint()).toBeEnabled()); @@ -766,6 +921,10 @@ describe('merged cloud teacher classroom', () => { fireEvent.click(hint()); await screen.findByText('上次提问仍绑定原项目,请切回原项目确认结果后重试。'); expect(api.sendConversation).toHaveBeenCalledTimes(1); + view.rerender(); + fireEvent.click(hint()); + await screen.findByText('上次提问仍绑定原操作对话,请切回原操作对话确认结果后重试。'); + expect(api.sendConversation).toHaveBeenCalledTimes(1); view.rerender(); fireEvent.click(hint()); await screen.findByText('结果暂未确认'); diff --git a/tests/unit/coding-teacher.test.ts b/tests/unit/coding-teacher.test.ts index 62cbfd50..bb290b47 100644 --- a/tests/unit/coding-teacher.test.ts +++ b/tests/unit/coding-teacher.test.ts @@ -628,6 +628,28 @@ describe('teacher contextual discussion entry points', () => { }); }); + it.each([false, true])('persists prose and exact raw diagnostics instead of excess new shortcuts (cloud=%s)', async (cloudTeacher) => { + const f = await fixture(cloudTeacher ? { cloudTeacher: true, mockCloud: true } : {}); + const scope = newScope(f); + const topic = await f.service.create(scope); + const reply = '我们先看看规则会怎样影响结局。'; + const quickReplies = ['看懂规则|现在是什么规则?', '想想影响|摸头会改变什么?', '试试结果|怎样验证?', '换个话题|先聊别的。']; + const raw = JSON.stringify({ reply, quickReplies }, null, 2); + f.replyWith(raw); + await f.service.send(scope, topic.id, { requestId, intent: 'suggestions', text: '帮我看看' }); + const completed = await finishRequest(f, scope, topic.id); + expect(completed.requests[0]).toMatchObject({ + status: 'completed', response: reply, suggestedQuestions: [], + replyParseError: expect.stringContaining('最多 3 条'), unparsedResponse: raw, + }); + const disk = JSON.parse(await readFile(path.join( + f.created.project.path, '.makelore/teacher-conversations', topic.accountId, 'project', topic.id + '.json' + ), 'utf8')); + expect(disk.requests[0]).toMatchObject({ + response: reply, suggestedQuestions: [], replyParseError: completed.requests[0].replyParseError, unparsedResponse: raw, + }); + }); + it('does not treat a cancelled response as valid suggestions even when its JSON is complete', async () => { const f = await fixture(); const scope = newScope(f); @@ -698,7 +720,7 @@ describe('teacher contextual discussion entry points', () => { expect(saved.requests[0].suggestedQuestions).toBeUndefined(); }); - it('adds a one-question guided opening for this round without changing the teacher system instructions', async () => { + it('marks the guided-help entry without choosing a teaching strategy or changing the system instructions', async () => { const f = await fixture(); const scope = newScope(f); const topic = await f.service.create(scope); @@ -710,7 +732,7 @@ describe('teacher contextual discussion entry points', () => { }); expect(completed.requests[0].suggestedQuestions).toEqual([]); const messages = f.run.mock.calls[0][0]; - expect(messages.at(-1).content).toContain('推荐一个具体切入点'); + expect(messages.at(-1).content).toContain('学生主动求助,表示暂时说不清想问什么'); expect(messages[0]).toEqual(compileTeacherContext(definition, context, [], '问题', [], undefined, 'question', undefined, true).messages[0]); }); @@ -755,26 +777,27 @@ describe('teacher contextual discussion entry points', () => { expect(f.run).not.toHaveBeenCalled(); }); - it('budgets the round-specific instructions and honestly starts from ideas with no source', () => { + it('budgets the entry facts even when no source conversation is available', () => { const empty = { ...context, messages: [] }; const ordinary = compileTeacherContext(definition, empty, [], '老师,帮我看看', []); const suggestions = compileTeacherContext(definition, empty, [], '老师,帮我看看', [], 8000, 'suggestions'); expect(suggestions.messages[0]).toEqual(ordinary.messages[0]); - expect(suggestions.messages.at(-1)?.content).toContain('没有可用上下文时'); - expect(suggestions.messages.at(-1)?.content).toContain('不编造'); + expect(suggestions.messages.at(-1)?.content).toContain('学生通过“帮我看看”主动请求帮助'); + expect(suggestions.messages.some(message => message.content.startsWith('来源编程会话'))).toBe(false); expect(() => compileTeacherContext(definition, empty, [], '老师,帮我看看', [], estimateTeacherTokens(ordinary.messages), 'suggestions')) .toThrow('超过上下文预算'); }); - it.each(['suggestions', 'guided-help'] as const)('recommends one concrete starting point for %s without local expression policy', (intent) => { + it.each(['suggestions', 'guided-help'] as const)('passes %s entry facts without local teaching or expression policy', (intent) => { const selected = { ...definition, system_prompt: '云端配置:正文长度随内容,选项保留【我想聊】前缀。' }; const compiled = compileTeacherContext(selected, context, [], '帮我看看', [], 8000, intent, teacherReplyInstructions()); const current = compiled.messages.at(-1)!.content; - expect(current).toContain('推荐一个具体切入点'); - expect(current).toContain('快捷回复围绕这个切入点'); + expect(current).toContain(intent === 'suggestions' + ? '学生通过“帮我看看”主动请求帮助' : '学生主动求助,表示暂时说不清想问什么'); expect(compiled.messages[0].content).toContain(selected.system_prompt); const local = compiled.messages.slice(1).map(message => message.content).join('\n'); - for (const removed of ['400 字', '120 字', '2–3', '简短中文', '学生口吻', 'Alice', '组件', '先这些']) + for (const removed of ['400 字', '120 字', '2–3', '简短中文', '学生口吻', 'Alice', '组件', '先这些', + '推荐一个具体切入点', '说明为何', '快捷回复围绕', '从构思切入', '整理想法', '理解关系', '承接已确认的共识']) expect(local).not.toContain(removed); }); @@ -822,9 +845,8 @@ describe('project teacher check-ins', () => { expect(JSON.stringify(messages)).toContain('创建计数器'); expect(JSON.stringify(messages)).toContain('变量是什么意思'); expect(messages.at(-1)).toMatchObject({ role: 'system' }); - expect(messages.at(-1).content).toContain('表达方式遵循已配置的要求'); - expect(messages.at(-1).content).toContain('按照已配置的人设和职责'); - expect(messages.at(-1).content).toContain('不声称实际运行'); + expect(messages.at(-1).content).toContain('程序触发的项目进展检查,学生没有在本轮主动提问'); + expect(messages.at(-1).content).toContain('回应方式遵循当前智能体的云端配置'); expect(f.prepareModel).toHaveBeenLastCalledWith(expect.anything(), selected.definition, expect.objectContaining({ projectPath: f.created.project.path, source: context, assertCurrent: expect.any(Function), }), { finalOnly: false }); @@ -1289,6 +1311,55 @@ it.each(['agent', 'teacher'] as const)('routes project-level %s config and topic describe('structured teacher service integration', () => { + it.each(['discussion-v1', undefined] as const)('rotates a legacy %s cloud checkpoint within the same visible conversation', async (presentation) => { + const f = await fixture({ cloudTeacher: true, mockCloud: true }); + const agentId = 'cloud-teacher'; + const input = { projectId: f.scope.projectId, sourceConversationId: f.scope.sourceId, + requestId: crypto.randomUUID(), text: '看看数字如何改变' }; + const reply = '已确认的旧正文:数字会随着玩家操作改变。'; + const quickReplies = ['看看改变的时机']; + f.replyWith(JSON.stringify({ reply, quickReplies })); + await f.service.sendConversation(agentId, input); + f.finish(); + await vi.waitFor(async () => expect((await f.service.conversation(agentId)).topic?.requests[0].status).toBe('completed')); + const original = (await f.service.conversation(agentId)).topic!; + await f.service.dispose(); + + const turnFile = path.join(f.root, 'agent-conversations', original.accountId, agentId, 'turns', input.requestId + '.json'); + const archived = { ...original.requests[0], presentation, + discussionContext: { focusId: 'RETIRED_CHECKPOINT_CONTEXT' }, + discussionSnapshot: { kind: 'ideas', title: 'RETIRED_CHECKPOINT_COMPONENT' } }; + await writeFile(turnFile, JSON.stringify(archived)); + const service = await f.restart(); + await service.sendConversation(agentId, { ...input, requestId: crypto.randomUUID(), text: '继续看看' }); + const migrated = (await service.conversation(agentId)).topic!; + expect(migrated.id).toBe(original.id); + expect(migrated.conversation?.segmentTurns).toBe(1); + expect(migrated.requests[1]).toMatchObject({ projectId: input.projectId, + sourceConversationId: input.sourceConversationId, teacherVersion: 9, presentation: 'reply-v1' }); + expect(migrated.requests[1].runtimeThreadId).toBeTruthy(); + expect(migrated.requests[1].runtimeThreadId).not.toBe(original.requests[0].runtimeThreadId); + const bootstrap = JSON.stringify(f.run.mock.calls[1][0]); + expect(bootstrap).toContain(reply); + expect(bootstrap).toContain(quickReplies[0]); + expect(bootstrap).not.toContain('RETIRED_CHECKPOINT_'); + expect(f.run.mock.calls[1][0]).toContainEqual({ role: 'system', content: teacherReplyInstructions() }); + f.finish(); + await vi.waitFor(async () => expect((await service.conversation(agentId)).topic?.requests[1].status).toBe('completed')); + + await service.sendConversation(agentId, { ...input, requestId: crypto.randomUUID(), text: '再往下聊' }); + const continued = (await service.conversation(agentId)).topic!; + expect(continued.id).toBe(original.id); + expect(continued.conversation?.segmentTurns).toBe(2); + expect(continued.requests[2].runtimeThreadId).toBe(migrated.requests[1].runtimeThreadId); + expect(JSON.stringify(f.run.mock.calls[2][0])).not.toContain(reply); + expect(continued.requests[0]).toEqual(archived); + expect(JSON.parse(await readFile(turnFile, 'utf8'))).toEqual(JSON.parse(JSON.stringify(archived))); + f.finish(); + await vi.waitFor(async () => expect((await service.conversation(agentId)).topic?.requests[2].status).toBe('completed')); + expect((await service.conversation(agentId)).topic?.requests[2].usage).toEqual({ inputTokens: 20, outputTokens: 10 }); + }); + it('keeps continuous chat scopes, releases and usage while archived components remain inert', async () => { const f = await fixture({ cloudTeacher: true, mockCloud: true }); const agentId = 'cloud-teacher'; @@ -1551,7 +1622,7 @@ describe('project consultations with selected cloud teachers', () => { expect(result.topic?.id).toBe(topic.id); expect(result.topic?.requests[0]).toMatchObject({ intent: 'check-in', sourceConversationId: f.scope.sourceId }); const messages = f.run.mock.calls[0][0] as Array<{ role: string; content: string }>; - expect(messages.some(message => message.role === 'system' && message.content.includes('不是用户提问'))).toBe(true); + expect(messages.some(message => message.role === 'system' && message.content.includes('学生没有在本轮主动提问'))).toBe(true); expect(messages[0].content).not.toContain(TEACHER_BEHAVIOR_PROMPT); expect(f.prepareCloud.mock.calls[0][3]).toMatchObject({ projectPath: f.created.project.path, source: context }); expect(f.prepareModel).not.toHaveBeenCalled(); diff --git a/tests/unit/teacher-companion.test.tsx b/tests/unit/teacher-companion.test.tsx index 720534f5..31ec78b8 100644 --- a/tests/unit/teacher-companion.test.tsx +++ b/tests/unit/teacher-companion.test.tsx @@ -468,6 +468,73 @@ describe('teacher presence and periodic guidance', () => { expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); }); + it('consumes the static greeting when consultation opens through the avatar and remembers it across projects', async () => { + const greeting = '有什么编程问题想一起讨论?'; + api.config.mockResolvedValue({ enabled: true, definition: { ...definition, welcome_message: greeting } }); + const view = await mount(); + expect(screen.getByTestId('teacher-invitation')).toHaveAttribute('data-bubble-kind', 'welcome'); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true }))); + expect(within(screen.getByTestId('teacher-chat-panel')).getByText(greeting)).toBeVisible(); + expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true }))); + expect(screen.queryByTestId('teacher-chat-panel')).not.toBeInTheDocument(); + expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true }))); + expect(screen.getByTestId('teacher-chat-panel')).toBeVisible(); + expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); + view.unmount(); + const next = await mount(); + expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); + await act(async () => next.rerender()); + expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); + expect(api.create).not.toHaveBeenCalled(); + expect(api.send).not.toHaveBeenCalled(); + expect(api.checkIn).not.toHaveBeenCalled(); + }); + + it.each([false, true])('consumes the static greeting when consultation opens before config loads (closed before load: %s)', async (closeBeforeLoad) => { + const config = { enabled: true, definition: { ...definition, welcome_message: '欢迎一起创作' } }; + let resolve!: (value: typeof config) => void; + api.config.mockImplementationOnce(() => new Promise((done) => { resolve = done; })); + api.config.mockResolvedValue(config); + const view = await mount(); + expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true }))); + if (closeBeforeLoad) { + await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true }))); + } + await act(async () => resolve(config)); + expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); + if (!closeBeforeLoad) { + await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true }))); + } + expect(screen.queryByTestId('teacher-chat-panel')).not.toBeInTheDocument(); + expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); + view.unmount(); + await mount(); + expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); + expect(api.create).not.toHaveBeenCalled(); + expect(api.send).not.toHaveBeenCalled(); + expect(api.checkIn).not.toHaveBeenCalled(); + }); + + it('consumes only the static greeting on avatar entry and leaves a real unread check-in until explicitly viewed', async () => { + api.config.mockResolvedValue({ enabled: true, definition: { ...definition, welcome_message: '欢迎一起创作' } }); + api.list.mockResolvedValue({ items: [{ id: 'topic-project' }], lastSelectedTopicId: 'topic-project' }); + api.read.mockResolvedValue(checkInTopic()); + await mount(); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true }))); + expect(screen.getByTestId('teacher-invitation')).toHaveAttribute('data-bubble-kind', 'check-in'); + expect(screen.getByTestId('teacher-invitation')).toHaveTextContent('排行榜'); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '看看智能体说的' }))); + expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); + await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true }))); + expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument(); + expect(api.create).not.toHaveBeenCalled(); + expect(api.send).not.toHaveBeenCalled(); + expect(api.checkIn).not.toHaveBeenCalled(); + }); + it('keeps the elapsed interval when the Renderer reloads instead of starting another five-minute wait', async () => { const first = await mount(); await advance(4 * 60_000); diff --git a/tests/unit/teacher-conversation-panel.test.tsx b/tests/unit/teacher-conversation-panel.test.tsx index 0cf82b74..272cbf65 100644 --- a/tests/unit/teacher-conversation-panel.test.tsx +++ b/tests/unit/teacher-conversation-panel.test.tsx @@ -41,6 +41,37 @@ const props = { projectId: 'project-a', projectName: '天气', sourceId: 'source async function mount() { let view!: ReturnType; await act(async () => { view = render(); }); return view; } describe('continuous agent panel', () => { + it('retries incomplete prose only in its original project/source and keeps current drafts and one send identity', async () => { + const original = { ...request('broken', '现在项目是什么阶段?'), response: '它早就不是', replyIncomplete: true, + references: [{ kind: 'code' as const, text: '原项目代码' }] }; + api.conversation.mockResolvedValue({ topic: topic([original]), before: null }); + api.seenConversation.mockImplementation(async () => topic([original])); + let finish!: (value: TeacherTopic) => void; + api.sendConversation.mockImplementation(() => new Promise(resolve => { finish = resolve; })); + const view = await mount(); + expect(screen.getByRole('status')).toHaveTextContent('这次回复未能完整显示'); + expect(screen.queryByTestId('teacher-reply')).toBeNull(); + expect(api.sendConversation).not.toHaveBeenCalled(); + fireEvent.change(screen.getByRole('textbox'), { target: { value: '仍在编辑的草稿' } }); + view.rerender(); + expect(screen.getByRole('button', { name: '重新回答' })).toBeDisabled(); + view.rerender(); + expect(screen.getByRole('button', { name: '重新回答' })).toBeDisabled(); + view.rerender(); + const retry = screen.getByRole('button', { name: '重新回答' }); + fireEvent.click(retry); fireEvent.click(retry); + expect(api.sendConversation).toHaveBeenCalledTimes(1); + const sent = api.sendConversation.mock.calls[0][1]; + expect(sent).toEqual({ requestId: expect.any(String), projectId: 'project-a', sourceConversationId: 'source-a', + text: original.text, references: original.references }); + expect(sent.requestId).not.toBe(original.id); + fireEvent.change(screen.getByRole('textbox'), { target: { value: '等待时补充的草稿' } }); + await act(async () => finish({ ...topic([original, { ...request(sent.requestId), response: '完整回答' }]), revision: 2 })); + expect(screen.getByText('完整回答')).toBeVisible(); + expect(screen.getByRole('textbox')).toHaveValue('等待时补充的草稿'); + expect(JSON.parse(localStorage.getItem(`makelore-consultation-draft:kid-${identity.id}:agent:agent-a`)!)).toMatchObject({ text: '等待时补充的草稿' }); + }); + it('opens one chat without topic controls or creating a topic, and preserves it across project switches', async () => { const view = await mount(); expect(screen.queryByRole('button', { name: '智能体新话题' })).toBeNull(); diff --git a/tests/unit/teacher-conversation.test.ts b/tests/unit/teacher-conversation.test.ts index 6b5a25ee..82ed96b5 100644 --- a/tests/unit/teacher-conversation.test.ts +++ b/tests/unit/teacher-conversation.test.ts @@ -6,6 +6,7 @@ import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { TeacherConversationStore } from '../../electron/coding-teacher/conversation-store'; import { TeacherTopicStore } from '../../electron/coding-teacher/store'; +import { applyTeacherReply } from '../../electron/coding-teacher/reply'; import { CodingTeacherService } from '../../electron/coding-teacher/service'; import { CodingProjectService } from '../../electron/coding-projects/project-service'; import { createCodingProjectStore, createMemoryCodingProjectStorage } from '../../electron/coding-projects/project-store'; @@ -36,6 +37,55 @@ function turn(index: number): TeacherRequest { omittedMessages: 0, status: 'completed', response: '回答 ' + index }; } describe('continuous conversation persistence', () => { + it('repairs retained replies on every page without rewriting their files during restart recovery', async () => { + const dir = await root(); + const store = new TeacherConversationStore(dir, 'account', 'agent-a'); + const topic = await store.ensure(definition, 1); + const prose = '它早就不是"刚搭好架子"那种阶段了。\n完整的后半段。'; + const damaged = { ...turn(0), response: '它早就不是', discussionError: '旧解析失败', + unparsedResponse: '{"reply":"' + prose + '","quickReplies":[]}' }; + const incomplete = { ...turn(1), response: '它早就不是', discussionError: '旧解析失败', + unparsedResponse: '{"reply":"它早就不是"刚搭好架子' }; + const running = { ...turn(60), status: 'running' as const }; + topic.requests = [damaged, incomplete, ...Array.from({ length: 57 }, (_, i) => turn(i + 2)), running]; + await store.save(topic); + const file = (id: string) => path.join(dir, 'turns', id + '.json'); + const rawBefore = await readFile(file(damaged.id), 'utf8'); + // Simulate a pre-repair index, including an untrusted partial assistant excerpt. + const manifestFile = path.join(dir, 'conversation.json'); + const manifest = JSON.parse(await readFile(manifestFile, 'utf8')); + for (const item of manifest.turns) delete item.replyProjectionVersion; + manifest.turns[1].messages.push({ id: 'teacher:' + incomplete.id + ':assistant', role: 'assistant', text: '它早就不是' }); + await atomicWriteJson(manifestFile, manifest); + const resumed = new TeacherConversationStore(dir, 'account', 'agent-a'); + const latest = await resumed.page(); + expect(latest.requests.at(-1)?.status).toBe('interrupted'); + const archive = await resumed.history(); + expect(archive.messages.some(message => message.text === '它早就不是')).toBe(false); + expect(await archive.read('teacher:' + incomplete.id + ':assistant')).toBeUndefined(); + expect((await archive.read('teacher:' + damaged.id + ':assistant'))?.text).toBe(prose); + const older = await resumed.page(latest.before!); + expect(older.requests[0]).toMatchObject({ response: prose, unparsedResponse: damaged.unparsedResponse }); + expect(older.requests[1].replyIncomplete).toBe(true); + expect(await readFile(file(damaged.id), 'utf8')).toBe(rawBefore); + expect(JSON.parse(await readFile(file(incomplete.id), 'utf8'))).not.toHaveProperty('replyIncomplete'); + expect(JSON.parse(await readFile(file(running.id), 'utf8')).status).toBe('interrupted'); + }); + + it('keeps rejected new shortcuts hidden after reload while retaining historical cards', async () => { + const dir = await root(); + const store = new TeacherConversationStore(dir, 'account', 'agent-a'); + const topic = await store.ensure(definition, 1); + const current = turn(1); + const old = { ...turn(0), suggestedQuestions: ['一', '二', '三', '四'] }; + applyTeacherReply(current, JSON.stringify({ reply: '完整回答', quickReplies: old.suggestedQuestions })); + topic.requests = [old, current]; + await store.save(topic); + const loaded = await new TeacherConversationStore(dir, 'account', 'agent-a').read(); + expect(loaded.requests[0].suggestedQuestions).toEqual(old.suggestedQuestions); + expect(loaded.requests[1]).toMatchObject({ response: '完整回答', suggestedQuestions: [], replyQuickRepliesRejected: true, replyParseError: expect.any(String) }); + }); + it('pages thousands of turns, restores the latest page and preserves repeated questions', async () => { const dir = await root(); const store = new TeacherConversationStore(dir, 'account', 'agent-a'); diff --git a/tests/unit/teacher-guidance.test.ts b/tests/unit/teacher-guidance.test.ts index 7b1d0a73..2af80ab4 100644 --- a/tests/unit/teacher-guidance.test.ts +++ b/tests/unit/teacher-guidance.test.ts @@ -30,13 +30,14 @@ describe('published guidance and text reply wiring', () => { expect(system).toContain('启用的教学补充'); expect(system).not.toContain('不应加载的资料'); expect(compiled.messages.map(message => message.content).join('\n')) - .not.toMatch(/你是麦洛的创作老师|Alice|最多120字|不超过400字|2–3|学生自己的口吻/); + .not.toMatch(/你是麦洛的创作老师|Alice|最多120字|不超过400字|2–3|学生自己的口吻|推荐一个具体切入点|说明为何|快捷回复围绕|整理想法|理解关系|承接已确认的共识/); expect(definition).toEqual(before); expect(compiled.messages.at(-1)?.role).toBe(intent === 'check-in' ? 'system' : 'user'); if (instructions) expect(compiled.messages.at(-2)).toEqual({ role: 'system', content: instructions }); - if (intent === 'suggestions' || intent === 'guided-help') { - expect(compiled.messages.at(-1)?.content).toContain('推荐一个具体切入点'); - } + const entry = compiled.messages.at(-1)?.content; + if (intent === 'suggestions') expect(entry).toContain('学生通过“帮我看看”主动请求帮助'); + if (intent === 'guided-help') expect(entry).toContain('学生主动求助,表示暂时说不清想问什么'); + if (intent === 'check-in') expect(entry).toContain('程序触发的项目进展检查,学生没有在本轮主动提问'); } ); diff --git a/tests/unit/teacher-reply-history.test.ts b/tests/unit/teacher-reply-history.test.ts index 42f59047..bc623bfd 100644 --- a/tests/unit/teacher-reply-history.test.ts +++ b/tests/unit/teacher-reply-history.test.ts @@ -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'; @@ -112,6 +113,40 @@ async function fixture(value: TeacherTopic) { } describe('passive teacher reply history compatibility', () => { + it('retains all four historical shortcuts through read, list and a later save', async () => { + const original = topic(archivedCases[4]); + const historicalReplies = [ + '看懂规则|摸头会改变什么?', + '想想作用|我们聊聊摸头的作用。', + '试试结果|怎样知道变化发生了?', + '换个话题|我想先聊别的地方。', + ]; + original.requests[0].suggestedQuestions = historicalReplies; + const f = await fixture(original); + + const loaded = await f.store.read(original.id); + expect(loaded).toEqual(original); + expect(loaded.requests[0].suggestedQuestions).toEqual(historicalReplies); + expect((await f.store.list()).items).toEqual([{ + id: original.id, title: original.requests[0].text, updatedAt: original.updatedAt, + version: 31, teacherId: 'selected-agent', + }]); + expect(await new TeacherTopicStore(f.root).read(original.id)).toEqual(original); + await f.unchanged(); + + loaded.revision++; + loaded.requests.push(request({ id: 'new-turn', presentation: 'reply-v1', suggestedQuestions: [], + discussionError: undefined, unparsedResponse: undefined })); + await f.store.save(loaded); + const expected = JSON.parse(JSON.stringify({ ...original, + revision: original.revision + 1, requests: [...original.requests, loaded.requests[1]], + })); + const saved = JSON.parse(await readFile(f.file, 'utf8')); + expect(saved).toEqual(expected); + expect(saved.requests[0].suggestedQuestions).toEqual(historicalReplies); + expect(await new TeacherTopicStore(f.root).read(original.id)).toEqual(expected); + }); + it.each(archivedCases)('keeps $name opaque through read, list and a later save', async archive => { const original = topic(archive); const f = await fixture(original); @@ -206,3 +241,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); + }); +}); diff --git a/tests/unit/teacher-reply.test.ts b/tests/unit/teacher-reply.test.ts index d6bae637..59abf0ca 100644 --- a/tests/unit/teacher-reply.test.ts +++ b/tests/unit/teacher-reply.test.ts @@ -9,6 +9,13 @@ import { import { applyTeacherReply, teacherReplyInstructions } from '../../electron/coding-teacher/reply'; import type { TeacherRequest } from '../../shared/coding-teacher'; +const fourHistoricalReplies = [ + '看懂规则|摸头会改变什么?', + '想想作用|我们聊聊摸头的作用。', + '试试结果|怎样知道变化发生了?', + '换个话题|我想先聊别的地方。', +]; + describe('teacher text and suggestions transport', () => { const envelope = { reply: '🍎 **先看看重力带来的变化。**', quickReplies: ['继续解释', '比较不同选择'] }; @@ -66,6 +73,17 @@ describe('teacher text and suggestions transport', () => { .toEqual({ reply: '一个入口。', quickReplies: ['继续'] }); }); + it('keeps four historical shortcuts intact when decoding either envelope format', () => { + const reply = '这些是之前留下的讨论入口。'; + for (const historical of [ + { reply, quickReplies: fourHistoricalReplies }, + { intro: reply, questions: fourHistoricalReplies }, + ]) { + expect(parseTeacherReply(JSON.stringify(historical))) + .toEqual({ reply, quickReplies: fourHistoricalReplies }); + } + }); + it('preserves long ordinary and structured replies without the old 12000 character truncation', () => { const reply = '🍏 云端前缀\n\n' + '这是一段完整的详细解释。'.repeat(10000); expect(parseTeacherReply(reply)).toEqual({ reply, quickReplies: [] }); @@ -122,11 +140,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":"正文保留。"}}', @@ -195,10 +263,47 @@ describe('Main reply application', () => { expect(current.discussionSnapshot).toEqual({ kind: 'ideas', title: '历史记录' }); }); + it.each([ + { quickReplies: [] }, + { quickReplies: ['看懂规则|摸头会改变什么?'] }, + { quickReplies: ['看懂规则|摸头会改变什么?', '看懂规则|摸头会改变什么?', '换个话题|我想聊别的地方。'] }, + ])('applies zero to three new shortcuts without stripping prefixes or deduplicating: $quickReplies', ({ quickReplies }) => { + const current = request(); + current.replyParseError = '之前的格式错误'; + current.unparsedResponse = '之前的原始输出'; + applyTeacherReply(current, JSON.stringify({ reply: '先看看摸头的作用。', quickReplies })); + + expect(current.response).toBe('先看看摸头的作用。'); + expect(current.suggestedQuestions).toEqual(quickReplies); + expect(current.replyParseError).toBeUndefined(); + expect(current.unparsedResponse).toBeUndefined(); + }); + + it.each(['current', 'legacy'] as const)('rejects four newly generated shortcuts in a %s envelope without losing the reply or raw output', format => { + const current = request(); + current.suggestedQuestions = ['上一轮的旧选项']; + const reply = '保留这一轮的完整正文。'; + const envelope = format === 'current' + ? { reply, quickReplies: fourHistoricalReplies } + : { intro: reply, questions: fourHistoricalReplies }; + const raw = ' \n```json\n' + JSON.stringify(envelope, null, 2) + '\n```\n '; + expect(parseTeacherReply(raw)).toEqual({ reply, quickReplies: fourHistoricalReplies }); + + applyTeacherReply(current, raw); + + expect(current.response).toBe(reply); + expect(current.suggestedQuestions).toEqual([]); + expect(current.replyParseError).toBeTruthy(); + expect(current.unparsedResponse).toBe(raw); + expect(current.discussionSnapshot).toEqual({ kind: 'ideas', title: '历史记录' }); + }); + it('limits instructions to the text/suggestions shape and defers writing choices to cloud configuration', () => { const instructions = teacherReplyInstructions(); expect(instructions).toContain('{"reply":string,"quickReplies":string[]}'); expect(instructions).toContain('云端配置'); - expect(instructions).not.toMatch(/Alice|120|400|2–3|学生口吻|kind|tool|想法板|流程图/); + expect(instructions).toMatch(/最多\s*3\s*条|0[–-]3\s*条/); + expect(instructions).toContain('空数组'); + expect(instructions).not.toMatch(/Alice|120|400|2–3|学生口吻|kind|tool|想法板|流程图|整理想法|理解关系|承接|共识|已确认内容|待定想法/); }); });