diff --git a/.project-docs/30-worklog/tasks/20260922-teacher-unavailable-6d8fa721.md b/.project-docs/30-worklog/tasks/20260922-teacher-unavailable-6d8fa721.md index 2985fca8..aa7c54ea 100644 --- a/.project-docs/30-worklog/tasks/20260922-teacher-unavailable-6d8fa721.md +++ b/.project-docs/30-worklog/tasks/20260922-teacher-unavailable-6d8fa721.md @@ -12,18 +12,22 @@ ## Scope -- Explain the screenshot message 老师暂未开放 by tracing its exact UI condition and cloud configuration path. Product code and live Operations state remain read-only. +- Explain the screenshot message 老师暂未开放 by tracing its exact UI condition and cloud configuration path. Follow-up now covers the reported reasoning error and a bounded client fix; live Operations state remains read-only. ## Intent And Constraints - Project Context Loaded: official check/start/status passed with Identity above, feature mode, isolated managed worktree and base e5d271bc457b4e91e6bda52a28db4a1ecf91d406. 123 owner records read; 17 peer placeholder scopes remain unknown, no concrete conflicting dependency. Packaging 1.6.2 is independent and product-read-only. - Read entry/planning-gate, own record, teacher ADR and integrated state; reuse unchanged memory-index, template positioning, decisions, system/module/data-flow/domain, success criteria and evidence/reflection/commitment/stale context already loaded during preceding integration. Project goal remains Electron/Main-owned coding with independently configured official teacher. -- Active constraints: no subagents, no code/configuration/production writes, no paid calls, no access to credentials or unrelated peer work. Accepted teacher ADR separates publication from enablement; deployment and live billing are not established by merge. +- Active constraints: no subagents, no live configuration/production writes, no paid calls, no access to credentials or unrelated peer work. Accepted teacher ADR separates publication from enablement; deployment and live billing are not established by merge. - Planning Gate Passed. Relevant modules: TeacherChatPanel, teacher config client/service and CodingChatPanel mount lifecycle. Current live server enabled status is unknown; screenshot establishes only the last loaded client state. - diagnosing-bugs applied as bounded symptom-condition inspection. This is a behavior question, not an established defect or requested fix; skip reproduction, hypothesis ranking and regression mutation because the exact single UI condition resolves what the message means. Do not claim a live backend root cause without its response. ## Outcome +- Confirmed reasoning error root cause: the selected installed-app topic is pinned to published v1 with mode=enabled and effort=null. Server Pydantic intentionally persists nullable effort; client request validation previously formed effort:null and rejected it as an unavailable native strength. User-reported current Operations disabled selection does not change that existing topic. +- Corrected TeacherDefinition's wire type to represent nullable effort, and normalized null/missing enabled effort to the canonical default-strength choice before existing capability validation. Explicit strengths and unsupported-choice rejection remain unchanged; no fallback model, topic migration or capability override. +- Immediate installed-version path: save and publish the intended disabled setting as a new version, then create a new teacher topic using +. Reopening the same topic retains its frozen v1. Did not mutate the user's topic or live Operations settings. + - TeacherChatPanel.tsx:65-68 reads config.enabled; :336-337 renders this exact message only when enabled is false (initial value is true). :383 disables sending under the same condition. - Main definition() forwards /api/coding-teacher/config availability and reads the published definition independently of enabled; restored local topic history may also supply name/avatar. Seeing 编程老师1 does not establish current enablement. - Production createTopic rejects disabled or unpublished status; each send rechecks live availability. Permission/config request failures produce different errors. @@ -32,13 +36,22 @@ ## Verification +- Reasoning fix: pnpm exec vitest run tests/unit/coding-teacher-model.test.ts --maxWorkers=1 reproduced the exact user error in prepareTeacherModel for the local topic shape enabled/effort=null: 1 failed, 6 passed before the fix. The real server schema separately emitted JSON null for unspecified effort. +- After fix: teacher model/teacher service/managed model capability suites => 25 passed; pnpm run typecheck, scoped ESLint and pnpm run build:vite passed. Cases cover null/missing default strength, disabled, model default, explicit native strength, and rejection of unsupported strength/disabling. Tests run the real model preparation and native request construction with synthetic transport; no external model charge. + - Read-only exact-path searches and source reads at e5d271b establish setter, message predicate, send disabling, status forwarding and remount behavior. No runtime test executed or production state changed. - Inspected own diff; only this task record is changed. Task-aware documentation drift is checked before completion. ## Follow-ups -- If enabling and reopening does not clear the message, inspect the actual authenticated config response and which server the client connects to. No credentials or server response were acquired in this task. +- Fix is committed on this feature branch; merge and rebuild/install are still required to update the running app. Live upstream capabilities and an actual model response have not been verified. Existing teacher topics remain pinned to their creation version by design. ## Promotion Candidates -- None recorded. +- None. This correction brings the client wire boundary into agreement with the existing server schema and accepted default-strength semantics; it does not change canonical teacher behavior. + +## Reasoning error follow-up + +- Same-task official resume/status Passed at unchanged e5d271b base; re-read all 123 peer records, 17 undefined peer scopes remain unknown, no semantic conflict. Required context and teacher ADR unchanged; Planning Gate Passed. +- User reports deepseek-flash / disabled in current Operations (not independently fetched live). Read only selected project registration and filtered teacher/provider metadata, without outputting credentials or conversation text: actual local topic v1 persists enabled with effort=null, created 2026-09-22T06:14:37.190Z, zero requests. Local saved model capability supports disabling and deepseek controls; not a fresh server capability response. +- Server contract task verifies ReasoningChoice.model_dump(mode="json") includes effort=null. Plan: reproduce prepareTeacherModel with that real wire shape, normalize the teacher wire DTO at model-request preparation, retain capability validation, and verify enabled/default/disabled/native/unsupported cases. No topic-version migration or live configuration mutation. diff --git a/electron/coding-teacher/model-runner.ts b/electron/coding-teacher/model-runner.ts index d8531a31..5fcb364c 100644 --- a/electron/coding-teacher/model-runner.ts +++ b/electron/coding-teacher/model-runner.ts @@ -39,11 +39,16 @@ export async function prepareTeacherModel(account: TeacherAccount, definition: T '老师所用模型暂不可用,请联系运营调整。' ); } + const savedChoice = definition.model.reasoning_choice; + // Published definitions serialize an unspecified effort as null. + const choice = savedChoice.mode === 'enabled' + ? { mode: savedChoice.mode, ...(savedChoice.effort == null ? {} : { effort: savedChoice.effort }) } + : { mode: savedChoice.mode }; let fields: Record; try { fields = buildManagedModelRequest( modelId, - definition.model.reasoning_choice, + choice, capability ).reasoningFields; } catch { diff --git a/shared/coding-teacher.ts b/shared/coding-teacher.ts index 0218b3e0..0b1877a9 100644 --- a/shared/coding-teacher.ts +++ b/shared/coding-teacher.ts @@ -1,4 +1,3 @@ -import type { ManagedReasoningChoice } from './managed-model-capabilities'; import type { PublicUsage } from './coding-conversation-contracts'; export interface TeacherDefinition { @@ -17,7 +16,12 @@ export interface TeacherDefinition { instructions_markdown: string; enabled: boolean; }>; - model: { model_id: string | null; reasoning_choice: ManagedReasoningChoice }; + model: { + model_id: string | null; + reasoning_choice: + | { mode: 'default' | 'disabled'; effort?: null } + | { mode: 'enabled'; effort?: string | null }; + }; limits: { max_input_tokens: number; max_output_tokens: number }; } export interface TeacherAvailability { diff --git a/tests/unit/coding-teacher-model.test.ts b/tests/unit/coding-teacher-model.test.ts new file mode 100644 index 00000000..e18952fa --- /dev/null +++ b/tests/unit/coding-teacher-model.test.ts @@ -0,0 +1,79 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as cloud from '../../electron/coding-teacher/config-client'; +import * as transport from '../../electron/utils/proxy-fetch'; +import { prepareTeacherModel } from '../../electron/coding-teacher/model-runner'; +import type { TeacherDefinition } from '../../shared/coding-teacher'; + +const account: cloud.TeacherAccount = { + id: '11111111-1111-4111-8111-111111111111', + binding: { accountKey: 'teacher-test', epoch: 1 }, +}; + +// JSON round trip reproduces the cloud/durable-topic boundary, including Pydantic nulls. +function definition(choice: unknown): TeacherDefinition { + return JSON.parse(JSON.stringify({ + schema_version: 1, teacher_id: 'coding-teacher', name: 'Teacher', + description: '', avatar_id: 'avatar-01', welcome_message: '', + suggested_questions: [], system_prompt: 'Explain code.', skills: [], + model: { model_id: 'deepseek-flash', reasoning_choice: choice }, + limits: { max_input_tokens: 8000, max_output_tokens: 1500 }, + })); +} + +function setup(canDisable = true) { + vi.spyOn(cloud, 'teacherCloudRequest').mockResolvedValue({ + api_key: 'synthetic-key', base_url: 'https://teacher-model.invalid/v1', + models: ['deepseek-flash'], + model_capabilities_v2: { + schema_version: 2, models: { + 'deepseek-flash': { + input_modalities: ['text', 'image'], output_modalities: ['text'], + reasoning: { + supported: true, can_disable: canDisable, default_enabled: true, + effort_values: ['low', 'high', 'max'], default_effort: 'high', + control_format: 'deepseek', + }, + }, + }, + }, + }); + vi.spyOn(cloud, 'assertTeacherAccount').mockReturnValue(undefined); + return vi.spyOn(transport, 'proxyAwareFetch').mockResolvedValue(new Response( + 'data: {"choices":[{"delta":{"content":"Explanation"},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n', + { headers: { 'content-type': 'text/event-stream' } }, + )); +} + +afterEach(() => vi.restoreAllMocks()); + +describe('teacher published reasoning wire contract', () => { + it.each([ + [{ mode: 'enabled', effort: null }, { thinking: { type: 'enabled' } }], + [{ mode: 'enabled' }, { thinking: { type: 'enabled' } }], + [{ mode: 'disabled', effort: null }, { thinking: { type: 'disabled' } }], + [{ mode: 'default', effort: null }, {}], + [{ mode: 'enabled', effort: 'high' }, { thinking: { type: 'enabled' }, reasoning_effort: 'high' }], + ])('prepares saved choice %j and sends its native controls', async (choice, fields) => { + const fetch = setup(); + const prepared = await prepareTeacherModel(account, definition(choice)); + const onText = vi.fn(); + await prepared.run([{ role: 'user', content: 'Explain this.' }], new AbortController().signal, onText); + expect(onText).toHaveBeenCalledWith('Explanation'); + const body = JSON.parse(String(fetch.mock.calls[0][1]?.body)); + expect({ + ...(body.thinking === undefined ? {} : { thinking: body.thinking }), + ...(body.reasoning_effort === undefined ? {} : { reasoning_effort: body.reasoning_effort }), + }).toEqual(fields); + }); + + it.each([ + [{ mode: 'enabled', effort: 'unsupported' }, true], + [{ mode: 'disabled', effort: null }, false], + ])('keeps rejecting unsupported choice %j', async (choice, canDisable) => { + const fetch = setup(Boolean(canDisable)); + await expect(prepareTeacherModel(account, definition(choice))) + .rejects.toThrow('老师所用思考选项已不可用,请联系运营调整。'); + expect(fetch).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file