代码修改

This commit is contained in:
董宏凯 committed 2026-07-23 15:08:00 +08:00
1 parent b60d9ed09e
commit 1d07025f74
9 files changed
+337 -53

No files matched your search

@@ -126,7 +126,7 @@ PositionRoundtablePage
`PositionBuiltinAgentChat` 不使用后台任务。每次发送先调用 `preparePositionSummary` 或 `preparePositionActionPlan`,从服务端得到冻结的输入提示词、来源版本和可复用线程 ID;待 `useThreadStream` 真正绑定到该线程后才发送首条消息。这个两步提交避免了界面先出现用户气泡、实际却没有启动流的竞态。
完成回调会调用 `completePositionSummary` 或 `completePositionActionPlan`。服务端使用 `source_revisions` 判断上游是否被重做,以避免将过期总结当作当前交付物。
完成回调会调用 `completePositionSummary` 或 `completePositionActionPlan`。服务端使用 `source_revisions` 判断上游是否被重做,以避免将过期总结当作当前交付物。方案总结(以及行动规划的 Markdown 部分)若模型只返回了对话正文、遗漏 `write_file`,服务端会将该正文自动写入对应线程的 `outputs/` 目录并登记为 Markdown 产物,因此用户始终同时获得聊天正文与可预览、下载的报告文件;行动规划仍必须另外生成 `action-plan-subtasks.json`。
## 5. 文件预览沙箱
@@ -334,7 +334,7 @@ export async function completePositionNodeTurn(
export async function rejectPositionNode(
sessionId: string,
nodeKey: string,
input: { title: string; reason: string },
input: { reason?: string; title?: string },
): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>(
await apiFetch(
@@ -343,8 +343,10 @@ export async function rejectPositionNode(
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: input.title,
reason: input.reason,
// ``title`` is retained for older callers. The current position
// workspace deliberately sends only an optional reason.
...(input.title ? { title: input.title } : {}),
reason: input.reason ?? "",
}),
},
),
@@ -76,12 +76,6 @@ interface PositionBuiltinAgentChatProps {
const SUMMARY_AGENT_ID = "roundtable-summary";
const ACTION_PLAN_AGENT_ID = "position-action-planner";
// 方案总结 / 行动规划仍使用普通的 thread 流;首次自动提交期间若组件因
// thread 绑定而短暂重渲染,保留服务端运行并允许恢复 SSE,避免首轮被取消。
const POSITION_BUILTIN_RUN_OPTIONS = {
onDisconnect: "continue",
multitaskStrategy: "rollback",
} as const;
function builtinAgentId(kind: PositionBuiltinAgentKind) {
return kind === "summary" ? SUMMARY_AGENT_ID : ACTION_PLAN_AGENT_ID;
@@ -244,7 +238,12 @@ export function PositionBuiltinAgentChat({
pendingSubmissionRef.current = false;
setPendingSubmission(null);
handledAutoStartRequestRef.current = undefined;
}, [fallbackAgentId, kind, persistedThreadId, session?.id]);
// Do not depend on `persistedThreadId` here. Preparing the automatic
// first question can persist a thread id while the page's initial
// session-request is still resolving. Treating that update as a new
// session used to clear `pendingSubmission` before the normal send path
// ran; a manual click only worked because that request had already ended.
}, [fallbackAgentId, kind, session?.id]);
const canWrite =
Boolean(session) &&
@@ -264,7 +263,6 @@ export function PositionBuiltinAgentChat({
const [thread, sendMessage, , actualThreadId] = useThreadStream({
threadId: boundThreadId,
assistantId,
runOptions: POSITION_BUILTIN_RUN_OPTIONS,
context: {
...settings.context,
agent_id: assistantId,
@@ -578,6 +576,12 @@ export function PositionBuiltinAgentChat({
thread={thread}
paddingBottom={24}
scrollButtonClassName="bottom-6"
// Use the built-in agent's regular submit path. This keeps a
// clarification answer on the same persisted thread and retains
// the summary/action-plan preparation and completion lifecycle.
onClarificationSubmit={(answer) =>
handleSubmit({ text: answer, files: [] })
}
onEditResubmit={(text) => void handleSubmit({ text, files: [] })}
onOpenArtifact={handleOpenMessageArtifact}
/>
@@ -4,6 +4,13 @@ import { Bot, FileText, LockKeyhole, Settings } from "lucide-react";
import { toast } from "sonner";
import { Button as TButton, Select as TSelect } from "tdesign-react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ThreadContext } from "@/components/workspace/messages/context";
import { MessageList } from "@/components/workspace/messages";
import { useAgent } from "@/core/agents";
@@ -207,8 +214,12 @@ export function PositionNodeChat({
onThreadSnapshot,
}: PositionNodeChatProps) {
const [settings, setSettings] = useLocalSettings();
const [rejectionReasonOpen, setRejectionReasonOpen] = useState(false);
const { agent } = useAgent(node.agentId);
const agentDisplayName = desensitize(agentName || agent?.name || node.agentId);
const lastRejection = node.status === "rejected" ? node.lastRejection : null;
const rejectionReason =
lastRejection && typeof lastRejection.reason === "string" ? lastRejection.reason.trim() : "";
const switchOptions = useMemo<PositionNodeSwitchOption[]>(
() =>
positionNodeOptions?.length
@@ -504,6 +515,17 @@ export function PositionNodeChat({
onSelect={onSelectBuiltinAgent}
/>
)}
{lastRejection && (
<TButton
size="small"
theme="warning"
variant="outline"
className="!shrink-0"
onClick={() => setRejectionReasonOpen(true)}
>
查看驳回理由
</TButton>
)}
{onOpenConfiguration && (
<TButton
size="small"
@@ -559,6 +581,13 @@ export function PositionNodeChat({
thread={thread}
paddingBottom={24}
scrollButtonClassName="bottom-6"
// Clarification cards are rendered by MessageList, but position
// nodes own the actual stream. Route their answer through the
// same submit path as the normal input so handoff context,
// permissions and node lifecycle updates are all preserved.
onClarificationSubmit={(answer) =>
handleSubmit({ text: answer, files: [] })
}
onEditResubmit={(text) => void handleSubmit({ text, files: [] })}
onOpenArtifact={handleOpenMessageArtifact}
/>
@@ -593,6 +622,22 @@ export function PositionNodeChat({
onStop={() => void thread.stop()}
/>
</div>
<Dialog open={rejectionReasonOpen} onOpenChange={setRejectionReasonOpen}>
<DialogContent className="w-[480px] !max-w-[calc(100vw-2rem)]">
<DialogHeader>
<DialogTitle>驳回理由</DialogTitle>
</DialogHeader>
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2.5 text-sm leading-6 text-amber-900 dark:border-amber-500/35 dark:bg-amber-950/25 dark:text-amber-100">
{rejectionReason || "本次驳回未填写具体理由。"}
</div>
<DialogFooter>
<TButton variant="outline" onClick={() => setRejectionReasonOpen(false)}>
关闭
</TButton>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</ThreadContext.Provider>
);
@@ -808,6 +808,11 @@ export default function PositionRoundtablePage({
const showClosureAgents = positionId === DEFAULT_POSITION_ID;
const [activeBuiltinKind, setActiveBuiltinKind] = useState<PositionBuiltinAgentKind | null>(null);
const [builtinAutoStartRequestKey, setBuiltinAutoStartRequestKey] = useState(0);
// Stage selection only arms the automatic first question. The key is
// incremented after the page has finished restoring its Session so the
// ordinary chat component never submits against a thread being rebound.
const [pendingBuiltinAutoStartKind, setPendingBuiltinAutoStartKind] =
useState<PositionBuiltinAgentKind | null>(null);
const builtinAgentOptions = useMemo<PositionBuiltinAgentSwitchOption[]>(
() => [
{
@@ -834,7 +839,27 @@ export default function PositionRoundtablePage({
useEffect(() => {
setActiveBuiltinKind(null);
setBuiltinAutoStartRequestKey(0);
setPendingBuiltinAutoStartKind(null);
}, [positionSession?.id]);
useEffect(() => {
if (
!pendingBuiltinAutoStartKind ||
pendingBuiltinAutoStartKind !== activeBuiltinKind ||
!positionSession ||
sessionLoading
) {
return;
}
// Let the ordinary chat mount and finish the current page's session
// restoration first. The callback below only increments a key; the child
// still uses its standard manual-submit path to create the run.
const timer = window.setTimeout(() => {
setBuiltinAutoStartRequestKey((current) => current + 1);
setPendingBuiltinAutoStartKind(null);
}, 0);
return () => window.clearTimeout(timer);
}, [activeBuiltinKind, pendingBuiltinAutoStartKind, positionSession?.id, sessionLoading]);
const assignedAgentIds = useMemo(
() =>
new Set(
@@ -1421,7 +1446,7 @@ export default function PositionRoundtablePage({
setActiveNodeKey(null);
setRightPanelOpen(false);
setActiveBuiltinKind(kind);
setBuiltinAutoStartRequestKey((current) => current + 1);
setPendingBuiltinAutoStartKind(kind);
},
[allPositionDeliveriesDone, closeSandbox, positionId, summaryDone],
);
@@ -4454,8 +4479,12 @@ function AnalysisResults({
const [nodeFilter, setNodeFilter] = useState("all");
const [kindFilter, setKindFilter] = useState<"all" | ResultKind>("all");
const [detailItem, setDetailItem] = useState<ResultItem | null>(null);
// Rejecting starts from the artifact detail dialog. Keep the requested node
// separate until that dialog has actually unmounted; otherwise Radix places
// the reason dialog below the existing deliverable dialog and the click
// appears to do nothing.
const [pendingRejectNode, setPendingRejectNode] = useState<PositionRoundtableNode | null>(null);
const [rejectNode, setRejectNode] = useState<PositionRoundtableNode | null>(null);
const [rejectTitle, setRejectTitle] = useState("");
const [rejectReason, setRejectReason] = useState("");
const [rejectSubmitting, setRejectSubmitting] = useState(false);
const seatsByAgentId = useMemo(
@@ -4666,37 +4695,41 @@ function AnalysisResults({
);
const openRejectDialog = useCallback((node: PositionRoundtableNode) => {
setRejectNode(node);
setRejectTitle(nodeRejectionTitle(node));
setRejectReason("");
// Close the high-z-index product dialog first, then open the rejection
// dialog on the next render. This prevents an invisible, unclickable
// dialog from being rendered under the product detail modal.
setDetailItem(null);
setPendingRejectNode(node);
}, []);
useEffect(() => {
if (!pendingRejectNode || detailItem || rejectSubmitting) return;
setRejectNode(pendingRejectNode);
setRejectReason("");
setPendingRejectNode(null);
}, [detailItem, pendingRejectNode, rejectSubmitting]);
const handleRejectSubmit = useCallback(async () => {
if (!rejectNode) return;
const title = rejectTitle.trim();
const reason = rejectReason.trim();
if (!title || !reason) {
toast.warning("请填写驳回标题和理由");
return;
}
setRejectSubmitting(true);
try {
const session = await rejectPositionNode(rejectNode.sessionId, rejectNode.nodeKey, {
title,
reason,
});
// The endpoint returns the complete session, including every downstream
// node invalidated by this rejection. Replacing the parent snapshot here
// updates the flow chart, delivery drawer and the active chat together.
onSessionUpdate(session);
setRejectNode(null);
setRejectTitle("");
setRejectReason("");
setDetailItem(null);
toast.success("已驳回该产物,后续阶段已标记为需重新完成");
toast.success("产物已驳回,相关后续阶段已同步标记为待重做");
} catch (error) {
toast.error(`驳回失败:${error instanceof Error ? error.message : String(error)}`);
} finally {
setRejectSubmitting(false);
}
}, [onSessionUpdate, rejectNode, rejectReason, rejectTitle]);
}, [onSessionUpdate, rejectNode, rejectReason]);
if (resultItems.length === 0) {
return (
@@ -4914,20 +4947,19 @@ function AnalysisResults({
<RejectDeliveryDialog
node={rejectNode}
title={rejectTitle}
reason={rejectReason}
submitting={rejectSubmitting}
onOpenChange={(open) => {
if (!open && !rejectSubmitting) {
setRejectNode(null);
setRejectTitle("");
setPendingRejectNode(null);
setRejectReason("");
}
}}
onTitleChange={setRejectTitle}
onReasonChange={setRejectReason}
onSubmit={() => void handleRejectSubmit()}
/>
{rejectSubmitting && <RejectSyncingOverlay />}
</div>
);
}
@@ -5143,26 +5175,25 @@ function DeliverableDetailDialog({
function RejectDeliveryDialog({
node,
title,
reason,
submitting,
onOpenChange,
onTitleChange,
onReasonChange,
onSubmit,
}: {
node: PositionRoundtableNode | null;
title: string;
reason: string;
submitting: boolean;
onOpenChange: (open: boolean) => void;
onTitleChange: (value: string) => void;
onReasonChange: (value: string) => void;
onSubmit: () => void;
}) {
return (
<Dialog open={Boolean(node)} onOpenChange={onOpenChange}>
<DialogContent className="w-[560px] !max-w-[calc(100vw-2rem)]">
<DialogContent
overlayClassName="!z-[6100]"
className="!z-[6101] w-[560px] !max-w-[calc(100vw-2rem)]"
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base">
<RotateCcw className="size-4 text-destructive" />
@@ -5171,22 +5202,14 @@ function RejectDeliveryDialog({
</DialogHeader>
<div className="space-y-3">
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800 dark:border-amber-500/35 dark:bg-amber-950/25 dark:text-amber-200">
驳回后,该节点会回到需重做状态;其后续阶段的产物会一起失效,但历史内容仍可查看。
驳回后,该节点会回到待重做状态;已受其影响的后续交付会同步失效,待上游重新完成后再依次生成。
</div>
<label className="block space-y-1.5">
<span className="text-xs font-semibold text-slate-700 dark:text-[#c5cfe0]">驳回标题</span>
<TInput
value={title}
placeholder="例如:依据不足,需要补充约束条件"
onChange={onTitleChange}
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold text-slate-700 dark:text-[#c5cfe0]">驳回理由</span>
<span className="text-xs font-semibold text-slate-700 dark:text-[#c5cfe0]">驳回理由(选填)</span>
<Textarea
rows={5}
value={reason}
placeholder="请说明需要重做的原因、补充材料或调整方向"
placeholder="可说明需要重做的原因、补充材料或调整方向;不填写也可以直接驳回"
onChange={(event) => onReasonChange(event.target.value)}
/>
</label>
@@ -5203,3 +5226,24 @@ function RejectDeliveryDialog({
</Dialog>
);
}
/** Blocks duplicate clicks while the server cascades the rejection state. */
function RejectSyncingOverlay() {
return (
<div
className="fixed inset-0 z-[7000] flex items-center justify-center bg-slate-950/45 p-4 backdrop-blur-[1px]"
role="status"
aria-live="polite"
>
<div className="w-full max-w-sm rounded-xl border border-slate-200 bg-white p-5 text-center shadow-2xl dark:border-[#2b8ffe]/35 dark:bg-[#082653]">
<span className="mx-auto flex size-10 items-center justify-center rounded-full bg-primary/10 text-primary">
<Loader2 className="size-5 animate-spin" />
</span>
<p className="mt-3 text-sm font-bold text-slate-900 dark:text-[#e8edf5]">正在驳回产物并同步业务链</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground dark:text-[#8aa0c4]">
正在更新受影响节点的状态,请稍候…
</p>
</div>
</div>
);
}
@@ -403,12 +403,23 @@ and direct-agent node indexes in `position_roundtable_nodes` (migration
`20260721_01`). Message history remains in the established LangGraph thread
store; a node completion unlocks its next chain stage and a later upstream
revision marks downstream records `stale` for refresh.
`POST /api/position-roundtable/sessions/{id}/nodes/{node_key}/reject` uses the
same cascade: its reason is intentionally optional, the selected node becomes
`rejected`, and downstream material becomes `stale` (or `locked` when no
material exists) with `invalidated_by` set. Clients must replace their complete
session snapshot from this response so the flow, delivery list, and active
node state stay consistent. A rejected/stale node returns to `done` only after
a fresh Markdown delivery is recorded.
For direct-agent handoff, the router resolves artifact paths only inside the
completed upstream node's own thread, then includes bounded UTF-8 excerpts for
text outputs; binary artifacts stay as metadata-only references.
`POST /sessions/{id}/archive` turns a session into a read-only historical
record: task/intent updates, node binding, hidden-context construction, and
turn completion are rejected, while read endpoints remain available.
Built-in summary and action-plan completion also preserves a visible report
answer when the model misses its final `write_file` call: the router writes
that exact answer as the required Markdown artifact in the owning thread's
`outputs/` directory, so users receive both chat text and a downloadable file.
### 岗位 (Position) Subscription System
+1 -1
View File
@@ -124,7 +124,7 @@ FastAPI application providing REST endpoints for frontend integration:
| `GET/POST /api/agents` | List visible agents or create a user-owned agent |
| `GET/PUT/DELETE /api/agents/{id}` | Read, update, or delete an id-addressed agent |
| `GET/POST/PUT/DELETE /api/roundtable-chains` | Manage user-owned roundtable business chains; each seat may optionally carry `position_id` for the separate position-collaboration workspace (the original roundtable ignores it) |
| `GET/POST/PUT /api/position-roundtable/sessions` | Standalone position-collaboration sessions: freeze task/intent/chain snapshots, index one direct-agent node per seat, build upstream handoff context (including bounded UTF-8 excerpts of text artifacts), and make archived sessions read-only without invoking the original roundtable coordinator or jobs |
| `GET/POST/PUT /api/position-roundtable/sessions` | Standalone position-collaboration sessions: freeze task/intent/chain snapshots, index one direct-agent node per seat, build upstream handoff context (including bounded UTF-8 excerpts of text artifacts), and make archived sessions read-only without invoking the original roundtable coordinator or jobs. Built-in summary/action-plan completion mirrors visible report text into a Markdown artifact when a model omits its final `write_file` call. `POST /sessions/{id}/nodes/{nodeKey}/reject` accepts an optional reason, marks the rejected delivery for rework, and invalidates affected downstream deliveries so they are regenerated in order. |
| `GET /api/memory` | Retrieve memory data |
| `POST /api/memory/reload` | Force memory reload |
| `GET /api/memory/config` | Memory configuration |
@@ -28,6 +28,10 @@ _MAX_INLINE_ARTIFACT_CHARS = 1_800
_BUILTIN_MATERIAL_BUDGET = 24_000
SUMMARY_AGENT_ID = "roundtable-summary"
ACTION_PLAN_AGENT_ID = "position-action-planner"
_BUILTIN_MARKDOWN_OUTPUTS = {
"summary": "mnt/user-data/outputs/岗位会商方案总结报告.md",
"action_plan": "mnt/user-data/outputs/岗位会商行动规划.md",
}
_TEXT_ARTIFACT_SUFFIXES = {
".csv",
".json",
@@ -113,8 +117,11 @@ class CompleteTurnRequest(BaseModel):
class RejectNodeRequest(BaseModel):
title: str = Field(min_length=1, max_length=120)
reason: str = Field(min_length=1, max_length=5_000)
# The UI only asks for an optional rejection reason. Keep ``title`` for
# compatible clients and for the audit record, but provide a stable
# default so neither field turns a valid rejection into a 422/400.
title: str = Field(default="产物驳回", max_length=120)
reason: str = Field(default="", max_length=5_000)
class PrepareBuiltinRunRequest(BaseModel):
@@ -731,6 +738,45 @@ def _artifact_manifest_refs(artifacts: list[dict[str, Any]]) -> list[dict[str, A
return refs
async def _write_builtin_markdown_fallback(
*, thread_id: str, kind: str, content: str
) -> dict[str, Any]:
"""Materialize a built-in agent's visible answer when it skipped ``write_file``.
Position roundtable runs are direct chat streams. A model can therefore
finish with a complete visible report but still omit its required artifact
tool call. Keep the chat answer as the source of truth and mirror it into
the owning thread's outputs directory so the report remains previewable and
downloadable after the stream has ended.
"""
virtual_path = _BUILTIN_MARKDOWN_OUTPUTS.get(kind)
report = content.strip()
if virtual_path is None:
raise HTTPException(status_code=400, detail=f"Unknown built-in delivery kind: {kind}")
if not report:
raise HTTPException(status_code=422, detail="内置智能体未返回可保存为报告的正文")
try:
output_path = await aresolve_thread_virtual_path(thread_id, virtual_path)
await asyncio.to_thread(output_path.parent.mkdir, parents=True, exist_ok=True)
await asyncio.to_thread(
output_path.write_text,
report if report.endswith("\n") else f"{report}\n",
encoding="utf-8",
)
stat = await asyncio.to_thread(output_path.stat)
except OSError as error:
raise HTTPException(status_code=500, detail="无法保存内置智能体 Markdown 报告") from error
return {
"name": output_path.name,
"path": virtual_path,
"mime_type": "text/markdown",
"modified_at": datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat(),
"size_bytes": stat.st_size,
}
async def _effective_delivery_material(
session: dict[str, Any], nodes: list[dict[str, Any]]
) -> str:
@@ -1109,10 +1155,8 @@ async def complete_node_turn(
async def reject_node(
request: Request, session_id: str, node_key: str, body: RejectNodeRequest
) -> SessionDetailResponse:
title = body.title.strip()
title = body.title.strip() or "产物驳回"
reason = body.reason.strip()
if not title or not reason:
raise HTTPException(status_code=400, detail="驳回标题和理由不能为空")
store, user_id, _ = await _require_writable_session(request, session_id)
current = await store.get_node(session_id, node_key, user_id)
if current is None:
@@ -1199,7 +1243,17 @@ async def _complete_builtin_run(
artifacts = _artifact_manifest_refs(body.artifact_manifest)
markdown = [artifact for artifact in artifacts if _is_markdown_artifact(artifact)]
if not markdown:
raise HTTPException(status_code=422, detail="内置智能体必须交付 outputs 目录下的一份 Markdown 报告文档")
# The report text is already visible in the chat stream. Do not lose
# that successful result just because the model skipped its final
# write_file call: mirror the exact response into the thread outputs
# directory and persist it as the normal report artifact.
fallback_markdown = await _write_builtin_markdown_fallback(
thread_id=body.thread_id,
kind=kind,
content=body.content,
)
artifacts.append(fallback_markdown)
markdown = [fallback_markdown]
if kind == "action_plan" and not any(_is_json_artifact(artifact) for artifact in artifacts):
raise HTTPException(status_code=422, detail="行动规划智能体还需交付 action-plan-subtasks.json 子任务清单")
@@ -27,6 +27,14 @@ async def store(tmp_path):
return PositionRoundtableRepository(async_sessionmaker(engine, expire_on_commit=False))
def test_reject_node_request_allows_an_optional_reason():
"""A rejection must work even when the reviewer leaves the reason blank."""
request = position_roundtable_router.RejectNodeRequest()
assert request.title == "产物驳回"
assert request.reason == ""
@pytest.mark.asyncio
async def test_session_activation_freezes_chain_and_creates_nodes_once(store):
created = await store.create_session(
@@ -186,6 +194,122 @@ def test_all_nodes_delivered_requires_markdown_for_non_intent_nodes():
)
@pytest.mark.asyncio
async def test_builtin_markdown_fallback_writes_visible_summary_to_thread_outputs(tmp_path, monkeypatch):
target = tmp_path / "outputs" / "岗位会商方案总结报告.md"
async def resolve(thread_id: str, path: str):
assert thread_id == "summary-thread"
assert path == "mnt/user-data/outputs/岗位会商方案总结报告.md"
return target
monkeypatch.setattr(position_roundtable_router, "aresolve_thread_virtual_path", resolve)
artifact = await position_roundtable_router._write_builtin_markdown_fallback(
thread_id="summary-thread",
kind="summary",
content="# 岗位会商方案总结报告\n\n这是对话中生成的完整报告正文。",
)
assert target.read_text(encoding="utf-8") == "# 岗位会商方案总结报告\n\n这是对话中生成的完整报告正文。\n"
assert artifact["name"] == "岗位会商方案总结报告.md"
assert artifact["path"] == "mnt/user-data/outputs/岗位会商方案总结报告.md"
assert artifact["mime_type"] == "text/markdown"
assert artifact["size_bytes"] == target.stat().st_size
@pytest.mark.asyncio
async def test_builtin_summary_completion_persists_fallback_markdown_when_agent_skips_write_file(monkeypatch):
node = {
"id": "node-1",
"session_id": "session-a",
"node_key": "intent-node",
"stage_index": 0,
"seat_index": 0,
"agent_id": "intent-agent",
"position_id": "intelligence",
"thread_id": "intent-thread",
"status": "done",
"latest_answer": "已确认任务意图",
"artifact_manifest": [],
"revision": 1,
"upstream_revision": None,
"rejection_count": 0,
"last_rejection": None,
"invalidated_by": None,
}
session = {
"id": "session-a",
"status": "active",
"task_snapshot": {},
"chain_id": "chain-a",
"chain_snapshot": {},
"intent_thread_id": "intent-thread",
"intent_snapshot": {"objective": "形成方案", "constraints": []},
"summary_thread_id": None,
"summary_snapshot": None,
"action_plan_thread_id": None,
"action_plan_snapshot": None,
}
class StubStore:
updates: dict | None = None
async def get_session(self, session_id, user_id):
assert (session_id, user_id) == ("session-a", "user-a")
return session
async def list_nodes(self, session_id, user_id):
assert (session_id, user_id) == ("session-a", "user-a")
return [node]
async def update_session(self, session_id, user_id, **updates):
assert (session_id, user_id) == ("session-a", "user-a")
self.updates = updates
return {**session, **updates}
store = StubStore()
async def write_fallback(**kwargs):
assert kwargs["thread_id"] == "summary-thread"
assert kwargs["kind"] == "summary"
assert kwargs["content"] == "# 方案总结\n\n完整报告正文"
return {
"name": "岗位会商方案总结报告.md",
"path": "mnt/user-data/outputs/岗位会商方案总结报告.md",
"mime_type": "text/markdown",
"modified_at": "2026-07-23T00:00:00+00:00",
"size_bytes": 42,
}
monkeypatch.setattr(position_roundtable_router, "_write_builtin_markdown_fallback", write_fallback)
request = SimpleNamespace(
app=SimpleNamespace(state=SimpleNamespace(position_roundtable_store=store)),
state=SimpleNamespace(user=SimpleNamespace(id="user-a")),
)
body = position_roundtable_router.CompleteBuiltinRunRequest(
thread_id="summary-thread",
content="# 方案总结\n\n完整报告正文",
artifact_manifest=[],
source_revisions={"intent-node": 1},
)
result = await position_roundtable_router._complete_builtin_run(
request,
"session-a",
kind="summary",
body=body,
)
assert store.updates is not None
snapshot = store.updates["summary_snapshot"]
assert snapshot["content"] == "# 方案总结\n\n完整报告正文"
assert snapshot["primary_artifact"]["name"] == "岗位会商方案总结报告.md"
assert result.summary_snapshot["primary_artifact"]["path"] == (
"mnt/user-data/outputs/岗位会商方案总结报告.md"
)
@pytest.mark.asyncio
async def test_node_updates_are_user_scoped_and_keep_thread_out_of_messages(store):
created = await store.create_session("user-a", external_task_id=None, task_snapshot={})