Skip to content
59 changes: 36 additions & 23 deletions docs/PROJECT_STATUS.md

Large diffs are not rendered by default.

53 changes: 53 additions & 0 deletions frontend/src/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
callToolRun,
archiveSession,
cancelChatResearchRuns,
createNewsRun,
digestNewsRun,
discussNewsRun,
Expand Down Expand Up @@ -122,6 +123,31 @@ describe("sendChatStream", () => {
]);
});

it("delivers formal research progress before the chat session is ready", async () => {
const progress: Array<Record<string, unknown>> = [];
vi.stubGlobal(
"fetch",
vi.fn(async () =>
sseResponse([
'event: research\ndata: {"run_id":"research-1","status":"running","stage":"searching","provider_status":"","stop_reason":"","error":"","query_attempt_count":0,"selected_source_count":0,"version":1}\n\n',
'event: session\ndata: {"session_id":"session-research","turn_id":"turn-research"}\n\n',
'event: done\ndata: {"session_id":"session-research","turn_id":"turn-research","reply":"done"}\n\n'
])
)
);

await sendChatStream(
"research",
[],
{ ragEnabled: false, chatSettings, ragSettings, turnId: "turn-research" },
{ onResearch: (value) => progress.push(value) }
);

expect(progress).toEqual([
expect.objectContaining({ run_id: "research-1", stage: "searching", version: 1 })
]);
});

