diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index b8c3f2aa..0d3b9fe1 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -967,6 +967,57 @@ test("access controller restores AgentRun mapping by Code Agent traceId", async assert.equal(restored.session.agentRun.namespace, "agentrun-v01"); }); +test("access controller preserves AgentRun runner mapping across conversation snapshot updates", async () => { + const accessController = createAccessController({ now: () => "2026-06-01T00:00:00.000Z" }); + await accessController.recordAgentSessionOwner({ + ownerUserId: "usr_agent_owner", + sessionId: "ses_agentrun_reuse_mapping", + projectId: "prj_v02_code_agent", + conversationId: "cnv_agentrun_reuse_mapping", + threadId: "thread-agentrun-requested", + traceId: "trc_agentrun_reuse_first", + status: "running", + session: { + source: "hwlab-cloud-api-agentrun-v01-adapter", + agentRun: { + adapter: "agentrun-v01", + runId: "run_reuse_mapping", + commandId: "cmd_reuse_mapping_first", + runnerId: "runner_reuse_mapping", + jobName: "agentrun-v01-runner-reuse-mapping", + namespace: "agentrun-v01", + backendProfile: "deepseek", + reuseEligible: true, + valuesPrinted: false + }, + valuesRedacted: true + } + }); + + await accessController.recordAgentSessionOwner({ + ownerUserId: "usr_agent_owner", + sessionId: "ses_agentrun_reuse_mapping", + projectId: "prj_v02_code_agent", + conversationId: "cnv_agentrun_reuse_mapping", + threadId: "thread-agentrun-requested", + traceId: "trc_agentrun_reuse_second", + status: "active", + session: { + source: "workbench-conversation-snapshot", + messages: [{ role: "assistant", content: "done" }], + valuesRedacted: true + } + }); + + const restored = await accessController.getAgentSession("ses_agentrun_reuse_mapping"); + assert.equal(restored.lastTraceId, "trc_agentrun_reuse_second"); + assert.equal(restored.session.source, "workbench-conversation-snapshot"); + assert.equal(restored.session.agentRun.runId, "run_reuse_mapping"); + assert.equal(restored.session.agentRun.commandId, "cmd_reuse_mapping_first"); + assert.equal(restored.session.agentRun.jobName, "agentrun-v01-runner-reuse-mapping"); + assert.equal(restored.session.agentRun.reuseEligible, true); +}); + test("cloud api protects device-pod routes when access control is required", async () => { const server = createCloudApiServer({ env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index bfe193fc..6d9ff12f 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -1872,7 +1872,14 @@ function publicJob(job) { return { id: job.id, devicePodId: job.devicePodId, own function jobRefs(job) { return { jobId: job.id, traceId: job.traceId, operationId: job.operationId }; } function formatEventLine(event) { return [event.ts?.slice(11, 19) ?? "00:00:00", event.scope?.toUpperCase() ?? "JOB", event.status, event.intent, event.summary, event.refs?.traceId ? `trace=${event.refs.traceId}` : null, event.blocker?.code ? `blocker=${event.blocker.code}` : null].filter(Boolean).join(" "); } function normalizeJob(input) { return { id: input.id, devicePodId: input.devicePodId, ownerUserId: input.ownerUserId, status: input.status, intent: input.intent, args: normalizeObject(input.args), reason: input.reason ?? "", traceId: input.traceId, operationId: input.operationId, output: normalizeObject(input.output), blocker: input.blocker ?? null, createdAt: input.now, updatedAt: input.now, completedAt: input.completedAt ?? null }; } -function normalizeAgentSessionOwnerRecord(input, existing, now) { return { id: input.sessionId, projectId: input.projectId ?? existing?.projectId ?? "prj_v02_code_agent", agentId: input.agentId ?? existing?.agentId ?? "hwlab-code-agent", status: input.status ?? existing?.status ?? "active", startedAt: existing?.startedAt ?? input.startedAt ?? now, endedAt: input.endedAt ?? existing?.endedAt ?? null, ownerUserId: input.ownerUserId, conversationId: input.conversationId ?? existing?.conversationId ?? null, threadId: input.threadId ?? existing?.threadId ?? null, lastTraceId: input.traceId ?? input.lastTraceId ?? existing?.lastTraceId ?? null, session: normalizeObject(input.session ?? existing?.session), updatedAt: now }; } +function normalizeAgentSessionOwnerRecord(input, existing, now) { return { id: input.sessionId, projectId: input.projectId ?? existing?.projectId ?? "prj_v02_code_agent", agentId: input.agentId ?? existing?.agentId ?? "hwlab-code-agent", status: input.status ?? existing?.status ?? "active", startedAt: existing?.startedAt ?? input.startedAt ?? now, endedAt: input.endedAt ?? existing?.endedAt ?? null, ownerUserId: input.ownerUserId, conversationId: input.conversationId ?? existing?.conversationId ?? null, threadId: input.threadId ?? existing?.threadId ?? null, lastTraceId: input.traceId ?? input.lastTraceId ?? existing?.lastTraceId ?? null, session: mergeAgentSessionOwnerEvidence(input.session, existing?.session), updatedAt: now }; } +function mergeAgentSessionOwnerEvidence(nextValue, existingValue) { + const existing = normalizeObject(existingValue); + const next = normalizeObject(nextValue); + const merged = { ...existing, ...next }; + if (!next.agentRun && existing.agentRun) merged.agentRun = existing.agentRun; + return merged; +} function agentSessionIdForRecord(sessionId, traceId) { const sessionText = textOr(sessionId, ""); if (/^ses_[A-Za-z0-9_.:-]+$/u.test(sessionText)) return sessionText; const traceText = textOr(traceId, ""); return /^trc_[A-Za-z0-9_.:-]+$/u.test(traceText) ? `ses_pending_${traceText.slice(4)}` : null; } function safeConversationIdLocal(value) { return /^cnv_[A-Za-z0-9_.:-]+$/u.test(textOr(value, "")); } function boundedListLimit(value) { const parsed = Number.parseInt(String(value ?? ""), 10); return Math.min(Math.max(Number.isInteger(parsed) && parsed > 0 ? parsed : 20, 1), 100); } diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index be08ebc9..c8d9bb97 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -961,6 +961,7 @@ function agentRunReusedMapping({ previous = {}, run = {}, command = {}, traceId, commandState: command.state ?? null, terminalStatus: null, lastSeq: previous.lastSeq ?? 0, + runnerJobCount: 0, traceId, sessionId: run?.sessionRef?.sessionId ?? previous.sessionId ?? null, conversationId: run?.sessionRef?.conversationId ?? previous.conversationId ?? null, diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index 79f8a41e..5673452c 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -384,11 +384,13 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte assert.equal(secondPayload.agentRun.providerTrace.traceId, secondTraceId); assert.equal(secondPayload.agentRun.jobName, "agentrun-v01-runner-hwlab-adapter"); assert.equal(secondPayload.sessionReuse.reused, true); + assert.equal(secondPayload.agentRun.runnerJobCount, 0); assert.match(secondPayload.reply.content, /复用已有 runner/u); assert.ok(secondPayload.runnerTrace.events.some((event) => event.label === "agentrun:run:reused")); assert.ok(secondPayload.runnerTrace.events.some((event) => event.label === "agentrun:runner-job:reused")); assert.equal(calls.filter((call) => call.method === "POST" && call.path === "/api/v1/runs").length, 1); assert.equal(calls.filter((call) => call.method === "POST" && call.path === "/api/v1/runs/run_hwlab_adapter/runner-jobs").length, 1); + assert.equal(calls.filter((call) => call.method === "POST" && call.path === "/api/v1/runs/run_hwlab_adapter/commands").length, 2); } finally { await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); await new Promise((resolve, reject) => agentRunServer.close((error) => (error ? reject(error) : resolve()))); diff --git a/web/hwlab-cloud-web/app-conversation.ts b/web/hwlab-cloud-web/app-conversation.ts index 37411274..0f464818 100644 --- a/web/hwlab-cloud-web/app-conversation.ts +++ b/web/hwlab-cloud-web/app-conversation.ts @@ -1371,9 +1371,9 @@ function messageTraceToolbar(message, trace, events, rows) { function messageTraceCountText(trace, rawTotal, loadedTotal, readableTotal) { if (trace?.eventsCompacted === true && trace?.fullTraceLoaded !== true) { - return `完整 trace 回放中 / 当前可读事件 ${readableTotal} / 已载入原始 ${loadedTotal} / 后端原始 ${rawTotal}`; + return `完整 trace 回放中 / 当前渲染行 ${readableTotal} / 已载入原始 ${loadedTotal} / 后端原始 ${rawTotal}`; } - return `显示全部可读事件 ${readableTotal} / 已载入原始 ${loadedTotal} / 后端原始 ${rawTotal}`; + return `显示全部渲染行 ${readableTotal} / 已载入原始 ${loadedTotal} / 后端原始 ${rawTotal}`; } function renderTraceEventList(list, rows) { diff --git a/web/hwlab-cloud-web/app-trace.test.ts b/web/hwlab-cloud-web/app-trace.test.ts index 578a15af..dc9bb62d 100644 --- a/web/hwlab-cloud-web/app-trace.test.ts +++ b/web/hwlab-cloud-web/app-trace.test.ts @@ -171,6 +171,61 @@ test("trace display rows collapse AgentRun reused backend lifecycle noise", () = assert.match(rows[0].body ?? "", /HWLAB663_REUSE_T2_OK/u); }); +test("trace display rows render non-noise AgentRun tool item details", () => { + const events = [ + event(1, "agentrun:request:accepted", { message: "accepted" }), + event(2, "agentrun:tool:item/started", { + type: "tool_call", + status: "running", + toolName: "devicePod.list", + itemId: "tool_device_pod_list", + outputBytes: 128, + message: "tool call" + }), + event(3, "agentrun:tool:item/completed", { + type: "tool_call", + status: "completed", + toolName: "devicePod.list", + itemId: "tool_device_pod_list", + outputBytes: 2048, + message: "tool call" + }), + event(4, "agentrun:tool:item/started", { + type: "tool_call", + status: "running", + toolName: "reasoning", + itemId: "rs_noise", + outputBytes: 32, + message: "tool call" + }), + event(5, "agentrun:tool:item/completed", { + type: "tool_call", + status: "completed", + toolName: "reasoning", + itemId: "rs_noise", + outputBytes: 64, + message: "tool call" + }), + event(6, "agentrun:assistant:message", { + type: "assistant", + status: "completed", + terminal: true, + message: "当前可见 2 个 device-pod。" + }) + ]; + + const rows = traceDisplayRows({ traceId: "trc_agentrun_tool_item", events }, events); + const text = rows.map((row) => `${row.header}\n${row.body ?? ""}`).join("\n---\n"); + + assert.equal(rows.length, 2); + assert.match(text, /工具调用:devicePod\.list/u); + assert.match(text, /toolName=devicePod\.list/u); + assert.match(text, /itemId=tool_device_pod_list/u); + assert.match(text, /outputBytes=2048/u); + assert.equal(/reasoning/u.test(text), false); + assert.match(text, /当前可见 2 个 device-pod/u); +}); + function traceEvents() { return [ event(1, "request:accepted", { promptSummary: "请检查 trace" }), diff --git a/web/hwlab-cloud-web/app-trace.ts b/web/hwlab-cloud-web/app-trace.ts index 4d0b496a..2a61ac16 100644 --- a/web/hwlab-cloud-web/app-trace.ts +++ b/web/hwlab-cloud-web/app-trace.ts @@ -287,10 +287,12 @@ function setConversationScrollTop(top, { user = true } = {}) { export function traceDisplayRows(trace, events) { const rows = []; const commandGroups = traceCommandExecutionGroups(events); + const toolCallGroups = traceGenericToolCallGroups(events); const requestEvent = tracePrimaryRequestEvent(events); const hasTerminalAssistant = events.some((event) => isTerminalAssistantTraceEvent(event)); const terminalAssistantOnly = hasTerminalAssistant && !events.some((event) => isCommandExecutionTraceEvent(event)); const renderedCommands = new Set(); + const renderedToolCalls = new Set(); let requestRendered = false; let setupRendered = false; let completionRendered = false; @@ -309,6 +311,17 @@ export function traceDisplayRows(trace, events) { } continue; } + if (isGenericToolCallTraceEvent(event)) { + const groupKey = traceToolCallGroupKey(event); + const group = toolCallGroups.get(groupKey) ?? { key: groupKey, started: event, completed: event, events: [event] }; + const renderHere = event.label?.endsWith("/started") || group.started === event || (!group.started && group.completed === event); + if (renderHere && !renderedToolCalls.has(groupKey)) { + const row = traceGenericToolCallSummaryRow(trace, group); + if (row) rows.push(row); + renderedToolCalls.add(groupKey); + } + continue; + } if (isAssistantMessageTraceEvent(event)) { const row = traceAssistantSummaryRow(trace, event, assistantIndex); assistantIndex += 1; @@ -388,10 +401,29 @@ function traceCommandExecutionGroups(events) { return groups; } +function traceGenericToolCallGroups(events) { + const groups = new Map(); + for (const event of events) { + if (!isGenericToolCallTraceEvent(event)) continue; + const key = traceToolCallGroupKey(event); + if (!groups.has(key)) groups.set(key, { key, started: null, completed: null, events: [] }); + const group = groups.get(key); + group.events.push(event); + const status = String(event.status ?? "").toLowerCase(); + if (event.label?.endsWith("/started") || status === "running" || status === "started") group.started = group.started ?? event; + if (event.label?.endsWith("/completed") || status === "completed") group.completed = event; + } + return groups; +} + function traceCommandGroupKey(event, fallback = null) { return traceNonEmptyString(event?.itemId ?? event?.callId ?? event?.commandId ?? event?.id) || fallback || `cmd:${event?.seq ?? event?.createdAt ?? Math.random()}`; } +function traceToolCallGroupKey(event) { + return traceNonEmptyString(event?.itemId ?? event?.callId ?? event?.id) || `tool:${event?.toolName ?? "call"}:${event?.seq ?? event?.createdAt ?? Math.random()}`; +} + function traceCommandSummaryRow(trace, group) { const start = group.started ?? group.completed ?? group.outputs?.[0]; const completed = group.completed ?? null; @@ -488,6 +520,30 @@ function traceCommandTitle(command) { return "命令执行"; } +function traceGenericToolCallSummaryRow(trace, group) { + const start = group.started ?? group.completed ?? group.events?.[0]; + const completed = group.completed ?? null; + if (!start) return null; + const toolName = traceNonEmptyString(completed?.toolName ?? start.toolName) || "tool"; + const itemId = traceNonEmptyString(completed?.itemId ?? start.itemId); + const status = completed ? traceStatusToken(completed) : "running"; + const bytes = completed?.outputBytes ?? start.outputBytes; + const parts = [ + `toolName=${toolName}`, + itemId ? `itemId=${compactTraceOneLine(itemId, 96)}` : null, + `status=${status}`, + Number.isFinite(bytes) ? `outputBytes=${bytes}` : null, + completed?.outputTruncated === true || start.outputTruncated === true ? "outputTruncated=true" : null + ].filter(Boolean); + return { + rowId: `tool-item-summary:${group.key}`, + seq: start.seq ?? null, + tone: completed ? (status === "ok" ? "ok" : traceEventTone(completed)) : "source", + header: `${traceClock(start.createdAt)} 🔧 工具调用:${toolName}`, + body: parts.join("\n") + }; +} + function displayTraceCommand(command) { let text = cleanTraceOutputText(command); text = text.replace(/^\/?\s*bin\s*\/\s*bash\s+-lc\s+(["'])([\s\S]*)\1$/u, "$2"); @@ -914,6 +970,22 @@ function isCommandExecutionTraceEvent(event) { (event?.label === "item/commandExecution:started" || event?.label === "item/commandExecution:completed"); } +function isGenericToolCallTraceEvent(event) { + if (event?.type !== "tool_call") return false; + if (isCommandExecutionTraceEvent(event)) return false; + if (isToolOutputChunkTraceEvent(event)) return false; + if (isLowValueAgentRunToolItem(event)) return false; + const label = String(event?.label ?? ""); + return label === "agentrun:tool:item/started" || label === "agentrun:tool:item/completed"; +} + +function isLowValueAgentRunToolItem(event) { + const label = String(event?.label ?? ""); + if (!/^agentrun:tool:item\/(started|completed)$/u.test(label)) return false; + const toolName = String(event?.toolName ?? "").trim(); + return /^(userMessage|reasoning|agentMessage|assistantMessage)$/u.test(toolName); +} + function isAssistantMessageTraceEvent(event) { const label = String(event?.label ?? ""); return event?.type === "assistant_message" || event?.type === "assistant" || label.startsWith("assistant:") || label === "agentrun:assistant:message"; @@ -956,10 +1028,10 @@ function isNoisyTraceEvent(event) { const label = String(event?.label ?? ""); if (isAgentRunLifecycleNoiseEvent(event)) return true; if (isToolOutputChunkTraceEvent(event)) return false; - if (isCommandExecutionTraceEvent(event) || isAssistantMessageTraceEvent(event)) return false; + if (isCommandExecutionTraceEvent(event) || isGenericToolCallTraceEvent(event) || isAssistantMessageTraceEvent(event)) return false; if (isRequestTraceEvent(event) || isSetupTraceEvent(event) || isCompletionTraceEvent(event)) return false; if (isLowValueAgentRunBackendEvent(label)) return true; - if (/^agentrun:tool:item\/(started|completed)$/u.test(label)) return true; + if (isLowValueAgentRunToolItem(event)) return true; if (/token_count|outputDelta:chunk/iu.test(label)) return true; if (event?.type === "event" && !event.outputSummary && !event.message && !event.errorCode) return true; return false;