Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions docs/PROJECT_STATUS.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# Study Agent 当前状态

> **唯一进度入口**
> 更新:2026-07-31
> 更新:2026-08-03
> 产品定义:**Study Agent 是长期保持“正在学什么、已经确认什么、还不会什么、下一步是什么”的个人学习工作台。**
> 当前主线:**仅检查和改进 Study Agent 本体的代码功能、核心学习流程与桌面/移动端体验。**
> 冻结边界:**Provider replay 扩展、生产 claim producer / claim UI、生产 ChatTurn 接入、群聊能力扩张、新闻产品化与可执行 agent 均不是当前开发主线。**
> 当前切片:**P1-R1 联网研究阶段解释已实现,PR #96 的远程完整 CI 已通过;PR 仍为 Draft,等待用户决定是否合并。**

本文件只维护当前事实、可复核证据、缺口和执行顺序。不得新增并列长期 STATUS / ROADMAP / NEXT_PHASE / AUDIT 文档。

Expand Down Expand Up @@ -82,6 +83,16 @@ PR #93 修复 ResearchRun 两阶段取消在前端没有收口的问题:
1. 静态 owner 测试只读取旧控制器入口,迁移为稳定 re-export 后需要同时审查入口与实现文件;
2. 新增 real-stack 文件最初被普通 fixture Playwright 项目误收录,最终加入统一 `REAL_STACK_TESTS` 排除清单,由专用真实全栈配置运行。

### 4.3 P1-R1 联网研究阶段解释(远程 CI 已通过)

当前活动分支 `codex/p1-r1-research-stage-explanations` 收口“工程状态不可理解”的一处核心语义:

- `partial` ResearchRun 保留查询、来源与 checkpoint,但不再自动作为下一轮聊天资料;学习者可显式选用,或重试补全;
- 聊天恢复卡与群聊研究面板明确说明部分结果、已保留的查询/来源和重试语义;
- Windows 真实全栈重置在释放缓存连接后再删除 SQLite 文件,desktop 与 390×844 可连续执行;
- 已通过前端 222 项单元测试、TypeScript/Vite production build,以及 ResearchRun 取消—刷新—同 run 重试的 desktop 与 390×844 真实全栈旅程;
- PR #96 的两项 GitHub Actions CI 已通过,覆盖全量 pytest、RAG K1、Ruff、打包、detect-secrets、mypy、前端构建与两类浏览器门禁;该 PR 仍为 Draft,未经用户确认不得合并。

## 5. 当前质量门禁

CI #1731 完整通过:
Expand Down Expand Up @@ -133,7 +144,7 @@ CI #1731 完整通过:
**P1:核心功能与体验**

1. 继续逐批检查首次学习、返回学习、上传资料、理解验证和学习结束的真实交互细节,优先修阻断和高频困惑;
2. 扩展联网研究在多查询、读取失败、部分结果和重试过程中的阶段解释,避免只显示工程状态
2. 根据用户决定将 PR #96 继续保持 Draft、转 Ready 或合并;合并前不得扩大 P1-R1 范围
3. 对实体安卓/iOS 输入法、软键盘安全区、触摸惯性和返回键做实机抽样;
4. 加强源码学习的 symbol mapping、CI association precision 与 partial-result 用户解释。

Expand Down
24 changes: 24 additions & 0 deletions frontend/src/features/web-lookup/ChatResearchRecovery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,30 @@ describe("ChatResearchRecovery", () => {
expect(onRetry).toHaveBeenCalledOnce();
});

it("keeps partial findings out of chat until the learner explicitly uses them", () => {
const { container } = render(
<ChatResearchRecovery
run={{
...run("failed"),
status: "partial",
stage: "completed",
selected_sources: [{ item: { title: "official docs" } }],
error: "",
}}
isBusy={false}
canRetry
canResume={false}
useInChat={false}
onRetry={vi.fn()}
onResume={vi.fn()}
/>,
);

expect(container).toHaveTextContent("不会自动用于下一轮聊天");
expect(container).toHaveTextContent("1 个来源");
expect(container).toHaveTextContent("研究得到部分可用结果");
});

