diff --git a/README.md b/README.md index 11696958ba..8ceaeebcf6 100644 --- a/README.md +++ b/README.md @@ -2679,7 +2679,7 @@ Native subagent hard timeouts publish `TIMED_OUT` at the execution deadline whil Durable `batch_task` workers use one app-owned plugin snapshot for tool assembly and execution. Recovered tasks adopt the new worker's plugin snapshot after a Gateway restart; plugin objects are never stored in durable task records. -Ordinary `task` delegation and explicit durable `batch_task` execution share the startup-scoped `subagent_runtime` process capacity. Batch mode keeps large independent item sets in SQL with separate total, live, and running limits, restart recovery, bounded results, and a thread-scoped Web UI panel. The panel pages through bounded previews on demand; bulk stored results are available through the owner-scoped JSONL export, while internal execution and authorization context never enters owner-facing responses. If the batch worker is later stopped or disabled, threads with persisted batches retain read-only item inspection and JSONL export; execution controls remain disabled until the worker is running again. See `config.example.yaml` and [the implementation contract](docs/plans/2026-08-24-subagent-batch-capacity-implementation.md) for limits and recovery semantics. +Ordinary `task` delegation and explicit durable `batch_task` execution share the startup-scoped `subagent_runtime` process capacity. Batch mode keeps large independent item sets in SQL with separate total, live, and running limits, restart recovery, bounded results, and a thread-scoped Web UI panel. The panel pages through bounded previews on demand and opens a selected item’s stored report and captured evidence for review. Citations open the matching saved excerpt without contacting the provider, and an open result refreshes until the item finishes and when the item list observes a later retry. Closed result dialogs do not fetch full details. Bulk stored results remain available through the owner-scoped JSONL export, while internal execution and authorization context never enters owner-facing responses. If the batch worker is later stopped or disabled, threads with persisted batches retain read-only item inspection and JSONL export; execution controls remain disabled until the worker is running again. See `config.example.yaml` and [the implementation contract](docs/plans/2026-08-24-subagent-batch-capacity-implementation.md) for limits and recovery semantics. While durable batch tools are available, a later owner turn can explicitly inspect selected results with `read_batch_result(batch_id, position=0, offset=0, max_chars=4000)`. The reader is restricted to the current user and thread, including in explicitly bound SDK graphs. It returns one window of a JSON document containing the stored report, execution/error state and separate acceptance criteria/verdict, without starting or waiting for work. Concatenate `content` windows before parsing: pass `next_offset` and the returned `revision` as `expected_revision` until `next_offset` is null. A changed item returns `restart_required`; discard prior windows and start at zero. Use `next_position` for a selected next item within `batch_status.total_items`. The maximum window is 8,192 characters; metadata shares the window budget, and JSON response framing adds overhead. `result_truncated` means storage already capped the report; continuation cannot recover discarded content. A succeeded item is not an accepted task, and unchecked criteria remain UNVERIFIED. No automatic result ingestion, waiting loop or UI change is added. @@ -2687,7 +2687,9 @@ The complete escaped response also fits within 10,000 characters and the bound h Offline acceptance: `cd backend && PYTHONPATH=. uv run --locked pytest tests/test_batch_result_reader.py -k 'real_submission_worker or sdk_factory_graph' -q`. This exercises real submission, deterministic worker completion, database reopen and registered graph consumption; external model execution is replaced by a fake model/executor. -Durable knowledge-research results also export nullable `result_artifact.knowledge_sources` snapshots. Each retained source pairs a citation ID in the stored report with the exact excerpt captured by `knowledge_search` or a delegated `task`, plus its RAGFlow dataset/document/chunk locator and pages. The existing authenticated `GET /api/threads/{thread_id}/subagent-batches/{batch_id}/results.jsonl` remains the entry point: find a report's `#knowledge-` link in that row's `sources` by `id` to inspect the evidence after a Gateway restart or provider change. Compact item queries and the batch panel do not include these snapshots; this does not add clickable citations to batch previews. +Durable knowledge-research results also export nullable `result_artifact.knowledge_sources` snapshots. Each retained source pairs a citation ID in the stored report with the exact excerpt captured by `knowledge_search` or a delegated `task`, plus its RAGFlow dataset/document/chunk locator and pages. The existing authenticated `GET /api/threads/{thread_id}/subagent-batches/{batch_id}/results.jsonl` remains the entry point: find a report's `#knowledge-` link in that row's `sources` by `id` to inspect the evidence after a Gateway restart or provider change. Compact item queries do not include these snapshots; clickable citations are available in the selected result dialog, not batch previews. + +Browser review keeps at most 100 sources, with 16,000 characters per excerpt and bounded identifiers/names. Sources excluded by these read limits increase the displayed omission count; the persisted snapshot and JSONL export retain their original contents. Snapshots preserve whole excerpts, not freshly fetched provider content. They inherit the 100-source/1,000,000-text-character forwarding limits; the serialized snapshot has a separate character budget equal to `subagent_batches.max_result_chars`, in addition to the report's existing budget. `knowledge_sources.omitted_count` reports referenced records omitted by forwarding, snapshot projection checks or snapshot budgeting; malformed or unsupported producer artifacts filtered before candidate selection are not counted. Report truncation limits selection to complete citations in the stored text. Old/plain results have no snapshot (`null`); no historical evidence is reconstructed. Gateway startup adds a nullable JSON column through migration `0033_batch_result_artifact`. Back up the database before migrating; downgrading drops evidence snapshots while preserving report text. Cancelled, failed and stale attempts do not publish evidence, and explicit retries clear previous snapshots. diff --git a/backend/app/gateway/routers/subagent_batches.py b/backend/app/gateway/routers/subagent_batches.py index 4cb575f557..350953d82e 100644 --- a/backend/app/gateway/routers/subagent_batches.py +++ b/backend/app/gateway/routers/subagent_batches.py @@ -5,7 +5,7 @@ from __future__ import annotations import json from collections.abc import AsyncIterator -from fastapi import APIRouter, HTTPException, Query, Request +from fastapi import APIRouter, HTTPException, Path, Query, Request from fastapi.responses import StreamingResponse from app.gateway.authz import require_permission @@ -70,6 +70,26 @@ async def list_batch_items( return await repo.list_items(batch_id, user_id=user_id, offset=offset, limit=limit, status=status) or [] +@router.get("/{batch_id}/items/{position}") +@require_permission("threads", "read", owner_check=True) +async def get_batch_item( + thread_id: ThreadId, + batch_id: str, + request: Request, + position: int = Path(..., ge=0), +) -> dict: + _repo, user_id, _batch = await _owned_batch(request, thread_id, batch_id) + item = await get_subagent_batch_service(request).read_browser_batch_item( + batch_id=batch_id, + user_id=user_id, + thread_id=thread_id, + position=position, + ) + if item is None: + raise HTTPException(status_code=404, detail="Subagent batch item not found") + return item + + @router.post("/{batch_id}/pause") @require_permission("threads", "write", owner_check=True) async def pause_batch(thread_id: ThreadId, batch_id: str, request: Request) -> dict: diff --git a/backend/packages/harness/deerflow/subagents/AGENTS.md b/backend/packages/harness/deerflow/subagents/AGENTS.md index 0ae8662390..6ba9bd3945 100644 --- a/backend/packages/harness/deerflow/subagents/AGENTS.md +++ b/backend/packages/harness/deerflow/subagents/AGENTS.md @@ -23,11 +23,13 @@ Durable batch specs store overlays as JSON and restore them before execution. `read_batch_item` projects only report/state/acceptance fields after owner and current-thread checks. Positions are immutable submission order; no status filtering. The bounded tool hashes that projection for continuation, detecting completion/retry/cancel changes. It does not persist snapshots, schedule work or expose execution specs; HTTP bulk export stays independent. Batch evidence uses nullable `result_artifact` (migration 0033), persisted with -successful results under the same lease fence. Only full-result exports expose -bounded, cited RAG snapshots; compact `list_items` defers them. Cancelled, -failed and stale attempts cannot publish evidence; retry clears it. Cover the -formatter/step capture -> worker -> reopened SQLite -> authorized export chain -in `tests/test_batch_rag_evidence.py`; no provider fetch or preview UI is added. +successful results under the same lease fence. Full-result exports and selected +browser item reads expose bounded, cited RAG snapshots; compact `list_items` +defers them. Browser reads recheck owner/thread, whitelist fields and bound legacy +IDs/text without provider fetches or a running worker. Count read-time size/source +limit omissions without changing stored evidence. Cancelled, failed and stale +attempts cannot publish evidence; retry clears it. Cover the formatter/step capture +-> worker -> reopened SQLite -> authorized export chain in `tests/test_batch_rag_evidence.py`. Explicitly enabled nested tasks propagate report-bound omission IDs; filter and deduplicate at each forwarding boundary before publishing the durable count. diff --git a/backend/packages/harness/deerflow/subagents/batch_service.py b/backend/packages/harness/deerflow/subagents/batch_service.py index bc50902e3f..d2e4b30f45 100644 --- a/backend/packages/harness/deerflow/subagents/batch_service.py +++ b/backend/packages/harness/deerflow/subagents/batch_service.py @@ -32,6 +32,62 @@ from deerflow.utils.assembly_io import run_assembly logger = logging.getLogger(__name__) +_BROWSER_EVIDENCE_FIELDS = ( + "id", + "provider", + "dataset_id", + "document_id", + "chunk_id", + "dataset_name", + "document_name", + "text", +) + + +def _browser_evidence_snapshot(artifact: object) -> dict[str, Any] | None: + """Project one persisted evidence artifact into the browser contract. + + Durable batch artifacts are already bounded at write time, but this + projection also protects older or manually repaired rows from exposing + arbitrary JSON fields such as provider credentials or execution metadata. + """ + if not isinstance(artifact, dict): + return None + payload = artifact.get("knowledge_sources") + if not isinstance(payload, dict) or payload.get("version") != 1: + return None + raw_sources = payload.get("sources") + if not isinstance(raw_sources, list): + return None + sources: list[dict[str, Any]] = [] + read_omissions = max(0, len(raw_sources) - 100) + for source in raw_sources[:100]: + if not isinstance(source, dict) or source.get("provider") != "ragflow": + continue + if not all(isinstance(source.get(field), str) for field in _BROWSER_EVIDENCE_FIELDS): + continue + if any(len(source[field]) > 512 for field in ("dataset_name", "document_name")): + read_omissions += 1 + continue + if any(len(source[field]) > 256 for field in ("id", "dataset_id", "document_id", "chunk_id")): + read_omissions += 1 + continue + # Keep legacy or manually repaired rows bounded at read time too; + # write-time limits do not protect this endpoint from old oversized text. + if len(source["text"]) > 16_000: + read_omissions += 1 + continue + record = {field: source[field] for field in _BROWSER_EVIDENCE_FIELDS} + pages = source.get("pages") + record["pages"] = sorted({page for page in pages[:100] if isinstance(page, int) and not isinstance(page, bool) and 1 <= page <= 1_000_000}) if isinstance(pages, list) else [] + record["truncated"] = source.get("truncated") is True + sources.append(record) + omitted_count = payload.get("omitted_count", 0) + if not isinstance(omitted_count, int) or isinstance(omitted_count, bool) or omitted_count < 0: + omitted_count = 0 + return {"version": 1, "sources": sources, "omitted_count": omitted_count + read_omissions} + + # Strong references to scheduled registry releases: an event loop keeps only # weak references to its tasks. _pending_releases: set[Future[None]] = set() @@ -271,6 +327,54 @@ class SubagentBatchService: fields = ("id", "item_key", "position", "status", "attempt", "result", "result_truncated", "error", "stop_reason", "acceptance_criteria", "acceptance_verdict") return {key: items[0].get(key) for key in fields} + async def read_browser_batch_item( + self, + *, + batch_id: str, + user_id: str, + thread_id: str, + position: int, + ) -> dict[str, Any] | None: + """Read a fixed, owner-scoped projection for browser result review. + + This path intentionally reads the stored report and evidence snapshot + only. It never invokes a provider, exposes the original prompt or + execution specification, or depends on the batch worker being alive. + """ + if position < 0: + return None + batch = await self._repository.get_batch(batch_id, user_id=user_id) + if batch is None or batch["thread_id"] != thread_id: + return None + items = await self._repository.list_items( + batch_id, + user_id=user_id, + offset=position, + limit=1, + include_result=True, + ) + if not items or items[0].get("position") != position: + return None + item = items[0] + return { + "id": item.get("id"), + "item_key": item.get("item_key"), + "position": item.get("position"), + "status": item.get("status"), + "attempt": item.get("attempt"), + "model_name": item.get("model_name"), + "report": item.get("result"), + "report_truncated": item.get("result_truncated") is True, + "error": item.get("error"), + "stop_reason": item.get("stop_reason"), + "acceptance_criteria": item.get("acceptance_criteria"), + "acceptance_verdict": item.get("acceptance_verdict"), + "evidence": _browser_evidence_snapshot(item.get("result_artifact")), + "started_at": item.get("started_at"), + "completed_at": item.get("completed_at"), + "updated_at": item.get("updated_at"), + } + async def cancel_batch( self, *, diff --git a/backend/tests/test_subagent_batch_service.py b/backend/tests/test_subagent_batch_service.py index e70ec6bec7..bf4073179e 100644 --- a/backend/tests/test_subagent_batch_service.py +++ b/backend/tests/test_subagent_batch_service.py @@ -60,6 +60,169 @@ def _request(**overrides) -> BatchSubmitRequest: return BatchSubmitRequest(**values) +@pytest.mark.asyncio +async def test_read_browser_batch_item_projects_report_and_captured_evidence() -> None: + repository = SimpleNamespace( + get_batch=AsyncMock(return_value={"id": "batch-1", "thread_id": "thread-1"}), + list_items=AsyncMock( + return_value=[ + { + "id": "item-1", + "item_key": "record-1", + "position": 0, + "status": "succeeded", + "attempt": 1, + "model_name": "model-a", + "result": "A report", + "result_truncated": False, + "error": None, + "stop_reason": None, + "acceptance_criteria": ["file:answer.md exists"], + "acceptance_verdict": {"status": "passed"}, + "result_artifact": { + "knowledge_sources": { + "version": 1, + "sources": [ + { + "id": "a" * 32 + "-1", + "provider": "ragflow", + "dataset_id": "dataset-1", + "document_id": "document-1", + "chunk_id": "chunk-1", + "dataset_name": "Private KB", + "document_name": "report.md", + "text": "Captured excerpt", + "pages": [2, True, 0, 3], + "truncated": True, + "secret": "must not escape", + }, + {"provider": "other", "text": "drop me"}, + ], + "omitted_count": 1, + } + }, + "started_at": "2026-10-17T00:00:00Z", + "completed_at": "2026-10-17T00:01:00Z", + "updated_at": "2026-10-17T00:01:00Z", + } + ] + ), + ) + service = SubagentBatchService( + repository=repository, + config=SubagentBatchesConfig(), + runtime_config=SubagentRuntimeConfig(), + ) + + detail = await service.read_browser_batch_item( + batch_id="batch-1", + user_id="user-1", + thread_id="thread-1", + position=0, + ) + + assert detail["report"] == "A report" + assert detail["acceptance_verdict"] == {"status": "passed"} + assert detail["evidence"] == { + "version": 1, + "sources": [ + { + "id": "a" * 32 + "-1", + "provider": "ragflow", + "dataset_id": "dataset-1", + "document_id": "document-1", + "chunk_id": "chunk-1", + "dataset_name": "Private KB", + "document_name": "report.md", + "text": "Captured excerpt", + "pages": [2, 3], + "truncated": True, + } + ], + "omitted_count": 1, + } + assert "secret" not in detail["evidence"]["sources"][0] + repository.list_items.assert_awaited_once_with( + "batch-1", + user_id="user-1", + offset=0, + limit=1, + include_result=True, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("field,limit", [("id", 256), ("dataset_id", 256), ("document_id", 256), ("chunk_id", 256), ("dataset_name", 512), ("document_name", 512), ("text", 16_000)]) +@pytest.mark.parametrize("oversized", [False, True]) +async def test_browser_evidence_bounds_legacy_rows(field, limit, oversized): + source = { + "id": "a" * 32 + "-1", + "provider": "ragflow", + "dataset_id": "dataset-1", + "document_id": "document-1", + "chunk_id": "chunk-1", + "dataset_name": "Private KB", + "document_name": "report.md", + "text": "Captured excerpt", + "pages": [2], + "truncated": False, + } + source[field] = "x" * (limit + int(oversized)) + repository = SimpleNamespace( + get_batch=AsyncMock(return_value={"id": "batch-1", "thread_id": "thread-1"}), + list_items=AsyncMock( + return_value=[ + { + "position": 0, + "result": "Stored report", + "prompt": "Private prompt", + "execution_spec": {"api_key": "private-key"}, + "result_artifact": {"knowledge_sources": {"version": 1, "sources": [source], "omitted_count": 2}}, + } + ] + ), + ) + service = SubagentBatchService(repository=repository, config=SubagentBatchesConfig(), runtime_config=SubagentRuntimeConfig()) + detail = await service.read_browser_batch_item(batch_id="batch-1", user_id="user-1", thread_id="thread-1", position=0) + assert detail["report"] == "Stored report" + assert detail["evidence"]["sources"] == ([] if oversized else [source]) + assert detail["evidence"]["omitted_count"] == 2 + int(oversized) + assert "prompt" not in detail + assert "execution_spec" not in detail + + +def test_browser_evidence_counts_valid_captured_excerpt_filtered_at_read(): + from langchain_core.messages import ToolMessage + + from deerflow.community.ragflow.formatting import format_retrieval_sources + from deerflow.community.ragflow.sources import durable_source_artifact + from deerflow.subagents.step_events import capture_step_message + + content, artifact = format_retrieval_sources( + {"chunks": [{"id": "chunk", "dataset_id": "kb", "document_id": "doc", "content": "x" * 17_000}]}, + dataset_names_by_id={"kb": "Knowledge base"}, + max_chars_per_chunk=20_000, + max_total_chars=30_000, + ) + captured = [] + capture_step_message(ToolMessage(content=content, artifact=artifact, name="knowledge_search", tool_call_id="search", id="message"), captured, set()) + source_id = artifact["knowledge_sources"]["sources"][0]["id"] + persisted = durable_source_artifact(captured, f"Claim [citation:1](#knowledge-{source_id})", max_chars=100_000) + assert len(persisted["knowledge_sources"]["sources"][0]["text"]) == 17_000 + assert persisted["knowledge_sources"]["omitted_count"] == 0 + assert service_module._browser_evidence_snapshot(persisted) == {"version": 1, "sources": [], "omitted_count": 1} + + +@pytest.mark.parametrize("persisted_omissions", [0, 3]) +def test_browser_evidence_source_cap_preserves_omissions(persisted_omissions): + source = {"id": "a" * 32 + "-1", "provider": "ragflow", "dataset_id": "kb", "document_id": "doc", "chunk_id": "chunk", "dataset_name": "KB", "document_name": "Manual", "text": "Excerpt", "pages": [], "truncated": False} + artifact = {"knowledge_sources": {"version": 1, "sources": [source] * 103, "omitted_count": persisted_omissions}} + projected = service_module._browser_evidence_snapshot(artifact) + assert len(projected["sources"]) == 100 + assert projected["omitted_count"] == persisted_omissions + 3 + assert artifact["knowledge_sources"]["omitted_count"] == persisted_omissions + + @pytest.mark.asyncio async def test_submit_keeps_batch_running_limit_separate_from_one_process_capacity() -> None: repository = SimpleNamespace(create_batch=AsyncMock(return_value={"id": "batch-1"})) diff --git a/backend/tests/test_subagent_batches_router.py b/backend/tests/test_subagent_batches_router.py index 39eb797a43..8298e3feda 100644 --- a/backend/tests/test_subagent_batches_router.py +++ b/backend/tests/test_subagent_batches_router.py @@ -72,6 +72,7 @@ def test_gateway_mounts_subagent_batch_routes() -> None: paths = {route.path for route in create_app().routes} assert "/api/threads/{thread_id}/subagent-batches" in paths assert "/api/threads/{thread_id}/subagent-batches/{batch_id}/items" in paths + assert "/api/threads/{thread_id}/subagent-batches/{batch_id}/items/{position}" in paths assert "/api/threads/{thread_id}/subagent-batches/{batch_id}/results.jsonl" in paths @@ -136,6 +137,45 @@ async def test_item_list_route_never_reads_full_results(monkeypatch, tmp_path) - assert not {"prompt", "result", "result_artifact"} & item_columns +@pytest.mark.asyncio +async def test_selected_item_route_returns_service_projection_and_scopes_thread(monkeypatch) -> None: + repo = Repository() + service = AsyncMock() + service.read_browser_batch_item.return_value = { + "id": "item-1", + "position": 0, + "status": "succeeded", + "report": "full report", + "evidence": None, + } + monkeypatch.setattr(subagent_batches, "get_current_user", AsyncMock(return_value="user-1")) + + result = await subagent_batches.get_batch_item.__wrapped__( + thread_id="thread-1", + batch_id="batch-1", + position=0, + request=_request(repo, service=service), + ) + + assert result["report"] == "full report" + service.read_browser_batch_item.assert_awaited_once_with( + batch_id="batch-1", + user_id="user-1", + thread_id="thread-1", + position=0, + ) + + service.read_browser_batch_item.return_value = None + with pytest.raises(HTTPException) as missing: + await subagent_batches.get_batch_item.__wrapped__( + thread_id="thread-1", + batch_id="batch-1", + position=1, + request=_request(repo, service=service), + ) + assert missing.value.status_code == 404 + + @pytest.mark.asyncio async def test_cancel_requires_running_worker_and_exact_owner(monkeypatch) -> None: repo = Repository() diff --git a/frontend/src/AGENTS.md b/frontend/src/AGENTS.md index 954779e2f0..9a198a3a63 100644 --- a/frontend/src/AGENTS.md +++ b/frontend/src/AGENTS.md @@ -89,19 +89,19 @@ Tests: `html-assets.dom.test.ts`, `artifact-file-preview.dom.test.tsx`, bounded error and attempt count; retryable failures use backend backoff, while a permanent rejection or exhausted five-attempt budget is shown as stopped rather than implying that retries will continue. - Explicit durable native-subagent batches use `core/subagent-batches` and - `ThreadSubagentBatches`. `/api/features` reports SQL-repository availability - separately from the startup worker. A running worker exposes the trigger on - both default and Custom Agent chat pages; a stopped worker keeps threads with - durable history visible in read-only mode for inspection and JSONL export, - while deployments with neither a worker nor history keep the trigger hidden. - The panel renders bounded progress and incrementally paged item previews, controls pause/resume/cancel, - retries failed items, and exports JSONL. Worker-dependent mutations stay - disabled in read-only history mode, and persisted progress is normalized to - a bounded percentage before reaching the UI primitive. Item pagination uses a - fixed page size and an explicit load-more control; full results remain available - only through JSONL export. The panel must not infer batch mode from prompt text - or inject the complete result set into chat state. + `core/subagent-batches` / `ThreadSubagentBatches` own durable native batches. + `/api/features` separates SQL availability from startup worker state. + Show the trigger on default/Custom Agent chats with a running worker, or + read-only with durable history; hide otherwise. Bound persisted progress + percentages. Use fixed item-preview pages and load-more; full results need + JSONL export. Gate pause/resume/cancel and failed-item retries on the worker. + Never infer batches from prompts or put full results in chat state. + Review citations use only the selected persisted snapshot. Poll open details + until their own response is terminal, regardless of compact-list status; + stop on close. Compact attempt/status/update changes invalidate cached details + so external retries refresh open dialogs; never fetch closed details. + Show omitted counts even without sources; localize page labels. + Real-hook/HTTP tests: `thread-subagent-batch-review.dom.test.tsx`. Capability Center > Plugins > Lark uses a local generation only to suppress stale React callbacks; server-issued Lark flow generations must be passed through every config/auth completion and across switch-or-register to authorization chains diff --git a/frontend/src/components/workspace/thread-subagent-batches.tsx b/frontend/src/components/workspace/thread-subagent-batches.tsx index 9c573a67f5..2c9e8ba317 100644 --- a/frontend/src/components/workspace/thread-subagent-batches.tsx +++ b/frontend/src/components/workspace/thread-subagent-batches.tsx @@ -1,19 +1,28 @@ "use client"; +import type { Message } from "@langchain/langgraph-sdk"; import { ArchiveIcon, CirclePauseIcon, CirclePlayIcon, CircleStopIcon, DownloadIcon, + EyeIcon, Layers3Icon, LoaderCircleIcon, RotateCcwIcon, } from "lucide-react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Progress } from "@/components/ui/progress"; import { Sheet, @@ -23,15 +32,23 @@ import { SheetTitle, SheetTrigger, } from "@/components/ui/sheet"; +import { CitationLink } from "@/components/workspace/citations/citation-link"; +import { KnowledgeSourcesProvider } from "@/components/workspace/citations/knowledge-source"; import { useSubagentBatchesCapability } from "@/core/features"; import { useI18n } from "@/core/i18n/hooks"; +import { + SafeStreamdown, + toStreamdownComponents, +} from "@/core/streamdown/components"; import { completedSubagentBatchItems, isActiveSubagentBatch, subagentBatchProgress, subagentBatchResultsUrl, type SubagentBatch, + type SubagentBatchItemDetail, type SubagentBatchItem, + useSubagentBatchItemDetail, useControlSubagentBatch, useRetrySubagentBatchItem, useSubagentBatchItems, @@ -282,6 +299,8 @@ function BatchItems({
{(query.data?.pages.flat() ?? []).map((item) => ( void; }) { const { t } = useI18n(); + const [detailOpen, setDetailOpen] = useState(false); + const detail = useSubagentBatchItemDetail(threadId, batchId, item.position, { + enabled: detailOpen, + polling: detailOpen, + revision: JSON.stringify([item.attempt, item.status, item.updated_at]), + }); return (
@@ -336,23 +365,138 @@ function BatchItemRow({ {item.error && (

{item.error}

)} - {item.status === "failed" && ( +
- )} + {item.status === "failed" && ( + + )} +
+ + + + {t.subagentBatches.resultTitle} + + {t.subagentBatches.resultDescription} + + + {detail.isLoading ? ( +
+ + {t.common.loading} +
+ ) : detail.isError ? ( +
+ {t.subagentBatches.loadResultFailed}: {detail.error.message} +
+ ) : detail.data ? ( + + ) : null} +
+
); } + +function BatchItemDetail({ detail }: { detail: SubagentBatchItemDetail }) { + const { t } = useI18n(); + const evidence = detail.evidence; + const citationMessages = useMemo( + () => + [ + { + type: "tool", + name: "task", + artifact: evidence ? { knowledge_sources: evidence } : undefined, + }, + ] as unknown as Message[], + [evidence], + ); + return ( + +
+
+

{t.subagentBatches.report}

+
+ {detail.report ? ( + + {detail.report} + + ) : ( + t.subagentBatches.noReport + )} +
+ {detail.report_truncated && ( +

+ {t.subagentBatches.truncated} +

+ )} +
+
+

{t.subagentBatches.evidence}

+ {!evidence || evidence.sources.length === 0 ? ( +

+ {t.subagentBatches.noEvidence} +

+ ) : ( +
+ {evidence.sources.map((source) => ( +
+

+ {source.dataset_name} / {source.document_name} +

+

+ {source.text} +

+ {source.pages.length > 0 && ( +

+ {t.subagentBatches.pagesLabel}: {source.pages.join(", ")} +

+ )} +
+ ))} +
+ )} + {evidence && evidence.omitted_count > 0 && ( +

+ {t.subagentBatches.omittedEvidence(evidence.omitted_count)} +

+ )} +
+ {detail.acceptance_verdict && ( +
+

{t.subagentBatches.acceptance}

+
+              {JSON.stringify(detail.acceptance_verdict, null, 2)}
+            
+
+ )} +
+
+ ); +} diff --git a/frontend/src/content/en/harness/subagents/observability.mdx b/frontend/src/content/en/harness/subagents/observability.mdx index 65e03b99e5..900e41e0f8 100644 --- a/frontend/src/content/en/harness/subagents/observability.mdx +++ b/frontend/src/content/en/harness/subagents/observability.mdx @@ -112,10 +112,13 @@ Durable batches have no SSE; the workspace UI polls, every 2 seconds while a bat | `POST /{batch_id}/resume` | Resume | | `POST /{batch_id}/cancel` | Cancel; 503 when the worker is not running | | `POST /{batch_id}/items/{item_id}/retry` | Retry one item; only `failed` items, otherwise 409 | +| `GET /{batch_id}/items/{position}` | Read one owner/thread-scoped report and captured evidence snapshot | | `GET /{batch_id}/results.jsonl` | Stream every item as NDJSON, including full results and acceptance verdicts | Batch statuses: `queued`, `running`, `paused` are active; `completed`, `failed`, `cancelled` are terminal. Item statuses: `pending` is waiting and not counted as active; `queued`, `leased`, `running` are active; `succeeded`, `failed`, `cancelled` are terminal. When a batch ends with failed items and no succeeded items it is `failed`, otherwise `completed`. +The selected-item route is the browser review contract. `position` is the immutable submission position, and the response contains the stored report, execution/acceptance fields, and a bounded `evidence` snapshot from the completed run. It never returns the original prompt or execution specification, never refetches a provider, and remains readable when the worker is stopped. The workspace batch panel opens this detail view on demand; compact item polling still returns previews only. + ## Extension observers Extensions with a task-lifecycle observer are notified when each subagent starts and stops: `TaskInfo.kind` is `subagent`, `task_id` is the server-side execution id, `parent_task_id` is the parent run id, and `agent_name` is the subagent name. The `TaskOutcome` at stop is `completed`, `aborted` (cancelled), or `failed` (everything else). Runs without a `run_id`, such as direct integrations, trigger no notifications. diff --git a/frontend/src/content/zh/harness/subagents/observability.mdx b/frontend/src/content/zh/harness/subagents/observability.mdx index 96742fc936..933f1557da 100644 --- a/frontend/src/content/zh/harness/subagents/observability.mdx +++ b/frontend/src/content/zh/harness/subagents/observability.mdx @@ -112,10 +112,13 @@ GET /api/threads/{thread_id}/token-usage?include_active=false | `POST /{batch_id}/resume` | 恢复 | | `POST /{batch_id}/cancel` | 取消;worker 未运行时返回 503 | | `POST /{batch_id}/items/{item_id}/retry` | 重试单个条目,只接受 `failed` 状态,否则 409 | +| `GET /{batch_id}/items/{position}` | 读取一个按所有者/线程授权的报告和已捕获证据快照 | | `GET /{batch_id}/results.jsonl` | 以 NDJSON 流式导出全部条目,含完整结果与验收结论 | 批次状态:`queued`、`running`、`paused` 为活跃,`completed`、`failed`、`cancelled` 为终态。条目状态:`pending` 为等待中,不计入活跃;`queued`、`leased`、`running` 为活跃,`succeeded`、`failed`、`cancelled` 为终态。批次结束时,若有失败条目且没有任何成功条目,批次记为 `failed`,否则为 `completed`。 +选中条目接口是浏览器审阅契约。`position` 是提交时固定的条目位置,响应包含已保存的报告、执行/验收字段,以及本次运行捕获并持久化的有界 `evidence` 快照。它不会返回原始 prompt 或执行规格,不会重新请求 provider;worker 停止时仍可读取。工作区批处理面板按需打开详情,普通条目轮询仍只返回预览。 + ## 扩展观察点 安装了任务生命周期观察者的扩展会在每个子 Agent 开始和结束时收到通知:`TaskInfo.kind` 为 `subagent`,`task_id` 是服务端执行 id,`parent_task_id` 是父 run 的 id,`agent_name` 是子 Agent 名称。结束时的 `TaskOutcome` 为 `completed`、`aborted`(取消)或 `failed`(其余情况)。没有 `run_id` 的运行(例如直接集成)不触发这些通知。 diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 872577fe4d..4dbef01eca 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -886,6 +886,18 @@ export const enUS: Translations = { cancel: "Cancel", retryItem: "Retry", exportResults: "Export JSONL", + viewResult: "Review result", + resultTitle: "Batch result", + resultDescription: "Stored report and captured evidence for this item.", + report: "Report", + evidence: "Captured evidence", + noEvidence: "No captured evidence", + acceptance: "Acceptance", + noReport: "No report has been stored yet.", + loadResultFailed: "Couldn't load batch result", + truncated: "Report truncated", + omittedEvidence: (count) => `${count} evidence entries omitted`, + pagesLabel: "Pages", viewItems: "View items", hideItems: "Hide items", itemsFailed: "Couldn't load batch items", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index de8dad0d69..88bbe3d241 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -689,6 +689,18 @@ export interface Translations { cancel: string; retryItem: string; exportResults: string; + viewResult: string; + resultTitle: string; + resultDescription: string; + report: string; + evidence: string; + noEvidence: string; + acceptance: string; + noReport: string; + loadResultFailed: string; + truncated: string; + omittedEvidence: (count: number) => string; + pagesLabel: string; viewItems: string; hideItems: string; itemsFailed: string; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 2748a7384c..981dda0714 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -824,6 +824,18 @@ export const zhCN: Translations = { cancel: "取消", retryItem: "重试", exportResults: "导出 JSONL", + viewResult: "查看结果", + resultTitle: "批处理结果", + resultDescription: "查看该条目的持久化报告和已捕获证据。", + report: "报告", + evidence: "已捕获证据", + noEvidence: "没有已捕获证据", + acceptance: "验收结果", + noReport: "该条目还没有保存报告。", + loadResultFailed: "无法加载批处理结果", + truncated: "报告已截断", + omittedEvidence: (count) => `${count} 条证据未展示`, + pagesLabel: "页码", viewItems: "查看条目", hideItems: "收起条目", itemsFailed: "无法加载批处理条目", diff --git a/frontend/src/core/subagent-batches/api.ts b/frontend/src/core/subagent-batches/api.ts index ddcfd04453..f8adad3c2a 100644 --- a/frontend/src/core/subagent-batches/api.ts +++ b/frontend/src/core/subagent-batches/api.ts @@ -2,7 +2,11 @@ import { throwGatewayApiError } from "@/core/api/errors"; import { fetch } from "@/core/api/fetcher"; import { getBackendBaseURL } from "@/core/config"; -import type { SubagentBatch, SubagentBatchItem } from "./types"; +import type { + SubagentBatch, + SubagentBatchItem, + SubagentBatchItemDetail, +} from "./types"; function batchUrl(threadId: string, path = ""): string { return `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/subagent-batches${path}`; @@ -44,6 +48,22 @@ export async function fetchSubagentBatchItems( ); } +export async function fetchSubagentBatchItemDetail( + threadId: string, + batchId: string, + position: number, +): Promise { + return json( + await fetch( + batchUrl( + threadId, + `/${encodeURIComponent(batchId)}/items/${encodeURIComponent(String(position))}`, + ), + ), + "Failed to load batch result", + ); +} + export async function controlSubagentBatch( threadId: string, batchId: string, diff --git a/frontend/src/core/subagent-batches/hooks.ts b/frontend/src/core/subagent-batches/hooks.ts index 07c8b20fa5..0789599676 100644 --- a/frontend/src/core/subagent-batches/hooks.ts +++ b/frontend/src/core/subagent-batches/hooks.ts @@ -4,10 +4,12 @@ import { useQuery, useQueryClient, } from "@tanstack/react-query"; +import { useEffect } from "react"; import { toast } from "sonner"; import { controlSubagentBatch, + fetchSubagentBatchItemDetail, fetchSubagentBatchItems, fetchSubagentBatches, retrySubagentBatchItem, @@ -18,6 +20,11 @@ export const subagentBatchesKey = (threadId: string) => ["subagent-batches", threadId] as const; export const subagentBatchItemsKey = (threadId: string, batchId: string) => [...subagentBatchesKey(threadId), batchId, "items"] as const; +export const subagentBatchItemDetailKey = ( + threadId: string, + batchId: string, + position: number, +) => [...subagentBatchItemsKey(threadId, batchId), position, "detail"] as const; const SUBAGENT_BATCH_ITEMS_PAGE_SIZE = 100; export function useSubagentBatches( @@ -65,6 +72,45 @@ export function useSubagentBatchItems( }); } +export function useSubagentBatchItemDetail( + threadId: string, + batchId: string, + position: number | null, + options: { enabled?: boolean; polling?: boolean; revision?: string } = {}, +) { + const queryClient = useQueryClient(); + useEffect(() => { + if (position === null || options.revision === undefined) return; + // A terminal detail stops polling, but another client can retry the item. + // Compact-list revisions invalidate this cached detail; disabled dialogs do + // not refetch until reopened. + void queryClient.invalidateQueries({ + queryKey: subagentBatchItemDetailKey(threadId, batchId, position), + exact: true, + }); + }, [queryClient, threadId, batchId, position, options.revision]); + return useQuery({ + queryKey: + position === null + ? [...subagentBatchItemsKey(threadId, batchId), "detail", "closed"] + : subagentBatchItemDetailKey(threadId, batchId, position), + queryFn: () => fetchSubagentBatchItemDetail(threadId, batchId, position!), + enabled: + options.enabled !== false && + Boolean(threadId) && + Boolean(batchId) && + position !== null, + refetchInterval: (query) => { + if (options.polling === false) return false; + const status = query.state.data?.status; + return status && ["succeeded", "failed", "cancelled"].includes(status) + ? false + : 2000; + }, + refetchIntervalInBackground: false, + }); +} + export function useControlSubagentBatch(threadId: string) { const queryClient = useQueryClient(); return useMutation({ diff --git a/frontend/src/core/subagent-batches/types.ts b/frontend/src/core/subagent-batches/types.ts index b25f9808df..61a1cb1961 100644 --- a/frontend/src/core/subagent-batches/types.ts +++ b/frontend/src/core/subagent-batches/types.ts @@ -51,6 +51,44 @@ export type SubagentBatchItem = { updated_at: string; }; +export type SubagentBatchEvidenceSource = { + id: string; + provider: "ragflow"; + dataset_id: string; + document_id: string; + chunk_id: string; + dataset_name: string; + document_name: string; + text: string; + pages: number[]; + truncated: boolean; +}; + +export type SubagentBatchEvidence = { + version: 1; + sources: SubagentBatchEvidenceSource[]; + omitted_count: number; +}; + +export type SubagentBatchItemDetail = { + id: string; + item_key: string; + position: number; + status: SubagentBatchItemStatus; + attempt: number; + model_name: string | null; + report: string | null; + report_truncated: boolean; + error: string | null; + stop_reason: string | null; + acceptance_criteria: string[] | null; + acceptance_verdict: Record | null; + evidence: SubagentBatchEvidence | null; + started_at: string | null; + completed_at: string | null; + updated_at: string; +}; + export function isActiveSubagentBatch(batch: SubagentBatch): boolean { return ["queued", "running", "paused"].includes(batch.status); } diff --git a/frontend/tests/unit/components/workspace/thread-subagent-batch-review.dom.test.tsx b/frontend/tests/unit/components/workspace/thread-subagent-batch-review.dom.test.tsx new file mode 100644 index 0000000000..b5941e14ba --- /dev/null +++ b/frontend/tests/unit/components/workspace/thread-subagent-batch-review.dom.test.tsx @@ -0,0 +1,313 @@ +import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; + +const mocks = rs.hoisted(() => ({ fetch: rs.fn(), workerRunning: false })); +rs.mock("@/core/api/fetcher", () => ({ fetch: mocks.fetch })); +rs.mock("@/core/config", () => ({ getBackendBaseURL: () => "" })); +rs.mock("@/core/features", () => ({ + useSubagentBatchesCapability: () => ({ + repositoryAvailable: true, + workerRunning: mocks.workerRunning, + maxRunning: 3, + isLoading: false, + }), +})); + +import { ThreadSubagentBatches } from "@/components/workspace/thread-subagent-batches"; +import { I18nContext } from "@/core/i18n/context"; +import { enUS } from "@/core/i18n/locales/en-US"; +import { zhCN } from "@/core/i18n/locales/zh-CN"; +import type { + SubagentBatch, + SubagentBatchItem, + SubagentBatchItemDetail, +} from "@/core/subagent-batches"; +import { subagentBatchItemsKey } from "@/core/subagent-batches/hooks"; + +const translations = { "en-US": enUS, "zh-CN": zhCN }; +const BATCH_URL = "/api/threads/thread-1/subagent-batches"; +const DETAIL_URL = `${BATCH_URL}/batch-1/items/0`; +const SOURCE_ID = "0123456789abcdef0123456789abcdef-1"; +const SECOND_SOURCE_ID = "0123456789abcdef0123456789abcdef-2"; +const clients: QueryClient[] = []; +const noLocaleChange = () => undefined; +let detail: SubagentBatchItemDetail; +let compactItem: SubagentBatchItem; + +const batch: SubagentBatch = { + id: "batch-1", + title: "Stored batch", + subagent_type: "general-purpose", + status: "running", + total_items: 1, + max_live_items: 1, + max_running_items: 1, + max_attempts: 3, + counts: { + pending: 0, + queued: 0, + leased: 0, + running: 1, + succeeded: 0, + failed: 0, + cancelled: 0, + }, + created_at: "2026-10-17T00:00:00Z", + updated_at: "2026-10-17T00:00:00Z", + completed_at: null, +}; +const item: SubagentBatchItem = { + id: "item-1", + batch_id: batch.id, + item_key: "record-1", + position: 0, + status: "running", + attempt: 1, + model_name: "model-a", + result_preview: null, + result_truncated: false, + error: null, + stop_reason: null, + token_usage: null, + started_at: null, + completed_at: null, + created_at: batch.created_at, + updated_at: batch.updated_at, +}; + +beforeEach(() => { + mocks.workerRunning = false; + compactItem = { ...item }; + detail = { + ...item, + status: "succeeded", + report: "Stored report", + report_truncated: false, + acceptance_criteria: null, + acceptance_verdict: null, + evidence: { + version: 1, + omitted_count: 0, + sources: [ + { + id: SOURCE_ID, + provider: "ragflow", + dataset_id: "dataset-1", + document_id: "document-1", + chunk_id: "chunk-1", + dataset_name: "Private KB", + document_name: "shared.pdf", + text: "First captured excerpt", + pages: [2], + truncated: false, + }, + ], + }, + }; + mocks.fetch.mockImplementation(async (input: string) => { + const path = new URL(input, "http://test").pathname; + const value = + path === BATCH_URL + ? [batch] + : path === `${BATCH_URL}/batch-1/items` + ? [compactItem] + : path === DETAIL_URL + ? detail + : undefined; + if (value === undefined) + throw new Error(`Unexpected HTTP request: ${input}`); + return new Response(JSON.stringify(value), { + headers: { "Content-Type": "application/json" }, + }); + }); +}); +afterEach(() => { + cleanup(); + clients.splice(0).forEach((client) => client.clear()); + mocks.fetch.mockReset(); +}); + +async function openReview(locale: keyof typeof translations = "en-US") { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + clients.push(client); + render( + + + + + , + ); + const t = translations[locale].subagentBatches; + fireEvent.click(await screen.findByRole("button", { name: t.label })); + fireEvent.click(await screen.findByRole("button", { name: t.viewItems })); + expect(detailRequests()).toBe(0); + fireEvent.click(await screen.findByRole("button", { name: t.viewResult })); + await waitFor(() => expect(detailRequests()).toBe(1)); + return t; +} +function detailRequests() { + return mocks.fetch.mock.calls.filter( + ([input]) => String(input) === DETAIL_URL, + ).length; +} + +// Keep the component, query hooks, report renderer and citation dialogs real. +// Only the HTTP boundary and unrelated worker capability are replaced. +describe("stored batch result review", () => { + it("refreshes an open terminal detail after an external retry and completion", async () => { + compactItem = { ...item, status: "failed", error: "Previous failure" }; + detail = { + ...detail, + status: "failed", + report: "Previous attempt", + error: "Previous failure", + }; + const t = await openReview(); + expect(await screen.findByText("Previous attempt")).toBeDefined(); + const client = clients[0]!; + + compactItem = { + ...compactItem, + status: "running", + attempt: 2, + updated_at: "2026-10-17T00:02:00Z", + error: null, + }; + detail = { ...detail, ...compactItem, report: null, evidence: null }; + await act(async () => { + await client.refetchQueries({ + queryKey: subagentBatchItemsKey("thread-1", "batch-1"), + exact: true, + }); + }); + expect(await screen.findByText(t.noReport)).toBeDefined(); + expect(screen.queryByText("Previous attempt")).toBeNull(); + + compactItem = { + ...compactItem, + status: "succeeded", + updated_at: "2026-10-17T00:03:00Z", + result_preview: "Retried report", + }; + detail = { ...detail, ...compactItem, report: "**Retried report**" }; + await act(async () => { + await client.refetchQueries({ + queryKey: subagentBatchItemsKey("thread-1", "batch-1"), + exact: true, + }); + }); + expect( + await within( + screen.getByRole("dialog", { name: t.resultTitle }), + ).findByText("Retried report"), + ).toBeDefined(); + const completedRequests = detailRequests(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 2200)); + }); + expect(detailRequests()).toBe(completedRequests); + }, 10000); + + it("refreshes an open running detail, then stops polling on its own terminal response", async () => { + mocks.workerRunning = true; + detail = { ...detail, status: "running", report: null, evidence: null }; + const t = await openReview(); + expect(await screen.findByText(t.noReport)).toBeDefined(); + detail = { ...detail, status: "succeeded", report: "**Completed report**" }; + await screen.findByText("Completed report", {}, { timeout: 5000 }); + // The compact list deliberately remains running; only the detail is terminal. + const completedRequests = detailRequests(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 2200)); + }); + expect(detailRequests()).toBe(completedRequests); + }, 10000); + + it("stops polling a nonterminal detail when its dialog closes", async () => { + detail = { ...detail, status: "running", report: null, evidence: null }; + const t = await openReview(); + expect(await screen.findByText(t.noReport)).toBeDefined(); + fireEvent.keyDown(screen.getByRole("dialog", { name: t.resultTitle }), { + key: "Escape", + }); + await waitFor(() => + expect(screen.queryByRole("dialog", { name: t.resultTitle })).toBeNull(), + ); + const closedRequests = detailRequests(); + compactItem = { + ...compactItem, + attempt: 2, + updated_at: "2026-10-17T00:02:00Z", + }; + await act(async () => { + await clients[0]!.refetchQueries({ + queryKey: subagentBatchItemsKey("thread-1", "batch-1"), + exact: true, + }); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 2200)); + }); + expect(detailRequests()).toBe(closedRequests); + }); + + it("opens the matching stored excerpt for a report citation without fetching a provider", async () => { + const first = detail.evidence!.sources[0]!; + detail = { + ...detail, + report: `A cited claim [citation:2](#knowledge-${SECOND_SOURCE_ID}).`, + evidence: { + version: 1, + omitted_count: 0, + sources: [ + first, + { + ...first, + id: SECOND_SOURCE_ID, + chunk_id: "chunk-2", + text: "Second captured excerpt ", + pages: [7], + }, + ], + }, + }; + await openReview(); + fireEvent.click( + await screen.findByRole("button", { name: "View source: shared.pdf" }), + ); + const source = await screen.findByRole("dialog", { name: "shared.pdf" }); + expect(source.textContent).toContain( + "Second captured excerpt ", + ); + expect(source.textContent).not.toContain("First captured excerpt"); + expect(source.textContent).toContain("Pages 7"); + expect(source.querySelector("script")).toBeNull(); + expect(detailRequests()).toBe(1); + }); + + for (const locale of ["en-US", "zh-CN"] as const) { + it(`shows omitted evidence even with no retained sources in ${locale}`, async () => { + detail.evidence = { version: 1, sources: [], omitted_count: 3 }; + const t = await openReview(locale); + expect(await screen.findByText(t.noEvidence)).toBeDefined(); + expect(screen.getByText(t.omittedEvidence(3))).toBeDefined(); + }); + it(`localizes the captured page label in ${locale}`, async () => { + const t = await openReview(locale); + expect(await screen.findByText(`${t.pagesLabel}: 2`)).toBeDefined(); + }); + } +}); diff --git a/frontend/tests/unit/components/workspace/thread-subagent-batches.dom.test.tsx b/frontend/tests/unit/components/workspace/thread-subagent-batches.dom.test.tsx index 02fb88fd7a..1a7a87b775 100644 --- a/frontend/tests/unit/components/workspace/thread-subagent-batches.dom.test.tsx +++ b/frontend/tests/unit/components/workspace/thread-subagent-batches.dom.test.tsx @@ -9,6 +9,7 @@ const batchState = rs.hoisted(() => ({ batches: [] as Array>, control: rs.fn(), itemPages: [] as Array>>, + detailData: undefined as Record | undefined, fetchNextPage: rs.fn(), hasNextPage: false, })); @@ -39,6 +40,17 @@ rs.mock("@/core/i18n/hooks", () => ({ cancel: "Cancel", retryItem: "Retry", exportResults: "Export JSONL", + viewResult: "Review result", + resultTitle: "Batch result", + resultDescription: "Stored report and captured evidence for this item.", + report: "Report", + evidence: "Captured evidence", + noEvidence: "No captured evidence", + acceptance: "Acceptance", + noReport: "No report has been stored yet.", + loadResultFailed: "Couldn't load batch result", + truncated: "Report truncated", + omittedEvidence: (count: number) => `${count} evidence entries omitted`, viewItems: "View items", hideItems: "Hide items", itemsFailed: "Items failed", @@ -65,6 +77,11 @@ rs.mock("@/core/subagent-batches", () => ({ ["queued", "running", "paused"].includes(batch.status), subagentBatchProgress: () => 50, subagentBatchResultsUrl: () => "/results.jsonl", + useSubagentBatchItemDetail: () => ({ + isLoading: false, + isError: false, + data: batchState.detailData, + }), useControlSubagentBatch: () => ({ isPending: false, variables: undefined, @@ -122,6 +139,7 @@ afterEach(() => { batchState.batches = []; batchState.control.mockReset(); batchState.itemPages = []; + batchState.detailData = undefined; batchState.fetchNextPage.mockReset(); batchState.hasNextPage = false; }); @@ -163,6 +181,7 @@ describe("ThreadSubagentBatches capability gating", () => { { id: "item-1", item_key: "record-1", + position: 0, status: "succeeded", result_preview: "done", }, @@ -177,4 +196,64 @@ describe("ThreadSubagentBatches capability gating", () => { expect(batchState.fetchNextPage).toHaveBeenCalledTimes(1); }); + + it("reviews a stored report and captured evidence", async () => { + batchState.batches = [HISTORICAL_BATCH]; + batchState.itemPages = [ + [ + { + id: "item-1", + item_key: "record-1", + position: 0, + status: "succeeded", + result_preview: "preview", + }, + ], + ]; + batchState.detailData = { + id: "item-1", + item_key: "record-1", + position: 0, + status: "succeeded", + attempt: 1, + model_name: "model-a", + report: "Stored report", + report_truncated: false, + error: null, + stop_reason: null, + acceptance_criteria: null, + acceptance_verdict: null, + evidence: { + version: 1, + omitted_count: 0, + sources: [ + { + id: "source-1", + provider: "ragflow", + dataset_id: "dataset-1", + document_id: "document-1", + chunk_id: "chunk-1", + dataset_name: "Private KB", + document_name: "report.md", + text: "Captured excerpt", + pages: [2], + truncated: false, + }, + ], + }, + started_at: null, + completed_at: null, + updated_at: "2026-08-24T00:01:00Z", + }; + render(); + + fireEvent.click(screen.getByRole("button", { name: "Batches" })); + fireEvent.click(screen.getByRole("button", { name: "View items" })); + fireEvent.click( + await screen.findByRole("button", { name: "Review result" }), + ); + + expect(await screen.findByText("Stored report")).toBeDefined(); + expect(screen.getByText("Captured excerpt")).toBeDefined(); + }); }); diff --git a/frontend/tests/unit/core/subagent-batches/api.test.ts b/frontend/tests/unit/core/subagent-batches/api.test.ts index 68f0a1cc06..9788a941b7 100644 --- a/frontend/tests/unit/core/subagent-batches/api.test.ts +++ b/frontend/tests/unit/core/subagent-batches/api.test.ts @@ -6,6 +6,7 @@ rs.mock("@/core/config", () => ({ getBackendBaseURL: () => "" })); import { fetch } from "@/core/api/fetcher"; import { controlSubagentBatch, + fetchSubagentBatchItemDetail, fetchSubagentBatchItems, fetchSubagentBatches, retrySubagentBatchItem, @@ -42,6 +43,12 @@ describe("subagent batch API", () => { expect(mockedFetch).toHaveBeenLastCalledWith( "/api/threads/thread%20%2F%201/subagent-batches/batch%20%2F%201/items?offset=100&limit=50&status=failed", ); + + mockedFetch.mockResolvedValueOnce(jsonResponse({})); + await fetchSubagentBatchItemDetail("thread / 1", "batch / 1", 3); + expect(mockedFetch).toHaveBeenLastCalledWith( + "/api/threads/thread%20%2F%201/subagent-batches/batch%20%2F%201/items/3", + ); }); it("posts control and failed-item retry actions", async () => {