it("sends scene and conversation instruction in the chat payload", async () => {
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) =>
sseResponse([
Expand All @@ -140,6 +166,8 @@ describe("sendChatStream", () => {
keepCurrentRole: true,
previousMode: "苏格拉底",
conversationInstruction: "不要转交给其他角色。",
webContext: "recovered sources",
webContextRunId: "research-recovered-1",
scene: "single"
}
);
Expand All @@ -148,6 +176,8 @@ describe("sendChatStream", () => {
const body = JSON.parse(String(init.body));
expect(body.scene).toBe("single");
expect(body.conversation_instruction).toBe("不要转交给其他角色。");
expect(body.web_context).toBe("recovered sources");
expect(body.web_context_run_id).toBe("research-recovered-1");
expect(body.performance_mode).toBe("deep");
expect(body.previous_mode).toBe("苏格拉底");
expect(body.rag_min_score).toBe(0.42);
Expand Down Expand Up @@ -179,6 +209,29 @@ describe("sendChatStream", () => {
});
});

describe("cancelChatResearchRuns", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it("cancels durable research runs by the preallocated turn owner", async () => {
const fetchMock = vi.fn(async () =>
new Response(JSON.stringify({ runs: [] }), {
status: 200,
headers: { "Content-Type": "application/json" }
})
);
vi.stubGlobal("fetch", fetchMock);

await expect(cancelChatResearchRuns("turn / 1")).resolves.toEqual([]);

expect(fetchMock).toHaveBeenCalledWith(
"/research-runs/owners/turns/turn%20%2F%201/cancel",
expect.objectContaining({ method: "POST" })
);
});
});

describe("local knowledge tool calls", () => {
afterEach(() => {
vi.unstubAllGlobals();
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
ApiSnapshot,
ChatMessage,
ChatResponse,
ChatResearchProgress,
ChatSettings,
HealthResponse,
MemoryCommitResponse,
Expand Down Expand Up @@ -77,6 +78,7 @@ type ChatRequestOptions = {
ragSettings: RagSettings;
keepCurrentRole?: boolean;
webContext?: string;
webContextRunId?: string;
conversationInstruction?: string;
previousMode?: string;
scene?: "single" | "group";
Expand All @@ -90,6 +92,7 @@ type ChatStreamHandlers = {
onSession?: (sessionId: string, meta?: { turnId?: string; operationId?: string }) => void;
onRoute?: (route: Record<string, unknown>) => void;
onRag?: (rag: ChatResponse["rag"]) => void;
onResearch?: (progress: ChatResearchProgress) => void;
onToken?: (token: string) => void;
onUsage?: (usage: Record<string, unknown>) => void;
onDone?: (done: Record<string, unknown>) => void;
Expand Down Expand Up @@ -135,6 +138,7 @@ function buildChatPayload(userInput: string, history: ChatMessage[], options: Ch
rag_retrieval_mode: options.ragSettings.retrievalMode,
rag_min_score: options.ragSettings.minScore,
web_context: options.webContext ?? "",
web_context_run_id: options.webContextRunId ?? null,
task_intent: consumePendingTaskIntentOverride() ?? null,
continuation_of_turn_id: options.continuationOfTurnId ?? null,
retry_of_turn_id: options.retryOfTurnId ?? null,
Expand Down Expand Up @@ -700,6 +704,14 @@ export async function sendChat(
});
}

export async function cancelChatResearchRuns(turnId: string): Promise<WebLookupRunResponse[]> {
const response = await requestJson<{ runs: WebLookupRunResponse[] }>(
`/research-runs/owners/turns/${encodeURIComponent(turnId)}/cancel`,
{ method: "POST" }
);
return response.runs;
}

export async function sendChatStream(
userInput: string,
history: ChatMessage[],
Expand Down Expand Up @@ -768,6 +780,10 @@ export async function sendChatStream(
handlers.onRag?.(rag);
return;
}
if (message.event === "research") {
handlers.onResearch?.(message.data as ChatResearchProgress);
return;
}
if (message.event === "token") {
const text = typeof message.data.text === "string" ? message.data.text : "";
reply += text;
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/app/WorkspaceView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,14 @@ export function WorkspaceView({
openDrawer("memory");
}}
isEndingSession={memoryController.isPreviewing}
researchRun={webLookupController.result}
researchProgress={chatController.researchProgress}
isResearchBusy={webLookupController.isBusy}
canRetryResearch={webLookupController.canRetry}
canResumeResearch={webLookupController.canResume}
useResearchInChat={webLookupController.useInChat}
onRetryResearch={() => void webLookupController.retry()}
onResumeResearch={() => void webLookupController.resume()}
/>
</div>
<SlideOver open={state.activeDrawer === "sessions"} title="会话历史" onClose={closeDrawer}>
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/app/useWorkspaceControllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export function useWorkspaceControllers(options: {
conversationInstruction: options.conversationInstruction,
setConversationInstruction: options.setConversationInstruction,
webLookupSource: webLookupController.result?.source_block ?? "",
webLookupRunId: webLookupController.result?.run_id,
useWebLookup: webLookupController.useInChat,
webPolicy,
setUseWebLookup: webLookupController.setUseInChat,
Expand All @@ -176,6 +177,10 @@ export function useWorkspaceControllers(options: {
clearChatArtifacts:
workspaceCoordinator.clearChatArtifacts.bind(workspaceCoordinator),
refresh: options.refresh,
onResearchRunDiscovered: (runId, forceRefresh = false) => {
options.setRunId.webLookup(runId);
if (forceRefresh) void webLookupController.refreshRun(runId);
},
});
const activeQuery =
options.input.trim() || chatController.lastChat?.rag?.query || "";
Expand Down
118 changes: 112 additions & 6 deletions frontend/src/features/chat/chatController.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useChatController } from "./chatController";

const apiMocks = vi.hoisted(() => ({
archiveSession: vi.fn(),
cancelChatResearchRuns: vi.fn(),
commitTurn: vi.fn(),
createNewSession: vi.fn(),
loadSessionDetail: vi.fn(),
Expand All @@ -35,8 +36,16 @@ function setter<T>(): Dispatch<SetStateAction<T>> {
return vi.fn<(value: SetStateAction<T>) => void>();
}

function controllerHarness() {
function controllerHarness(
recoveredResearch: { source: string; runId: string; use: boolean } = {
source: "",
runId: "",
use: false,
},
) {
let controller: ReturnType<typeof useChatController> | undefined;
const onResearchRunDiscovered = vi.fn();
const setUseWebLookup = setter<boolean>();
function Harness() {
controller = useChatController({
chatSettings,
Expand All @@ -51,27 +60,36 @@ function controllerHarness() {
setKeepCurrentRole: setter<boolean>(),
conversationInstruction: "",
setConversationInstruction: setter<string>(),
webLookupSource: "",
useWebLookup: false,
setUseWebLookup: setter<boolean>(),
webLookupSource: recoveredResearch.source,
webLookupRunId: recoveredResearch.runId || undefined,
useWebLookup: recoveredResearch.use,
setUseWebLookup,
setInput: setter<string>(),
setOperationError: setter<string>(),
clearChatArtifacts: vi.fn(),
refresh: vi.fn().mockResolvedValue(undefined),
onResearchRunDiscovered,
});
return null;
}
return { Harness, getController: () => controller };
return {
Harness,
getController: () => controller,
onResearchRunDiscovered,
setUseWebLookup,
};
}

describe("useChatController stop behavior", () => {
beforeEach(() => {
operationRegistry.cancelAll();
vi.clearAllMocks();
apiMocks.cancelChatResearchRuns.mockResolvedValue([]);
});

it("preserves partial output, commits it, exposes recovery, and clears busy", async () => {
apiMocks.commitTurn.mockResolvedValue({ committed: true });
apiMocks.cancelChatResearchRuns.mockResolvedValue([{ id: "research-stop" }]);
apiMocks.sendChatStream.mockImplementation(
async (_question, _history, _options, callbacks, requestOptions) =>
new Promise((_resolve, reject) => {
Expand All @@ -80,14 +98,26 @@ describe("useChatController stop behavior", () => {
operationId: "op-stop",
});
callbacks.onRoute({ role: "nahida", mode: "normal", model_profile: "flash" });
callbacks.onRag({ web_tools: { run_id: "research-rag" } });
callbacks.onResearch({
run_id: "research-stop",
status: "running",
stage: "searching",
provider_status: "",
stop_reason: "",
error: "",
query_attempt_count: 0,
selected_source_count: 0,
version: 1,
});
callbacks.onToken("partial token");
requestOptions.signal.addEventListener("abort", () => {
reject(new DOMException("stopped", "AbortError"));
});
})
);

const { Harness, getController } = controllerHarness();
const { Harness, getController, onResearchRunDiscovered } = controllerHarness();
await act(async () => {
create(
<WorkspaceProvider initialState={{ activeChatThreadId: "chat-stop" }}>
Expand Down Expand Up @@ -133,9 +163,85 @@ describe("useChatController stop behavior", () => {
operationId: "op-stop",
})
);
expect(apiMocks.cancelChatResearchRuns).toHaveBeenCalledWith("turn-stop");
await vi.waitFor(() => {
expect(onResearchRunDiscovered).toHaveBeenCalledWith("research-rag");
expect(onResearchRunDiscovered).toHaveBeenCalledWith("research-stop", true);
expect(getController()!.researchProgress).toBeNull();
});
expect(operationRegistry.size).toBe(0);
});

it("consumes one recovered ResearchRun and keeps its id in turn evidence", async () => {
const recoveredRag = {
status: "found",
query: "recovered",
retrieval_mode: "hybrid",
reason: "",
context: "",
sources: "",
result_count: 0,
results: [],
debug: {},
attempts: [],
rewritten_query: "",
web_context: {
used: true,
run_id: "research-recovered-1",
source: "research_run",
},
};
apiMocks.sendChatStream.mockImplementation(
async (_question, _history, _options, callbacks) => {
callbacks.onSession("chat-recovered", { turnId: "turn-recovered" });
callbacks.onRoute({ role: "nahida" });
callbacks.onRag(recoveredRag);
callbacks.onDone({
session_id: "chat-recovered",
turn_id: "turn-recovered",
reply: "answer from recovered sources",
});
return {
reply: "answer from recovered sources",
session_id: "chat-recovered",
turn_id: "turn-recovered",
route: { role: "nahida" },
rag: recoveredRag,
};
},
);

const { Harness, getController, setUseWebLookup } = controllerHarness({
source: "RECOVERED SOURCE BLOCK",
runId: "research-recovered-1",
use: true,
});
await act(async () => {
create(
<WorkspaceProvider initialState={{ activeChatThreadId: "chat-recovered" }}>
<Harness />
</WorkspaceProvider>,
);
});

await act(async () => {
await getController()!.send("use the recovered evidence");
});

expect(apiMocks.sendChatStream.mock.calls[0]?.[2]).toEqual(
expect.objectContaining({
webContext: "RECOVERED SOURCE BLOCK",
webContextRunId: "research-recovered-1",
}),
);
expect(setUseWebLookup).toHaveBeenCalledWith(false);
const messages = getController()!.messages;
const assistant = messages[messages.length - 1];
expect(assistant?.evidence?.rag?.web_context?.run_id).toBe(
"research-recovered-1",
);
});

it("restores committed learning state instead of interrupted attempted state", async () => {
apiMocks.loadSessionDetail.mockResolvedValue({
session_id: "chat-restore",
Expand Down
Loading
Loading