it("does not expose standalone research as chat recovery", () => {
const { container } = render(
<ChatResearchRecovery
Expand Down
18 changes: 16 additions & 2 deletions frontend/src/features/web-lookup/ChatResearchRecovery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,19 @@ const stageLabels: Record<string, string> = {
cancelled: "研究已停止",
};

function runDetail(run: ResearchLookupResponse): string {
if (run.status === "partial") {
return "部分结果不会自动用于下一轮聊天;你可以重试以补全研究";
}
if (run.status === "failed") {
return `${run.error || "本次研究未完成"};重试会从已保存的进度继续`;
}
if (run.status === "cancelled") {
return "已停止本次研究;需要时可从已保存的进度重试";
}
return run.stop_reason || stageLabels[run.stage] || run.stage;
}

export function ChatResearchRecovery({
run,
progress = null,
Expand Down Expand Up @@ -48,12 +61,13 @@ export function ChatResearchRecovery({
if (run?.research_context.run_kind !== "chat_tool_loop") return null;
const recovered = run.status === "completed" && run.provider_status === "found";
if (!canRetry && !canResume && !isBusy && !(recovered && useInChat)) return null;
const detail = run.error || run.stop_reason || stageLabels[run.stage] || run.stage;
const detail = runDetail(run);
const heading = run.status === "partial" ? "研究得到部分可用结果" : stageLabels[run.stage] ?? "联网研究可恢复";

return (
<div className={`memory-note ${recovered ? "" : "warn"}`} role="status">
<div>
<strong>{recovered ? "联网研究已恢复" : stageLabels[run.stage] ?? "联网研究可恢复"}</strong>
<strong>{recovered ? "联网研究已恢复" : heading}</strong>
<span>
{recovered && useInChat
? "恢复结果已设为下一轮聊天资料。"
Expand Down
25 changes: 25 additions & 0 deletions frontend/src/features/web-lookup/webLookupController.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,31 @@ describe("useWebLookupController", () => {
expect(apiMocks.createResearchRun).not.toHaveBeenCalled();
});

it("does not automatically use partial research in chat", async () => {
apiMocks.createResearchRun.mockResolvedValue(
runPayload({ status: "pending", stage: "planned", news_items: [], source_block: "" }),
);
apiMocks.executeResearchRun.mockResolvedValue(
runPayload({ status: "partial", stage: "completed", provider_status: "partial" }),
);

const { result } = renderHook(() =>
useWebLookupController({
query: "Python docs",
setOperationError: vi.fn(),
setActiveRunId: vi.fn(),
}),
);

await act(async () => {
await result.current.lookup();
});

expect(result.current.result?.status).toBe("partial");
expect(result.current.useInChat).toBe(false);
expect(result.current.canRetry).toBe(true);
});

it("sends server cancellation before invalidating the browser request", async () => {
apiMocks.loadResearchRun.mockResolvedValue(
runPayload({ status: "running", stage: "reading", active_operation_id: "op_1" }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ type WebLookupControllerOptions = {
};

function isUsable(response: ResearchLookupResponse): boolean {
return ["completed", "partial"].includes(response.status) && response.news_items.length > 0;
return response.status === "completed" && response.news_items.length > 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Exclude provider-partial runs from automatic chat use

When research retains usable items but any provider or source read fails, WebLookupService sets provider_status to partial, while WebLookupRepository.complete() still sets status to completed; therefore this predicate remains true and silently enables those partial findings for the next chat turn. Conversely, the backend's status === "partial" represents provider_status === "insufficient" and normally has no items, so the new test exercises a payload the production completion path does not produce. Check provider_status here (and in the new partial-result UI branches) so actual partially successful research requires explicit learner confirmation.

Useful? React with 👍 / 👎.

}

function isRetryable(response: ResearchLookupResponse | null): boolean {
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/features/wechat-workspace/WechatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ function researchStateText(value: ResearchLookupResponse): string {
if (value.status === "failed") {
return value.error || "研究服务暂时不可用,可重试。";
}
if (value.status === "partial") {
return `已保留 ${selected} 个来源和 ${attempts} 次查询。部分结果不会自动用于下一轮聊天;可手动确认或重试补全。`;
}
if (value.provider_status === "empty") {
return `当前来源未返回结果;这不代表目标不存在。已记录 ${attempts} 次查询。`;
}
Expand Down Expand Up @@ -328,7 +331,7 @@ export function WechatPanel({
{webLookup ? (
<div className="news-result lookup-result">
<div className="memory-preview-meta">
<strong>{stageLabels[webLookup.stage] ?? webLookup.stage}</strong>
<strong>{webLookup.status === "partial" ? "研究得到部分可用结果" : stageLabels[webLookup.stage] ?? webLookup.stage}</strong>
<span>{researchStateText(webLookup)}</span>
</div>
<label className="toggle-row">
Expand Down
4 changes: 4 additions & 0 deletions tools/real_stack_test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from __future__ import annotations

import asyncio
import gc
import os
import shutil
from dataclasses import asdict
Expand Down Expand Up @@ -329,6 +330,9 @@ def _real_stack_closure_service() -> LearningClosureService:


def _remove_runtime_database() -> None:
# Cached repositories hold SQLite connections. Releasing them before
# unlinking is required on Windows, where an open connection locks the file.
gc.collect()
database_path = runtime_database_path()
for suffix in ("", "-wal", "-shm"):
Path(f"{database_path}{suffix}").unlink(missing_ok=True)
Expand Down
Loading