Selaa lähdekoodia

docs: fix 4 review issues in context management plan

- Task 3: Revert fallback object to {provider, modelId} only; adding id/api
  would trick resolveModel into using a fake Model with wrong api field
- Task 1: Import beforeEach/afterEach in test file; consolidate describe block
- Task 5: Add explicit initialState modification with concrete code
- Task 7: Reduce to E2E verification only; remove vague grep instructions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fanghanjun 6 kuukautta sitten
vanhempi
commit
e2b913e910
1 muutettua tiedostoa jossa 86 lisäystä ja 73 poistoa
  1. 86 73
      docs/superpowers/plans/2026-04-17-agent-context-management-plan.md

+ 86 - 73
docs/superpowers/plans/2026-04-17-agent-context-management-plan.md

@@ -20,12 +20,26 @@
 
 ```ts
 // packages/core/src/__tests__/context-transform.test.ts
-import { describe, it, expect } from "vitest";
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
 import { createBookContextTransform } from "../agent/context-transform.js";
 
 describe("createBookContextTransform", () => {
+  let projectRoot: string;
+  const bookId = "test-book";
+
+  beforeEach(async () => {
+    projectRoot = await mkdtemp(join(tmpdir(), "ctx-test-"));
+  });
+
+  afterEach(async () => {
+    await rm(projectRoot, { recursive: true, force: true });
+  });
+
   it("returns messages unchanged when bookId is null", async () => {
-    const transform = createBookContextTransform(null, "/tmp/test-project");
+    const transform = createBookContextTransform(null, projectRoot);
     const messages = [
       { role: "user" as const, content: "hello", timestamp: Date.now() },
     ];
@@ -69,21 +83,13 @@ pnpm test -- packages/core/src/__tests__/context-transform.test.ts
 
 Expected: PASS
 
-- [ ] **Step 5: 写测试 — bookId 存在时读取真相文件并注入 user message**
+- [ ] **Step 5: 追加测试 — bookId 存在时读取真相文件并注入 user message**
 
-需要在 `/tmp` 下创建临时文件结构来模拟 `books/{bookId}/story/` 目录。
+在 Step 1 创建的 `describe` 块内部(`it("returns messages unchanged when bookId is null", ...)` 之后),追加以下两个测试。不要新开 `describe` 块。所需的 `beforeEach/afterEach/import` 已经在 Step 1 里写好了,只需在已有 `beforeEach` 末尾加上创建真相文件的代码:
 
-```ts
-import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
-import { join } from "node:path";
-import { tmpdir } from "node:os";
-
-describe("createBookContextTransform", () => {
-  // ... 上面的 null 测试 ...
-
-  let projectRoot: string;
-  const bookId = "test-book";
+首先把 Step 1 的 `beforeEach` 扩展为创建真相文件:
 
+```ts
   beforeEach(async () => {
     projectRoot = await mkdtemp(join(tmpdir(), "ctx-test-"));
     const storyDir = join(projectRoot, "books", bookId, "story");
@@ -91,11 +97,11 @@ describe("createBookContextTransform", () => {
     await writeFile(join(storyDir, "story_bible.md"), "# Story Bible\nA hero's journey.");
     await writeFile(join(storyDir, "current_focus.md"), "Focus on chapter 3.");
   });
+```
 
-  afterEach(async () => {
-    await rm(projectRoot, { recursive: true, force: true });
-  });
+然后在 `describe` 块内追加两个测试:
 
+```ts
   it("prepends a user message with truth file contents", async () => {
     const transform = createBookContextTransform(bookId, projectRoot);
     const original = [
@@ -141,7 +147,6 @@ describe("createBookContextTransform", () => {
     expect(rulesIdx).toBeLessThan(focusIdx);
     expect(focusIdx).toBeLessThan(extraIdx);
   });
-});
 ```
 
 - [ ] **Step 6: 跑测试确认失败**
@@ -240,7 +245,9 @@ pnpm test -- packages/core/src/__tests__/context-transform.test.ts
 
 Expected: PASS
 
-- [ ] **Step 9: 写测试 — story/ 目录不存在时返回原始 messages**
+- [ ] **Step 9: 追加测试 — story/ 目录不存在时返回原始 messages**
+
+在同一个 `describe` 块内追加:
 
 ```ts
   it("returns original messages when story/ directory does not exist", async () => {
@@ -377,37 +384,19 @@ import { getModel } from "@mariozechner/pi-ai";
           const fallbackModelId = config.llm.model;
           try {
             const piModel = getModel(fallbackProvider as any, fallbackModelId as any);
-            resolvedModel = piModel ?? {
-              id: fallbackModelId,
-              name: fallbackModelId,
-              api: "openai-completions",
-              provider: fallbackProvider,
-              baseUrl: "",
-              reasoning: false,
-              input: ["text"],
-              cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
-              contextWindow: 0,
-              maxTokens: 16384,
-            } as any;
+            resolvedModel = piModel ?? { provider: fallbackProvider, modelId: fallbackModelId } as any;
           } catch {
-            resolvedModel = {
-              id: fallbackModelId,
-              name: fallbackModelId,
-              api: "openai-completions",
-              provider: fallbackProvider,
-              baseUrl: "",
-              reasoning: false,
-              input: ["text"],
-              cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
-              contextWindow: 0,
-              maxTokens: 16384,
-            } as any;
+            resolvedModel = { provider: fallbackProvider, modelId: fallbackModelId } as any;
           }
         }
         resolvedApiKey = client._apiKey;
       }
 ```
 
+**重要说明:**
+
+不要给 fallback 对象加 `id` / `api` 字段。`agent-session.ts:95` 的 `resolveModel` 会检查 `"id" in spec && "api" in spec`:有这两个字段就当作完整 Model 直接用;没有的话会走 `getModel(provider, modelId)` 查询。fallback 对象只给 `provider` 和 `modelId` 保证查询路径被触发,如果 pi-ai 注册表查得到就拿到带正确 `contextWindow` 的完整 Model,查不到时 contextWindow 为 undefined,Task 4 的 `ratio` 会自动为 `null`,前端不显示比例。
+
 - [ ] **Step 3: 跑全量测试**
 
 ```bash
@@ -476,6 +465,7 @@ git commit -m "feat(studio): broadcast context:usage SSE event after each LLM ca
 **Files:**
 - Modify: `packages/studio/src/hooks/use-sse.ts`
 - Modify: `packages/studio/src/store/chat/types.ts`
+- Modify: `packages/studio/src/store/chat/slices/message/initialState.ts`
 - Modify: `packages/studio/src/store/chat/slices/message/action.ts`
 
 - [ ] **Step 1: 在 SSE 事件列表中增加 `context:usage`**
@@ -502,7 +492,40 @@ git commit -m "feat(studio): broadcast context:usage SSE event after each LLM ca
   setContextUsageRatio: (ratio: number | null) => void;
 ```
 
-- [ ] **Step 3: 在 message action slice 中实现**
+- [ ] **Step 3: 在初始 state 中加入默认值**
+
+`packages/studio/src/store/chat/slices/message/initialState.ts` 当前是:
+
+```ts
+import type { MessageState } from "../../types";
+
+export const initialMessageState: MessageState = {
+  sessions: {},
+  sessionIdsByBook: {},
+  activeSessionId: null,
+  input: "",
+  selectedModel: null,
+  selectedService: null,
+};
+```
+
+改为:
+
+```ts
+import type { MessageState } from "../../types";
+
+export const initialMessageState: MessageState = {
+  sessions: {},
+  sessionIdsByBook: {},
+  activeSessionId: null,
+  input: "",
+  selectedModel: null,
+  selectedService: null,
+  contextUsageRatio: null,
+};
+```
+
+- [ ] **Step 4: 在 message action slice 中实现**
 
 在 `packages/studio/src/store/chat/slices/message/action.ts` 的 `createMessageSlice` 返回对象中添加:
 
@@ -510,7 +533,7 @@ git commit -m "feat(studio): broadcast context:usage SSE event after each LLM ca
   setContextUsageRatio: (ratio) => set({ contextUsageRatio: ratio }),
 ```
 
-- [ ] **Step 4: 在 `sendMessage` 中监听 `context:usage` 事件**
+- [ ] **Step 5: 在 `sendMessage` 中监听 `context:usage` 事件**
 
 在 `sendMessage` 函数里,现有的 `streamEs.addEventListener("llm:progress", ...)` 之后添加:
 
@@ -527,7 +550,7 @@ git commit -m "feat(studio): broadcast context:usage SSE event after each LLM ca
     });
 ```
 
-- [ ] **Step 5: 跑全量测试**
+- [ ] **Step 6: 跑全量测试**
 
 ```bash
 pnpm test
@@ -535,10 +558,10 @@ pnpm test
 
 Expected: 全部 PASS
 
-- [ ] **Step 6: 提交**
+- [ ] **Step 7: 提交**
 
 ```bash
-git add packages/studio/src/hooks/use-sse.ts packages/studio/src/store/chat/types.ts packages/studio/src/store/chat/slices/message/action.ts
+git add packages/studio/src/hooks/use-sse.ts packages/studio/src/store/chat/types.ts packages/studio/src/store/chat/slices/message/initialState.ts packages/studio/src/store/chat/slices/message/action.ts
 git commit -m "feat(studio): receive context:usage SSE event in chat store"
 ```
 
@@ -624,30 +647,23 @@ git commit -m "feat(studio): add context usage ratio indicator in chat input are
 
 ---
 
-### Task 7: 初始化 store 默认值 + 全量验证
-
-**Files:**
-- Modify: `packages/studio/src/store/chat/store.ts`(或 store 初始化所在文件)
+### Task 7: 端到端验证
 
-- [ ] **Step 1: 确认 store 初始状态包含 `contextUsageRatio: null`**
-
-找到 chat store 的 create 调用,确认 `contextUsageRatio` 的初始值为 `null`。如果 store 使用 slice 组合模式,可能需要在 slice 的默认状态里加上。
-
-查找方式:
+- [ ] **Step 1: 跑全量测试确认无回归**
 
 ```bash
-grep -r "createMessageSlice\|MessageState\|contextUsageRatio" packages/studio/src/store/chat/
+pnpm test
 ```
 
-在 store 初始化处确保 `contextUsageRatio: null` 在默认状态中。
+Expected: 全部 PASS
 
-- [ ] **Step 2: 跑全量测试**
+- [ ] **Step 2: 类型检查**
 
 ```bash
-pnpm test
+pnpm build
 ```
 
-Expected: 全部 PASS
+Expected: 无 TypeScript 编译错误。
 
 - [ ] **Step 3: 手动端到端验证**
 
@@ -655,17 +671,14 @@ Expected: 全部 PASS
 pnpm dev
 ```
 
-验证清单:
-1. 打开 Studio,选一个已有书籍的 session
+打开 Studio(http://localhost:5173 或你配置的端口),按以下清单验证:
+
+1. 选一个已有书籍的 session
 2. 发一条消息(如"写下一章")
-3. 确认 agent 回复后,输入框底栏出现 context 比例指示器
+3. 等 agent 回复完成后,确认输入框底栏的 model picker 右边出现 context 比例指示器
 4. 比例值应该大于 0%(因为注入了真相文件)
-5. 如果模型 contextWindow 为 0,比例条不显示
-6. 建书模式下(无 bookId),比例条要么不显示(context 较小),要么显示正常比例
+5. 颜色:< 60% 绿、60%-85% 黄、> 85% 红
+6. 切换到另一个使用 pi-ai 注册表里不存在的模型(比如自定义服务商)时,比例条应不显示(因为 contextWindow 为 0)
+7. 建书模式(无 bookId)下发消息,比例条正常显示(不过比例会比带书籍时低,因为没注入真相文件)
 
-- [ ] **Step 4: 提交(如有改动)**
-
-```bash
-git add -A
-git commit -m "feat(studio): initialize contextUsageRatio in chat store"
-```
+如果发现问题,回到对应 task 修复,不要在此 task 里改。