From f9c54ae7ea61f24ab759ee46a4b7eda8aa2818aa Mon Sep 17 00:00:00 2001 From: root Date: Mon, 20 Jul 2026 14:50:06 +0200 Subject: [PATCH] fix: expose Workbench dispatch failures --- internal/cloud/code-agent-agentrun-result.ts | 2 + internal/workbench/contracts.ts | 9 ++ .../workbench/kafka-event-publisher.test.ts | 75 ++++++++++++ internal/workbench/kafka-event-publisher.ts | 68 +++++++++++ .../native-agentrun-application.test.ts | 57 +++++++++ .../workbench/native-agentrun-application.ts | 112 +++++++++++++++++- internal/workbench/runtime.ts | 6 +- .../workbench-realtime-runtime.test.ts | 22 +++- .../workbench/WorkbenchMessageCard.test.ts | 31 +++++ .../workbench/WorkbenchMessageCard.vue | 2 +- .../src/stores/workbench-timeline-model.ts | 10 +- 11 files changed, 386 insertions(+), 8 deletions(-) create mode 100644 internal/workbench/kafka-event-publisher.test.ts create mode 100644 internal/workbench/kafka-event-publisher.ts diff --git a/internal/cloud/code-agent-agentrun-result.ts b/internal/cloud/code-agent-agentrun-result.ts index e181a679..b5b91b87 100644 --- a/internal/cloud/code-agent-agentrun-result.ts +++ b/internal/cloud/code-agent-agentrun-result.ts @@ -79,6 +79,7 @@ const RETRYABLE_AGENTRUN_TRANSPORT_FAILURE_KINDS = new Set([ "agentrun-timeout", "timeout", "fetch-failed", + "connection-refused", "network-error" ]); @@ -755,6 +756,7 @@ export function isRetryableProviderInterruptionPayload(payload = {}, options = { export function normalizedFailureKind(value) { const normalized = String(value ?? "").trim().toLowerCase().replace(/_/gu, "-") || null; if (normalized === "agentrun-unreachable") return "agentrun-connect-failed"; + if (normalized === "connectionrefused" || normalized === "econnrefused") return "connection-refused"; return normalized; } diff --git a/internal/workbench/contracts.ts b/internal/workbench/contracts.ts index e50a387a..8120eb28 100644 --- a/internal/workbench/contracts.ts +++ b/internal/workbench/contracts.ts @@ -33,6 +33,15 @@ export type WorkbenchActivityConfig = { retryMaximumAttempts: number; }; +export interface WorkbenchEventPublisher { + publish(input: { + traceId: string; + sessionId: string; + event: Record; + }): Promise>; + close?(): Promise; +} + export interface WorkbenchApplication { health(): Promise>; createSession(input: { actor: WorkbenchActor; params: Record }): Promise>; diff --git a/internal/workbench/kafka-event-publisher.test.ts b/internal/workbench/kafka-event-publisher.test.ts new file mode 100644 index 00000000..8f3d8603 --- /dev/null +++ b/internal/workbench/kafka-event-publisher.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from "bun:test"; + +import { createWorkbenchKafkaEventPublisher } from "./kafka-event-publisher.ts"; + +test("L0 publisher writes semantic activity to hwlab.event.v1", async () => { + const sent: Array> = []; + const publisher = createWorkbenchKafkaEventPublisher({ + env: kafkaEnvironment(), + kafkaFactory: (() => ({ + producer() { + return { + async connect() {}, + async disconnect() {}, + async send(input: Record) { sent.push(input); return [{ partition: 0, baseOffset: "7" }]; }, + }; + }, + })) as never, + now: () => "2026-07-20T11:45:01.000Z", + }); + + await publisher.publish({ + traceId: "trc_l0_publish", + sessionId: "ses_l0_publish", + event: { + type: "error", + failureDomain: "infrastructure", + component: "agentrun-manager", + code: "connection-refused", + failureKind: "connection-refused", + retryPhase: "retryExhausted", + retryable: true, + attempt: 1, + maxAttempts: 1, + summary: "AgentRun 服务暂时不可达", + }, + }); + await publisher.close?.(); + + expect(sent).toHaveLength(1); + expect(sent[0]?.topic).toBe("hwlab.event.v1"); + const envelope = JSON.parse(sent[0]?.messages[0].value); + expect(envelope).toMatchObject({ + schema: "hwlab.event.v1", + traceId: "trc_l0_publish", + sessionId: "ses_l0_publish", + event: { + type: "error", + status: "failed", + failureDomain: "infrastructure", + failureComponent: "agentrun-manager", + failureCode: "connection-refused", + retryPhase: "retryExhausted", + retryAttempt: 1, + retryMaxAttempts: 1, + terminal: false, + source: "hwlab.workbench.activity", + }, + }); +}); + +function kafkaEnvironment(): Record { + return { + HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED: "false", + HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED: "true", + HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED: "false", + HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED: "false", + HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED: "false", + HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED: "false", + HWLAB_KAFKA_BOOTSTRAP_SERVERS: "127.0.0.1:9092", + HWLAB_KAFKA_AGENTRUN_EVENT_TOPIC: "agentrun.event.v1", + HWLAB_KAFKA_EVENT_TOPIC: "hwlab.event.v1", + HWLAB_KAFKA_CLIENT_ID: "hwlab-workbench-l0", + HWLAB_KAFKA_HWLAB_EVENT_GROUP_ID: "hwlab-workbench-l0-live", + }; +} diff --git a/internal/workbench/kafka-event-publisher.ts b/internal/workbench/kafka-event-publisher.ts new file mode 100644 index 00000000..09551ade --- /dev/null +++ b/internal/workbench/kafka-event-publisher.ts @@ -0,0 +1,68 @@ +import { randomUUID } from "node:crypto"; + +import { + buildHwlabKafkaProducerMessage, + defaultKafkaFactory, + kafkaEventBridgeConfig, + projectAgentRunKafkaEventToHwlabEvent, +} from "../cloud/kafka-event-bridge.ts"; +import type { WorkbenchEventPublisher } from "./contracts.ts"; + +export function createWorkbenchKafkaEventPublisher(options: { + env: Record; + kafkaFactory?: typeof defaultKafkaFactory; + now?: () => string; +}): WorkbenchEventPublisher { + const config = kafkaEventBridgeConfig(options.env); + if (!config) throw codedError("workbench_kafka_publisher_unconfigured", "Workbench event publisher requires Kafka realtime configuration."); + const kafka = (options.kafkaFactory ?? defaultKafkaFactory)(config); + const producer = kafka.producer({ allowAutoTopicCreation: false }); + let ready: Promise | null = null; + const now = options.now ?? (() => new Date().toISOString()); + + return { + async publish(input) { + ready ??= producer.connect(); + await ready; + const createdAt = timestamp(input.event.createdAt) ?? now(); + const sourceEventId = text(input.event.sourceEventId) || `workbench:${input.traceId}:${randomUUID()}`; + const sourceType = text(input.event.type) || "backend_status"; + const source = { + schema: "hwlab.workbench.activity.v1", + eventId: sourceEventId, + traceId: input.traceId, + sessionId: input.sessionId, + event: { + id: sourceEventId, + type: sourceType, + traceId: input.traceId, + createdAt, + payload: { + ...input.event, + type: sourceType, + traceId: input.traceId, + sessionId: input.sessionId, + }, + }, + }; + const envelope = projectAgentRunKafkaEventToHwlabEvent(source, { + source: `${config.clientId}:workbench-activity`, + producedAt: createdAt, + }); + if (!envelope) throw codedError("workbench_kafka_event_projection_failed", "Workbench event could not be projected to hwlab.event.v1."); + envelope.event.source = "hwlab.workbench.activity"; + envelope.diagnostics.projectedBy = "hwlab-workbench-kafka-event-publisher"; + await producer.send({ topic: config.hwlabTopic, messages: [buildHwlabKafkaProducerMessage(envelope)] }); + return { published: true, topic: config.hwlabTopic, traceId: input.traceId, sessionId: input.sessionId, sourceEventId, valuesPrinted: false }; + }, + async close() { + if (!ready) return; + await ready.catch(() => undefined); + await producer.disconnect(); + }, + }; +} + +function text(value: unknown): string { return String(value ?? "").trim(); } +function timestamp(value: unknown): string | null { const result = text(value); return result && Number.isFinite(Date.parse(result)) ? new Date(Date.parse(result)).toISOString() : null; } +function codedError(code: string, message: string) { return Object.assign(new Error(message), { code }); } diff --git a/internal/workbench/native-agentrun-application.test.ts b/internal/workbench/native-agentrun-application.test.ts index 65754eaf..64304cc0 100644 --- a/internal/workbench/native-agentrun-application.test.ts +++ b/internal/workbench/native-agentrun-application.test.ts @@ -89,6 +89,63 @@ test("L1 AgentRun application leaves dispatch failures to Temporal instead of pr expect((await app.snapshot!()).turns[input.traceId]).toMatchObject({ status: "admitted", terminal: false }); }); +test("L0 publishes admission, bounded ConnectionRefused retry, and semantic terminal failure to Kafka authority", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "hwlab-workbench-agentrun-visible-failure-")); + temporaryDirectories.push(directory); + const published: Array> = []; + let runCreateAttempts = 0; + const app = createNativeAgentRunWorkbenchApplication({ + stateFile: path.join(directory, "state.json"), + env: agentRunEnvironment(), + eventPublisher: { + async publish(input) { published.push(input); return { published: true }; }, + }, + fetchImpl: (async (request) => { + const url = new URL(String(request)); + if (url.pathname === "/api/v1/sessions") { + return Response.json({ ok: true, data: { session: { id: "ses_agentrun_l0_visible_failure", metadata: {} } } }); + } + runCreateAttempts += 1; + throw Object.assign(new Error("Unable to connect. Is the computer able to access the url?"), { code: "ConnectionRefused" }); + }) as typeof fetch, + now: () => "2026-07-20T11:45:01.000Z", + }); + const created = await app.createSession({ actor: { id: "usr_native" }, params: { providerProfile: "gpt.pika" } }); + const session = created.session as Record; + const input = await app.admitTurn({ + actor: { id: "usr_native" }, + traceId: "trc_l0_connection_refused", + sessionId: session.sessionId, + params: { message: "hi", providerProfile: "gpt.pika" }, + }); + + await expect(app.dispatchTurn(input)).rejects.toMatchObject({ + failureKind: "connection-refused", + retryable: true, + retryExhausted: true, + retryAttempt: 1, + }); + expect(runCreateAttempts).toBe(2); + expect(published.map((item) => item.event.type)).toEqual([ + "backend_status", + "backend_status", + "error", + "terminal_status", + ]); + expect(published[0]?.event).toMatchObject({ phase: "request-admitted", summary: "请求已接纳,正在启动 AgentRun" }); + expect(published[1]?.event).toMatchObject({ + retryPhase: "retryScheduled", + failureDomain: "infrastructure", + component: "agentrun-manager", + code: "connection-refused", + attempt: 1, + maxAttempts: 1, + }); + expect(published[2]?.event).toMatchObject({ retryPhase: "retryExhausted", summary: "AgentRun 服务暂时不可达" }); + expect(published[3]?.event).toMatchObject({ terminalStatus: "failed", failureKind: "connection-refused", terminal: true }); + expect(published.every((item) => item.traceId === input.traceId && item.sessionId === input.sessionId)).toBe(true); +}); + function agentRunEnvironment(): Record { return { WORKBENCH_MODE: "agentrun-native", diff --git a/internal/workbench/native-agentrun-application.ts b/internal/workbench/native-agentrun-application.ts index 49e6cd79..8fd3decf 100644 --- a/internal/workbench/native-agentrun-application.ts +++ b/internal/workbench/native-agentrun-application.ts @@ -8,7 +8,7 @@ import { submitAgentRunChatTurn, } from "../cloud/code-agent-agentrun-adapter.ts"; import { createCodeAgentTraceStore } from "../cloud/code-agent-trace-store.ts"; -import type { WorkbenchApplication, WorkbenchTurnInput } from "./contracts.ts"; +import type { WorkbenchApplication, WorkbenchEventPublisher, WorkbenchTurnInput } from "./contracts.ts"; type NativeState = { sessions: Record>; @@ -20,11 +20,13 @@ export function createNativeAgentRunWorkbenchApplication(options: { env: Record; fetchImpl?: typeof fetch; now?: () => string; + eventPublisher?: WorkbenchEventPublisher | null; }): WorkbenchApplication { const env = options.env; const fetchImpl = options.fetchImpl ?? fetch; const now = options.now ?? (() => new Date().toISOString()); const traceStore = createCodeAgentTraceStore(); + const eventPublisher = options.eventPublisher ?? null; const mutate = async (fn: (state: NativeState) => T | Promise) => { const state = await load(options.stateFile); const result = await fn(state); @@ -74,7 +76,7 @@ export function createNativeAgentRunWorkbenchApplication(options: { }); }, async admitTurn(input) { - return mutate((state) => { + const admitted = await mutate((state) => { const session = ownedSession(state, input.sessionId, input.actor.id); const timestamp = validTimestamp(input.params.submittedAt) ?? now(); const message = requiredText(input.params.message, "message"); @@ -95,6 +97,21 @@ export function createNativeAgentRunWorkbenchApplication(options: { session.updatedAt = timestamp; return { ...input, params: { ...input.params, message, submittedAt: timestamp, providerProfile: text(input.params.providerProfile) || session.providerProfile } }; }); + await eventPublisher?.publish({ + traceId: admitted.traceId, + sessionId: admitted.sessionId, + event: { + type: "backend_status", + phase: "request-admitted", + status: "running", + summary: "请求已接纳,正在启动 AgentRun", + message: "请求已接纳,正在启动 AgentRun", + createdAt: admitted.params.submittedAt, + terminal: false, + valuesPrinted: false, + }, + }); + return admitted; }, async dispatchTurn(input: WorkbenchTurnInput) { const state = await load(options.stateFile); @@ -109,7 +126,15 @@ export function createNativeAgentRunWorkbenchApplication(options: { providerProfile: text(input.params.providerProfile) || session.providerProfile, shortConnection: true, }; - const admitted = await submitAgentRunChatTurn({ traceId: input.traceId, params, options: { env, fetchImpl }, traceStore }); + const publishingTrace = publishingDispatchTraceStore(traceStore, eventPublisher, input); + let admitted; + try { + admitted = await submitAgentRunChatTurn({ traceId: input.traceId, params, options: { env, fetchImpl }, traceStore: publishingTrace.store }); + } catch (error) { + await publishingTrace.flush().catch(() => undefined); + throw error; + } + await publishingTrace.flush().catch(() => undefined); await projectRunning(admitted, input.traceId); return { status: "running", @@ -122,6 +147,7 @@ export function createNativeAgentRunWorkbenchApplication(options: { valuesPrinted: false, }; }, + async close() { await eventPublisher?.close?.(); }, async cancelTurn(input) { const state = await load(options.stateFile); const turn = state.turns[input.traceId]; @@ -157,6 +183,86 @@ export function createNativeAgentRunWorkbenchApplication(options: { } } +function publishingDispatchTraceStore(base: ReturnType, publisher: WorkbenchEventPublisher | null, input: WorkbenchTurnInput) { + const pending: Promise[] = []; + const store = { + ...base, + append(traceId: string, event: Record = {}, meta: Record = {}) { + const normalized = base.append(traceId, event, meta); + if (publisher && dispatchVisibilityEvent(event)) { + pending.push(publishDispatchVisibility(publisher, input, { ...event, createdAt: normalized?.createdAt ?? event.createdAt })); + } + return normalized; + }, + }; + return { store, async flush() { await Promise.all(pending); } }; +} + +function dispatchVisibilityEvent(event: Record): boolean { + const label = text(event.label); + const visible = label.startsWith("agentrun:dispatch-retry:") + || label.startsWith("agentrun:dispatch-retry-exhausted:") + || label.startsWith("agentrun:dispatch-failed:"); + return visible && (event.willRetry === true || event.terminal === true); +} + +async function publishDispatchVisibility(publisher: WorkbenchEventPublisher, input: WorkbenchTurnInput, event: Record) { + const retrying = event.willRetry === true; + const exhausted = event.terminal === true; + const failureKind = text(event.failureKind || event.errorCode) || "agentrun-dispatch-error"; + const summary = dispatchFailureSummary(failureKind); + await publisher.publish({ + traceId: input.traceId, + sessionId: input.sessionId, + event: { + type: retrying ? "backend_status" : "error", + phase: retrying ? "dispatch-retry-scheduled" : "dispatch-retry-exhausted", + failureDomain: "infrastructure", + component: "agentrun-manager", + code: failureKind, + failureKind, + summary, + message: summary, + retryable: event.retryable === true, + willRetry: retrying, + retryPhase: retrying ? "retryScheduled" : "retryExhausted", + attempt: event.retryAttempt, + maxAttempts: event.retryMax, + backoffMs: event.retryDelayMs, + nextRetryAt: event.nextRetryAt, + createdAt: event.createdAt, + runId: event.runId ?? null, + commandId: event.commandId ?? null, + terminal: false, + valuesPrinted: false, + }, + }); + if (!exhausted) return; + await publisher.publish({ + traceId: input.traceId, + sessionId: input.sessionId, + event: { + type: "terminal_status", + terminalStatus: "failed", + status: "failed", + failureKind, + errorCode: failureKind, + message: `${summary},有限重试已耗尽。`, + createdAt: event.createdAt, + runId: event.runId ?? null, + commandId: event.commandId ?? null, + terminal: true, + valuesPrinted: false, + }, + }); +} + +function dispatchFailureSummary(failureKind: string): string { + if (failureKind === "connection-refused" || failureKind === "agentrun-connect-failed") return "AgentRun 服务暂时不可达"; + if (failureKind.includes("timeout")) return "AgentRun 服务响应超时"; + return "AgentRun 调度基础设施暂时不可用"; +} + async function load(stateFile: string): Promise { const parsed = JSON.parse(await readFile(stateFile, "utf8").catch(() => '{"sessions":{},"turns":{}}')); return { sessions: object(parsed.sessions), turns: object(parsed.turns) }; diff --git a/internal/workbench/runtime.ts b/internal/workbench/runtime.ts index 108ce6cb..a7d6bb8e 100644 --- a/internal/workbench/runtime.ts +++ b/internal/workbench/runtime.ts @@ -6,15 +6,17 @@ import { createNativeTestWorkbenchApplication } from "./native-application.ts"; import { createNativeAgentRunWorkbenchApplication } from "./native-agentrun-application.ts"; import { createNativeTestTemporalGateway } from "./native-temporal.ts"; import { createWorkbenchTemporalGateway } from "./temporal.ts"; +import { createWorkbenchKafkaEventPublisher } from "./kafka-event-publisher.ts"; +import type { WorkbenchEventPublisher } from "./contracts.ts"; -export function workbenchRuntime(env: Record = process.env) { +export function workbenchRuntime(env: Record = process.env, options: { eventPublisher?: WorkbenchEventPublisher } = {}) { const mode = String(env.WORKBENCH_MODE ?? "").trim(); if (mode !== "native-test" && mode !== "agentrun-native" && mode !== "temporal") throw codedError("workbench_mode_required", "WORKBENCH_MODE must be native-test, agentrun-native, or temporal"); const stateFile = path.resolve(String(env.WORKBENCH_NATIVE_STATE_FILE ?? ".state/workbench-native/state.json")); const application = mode === "native-test" ? createNativeTestWorkbenchApplication({ stateFile }) : mode === "agentrun-native" - ? createNativeAgentRunWorkbenchApplication({ stateFile, env }) + ? createNativeAgentRunWorkbenchApplication({ stateFile, env, eventPublisher: options.eventPublisher ?? createWorkbenchKafkaEventPublisher({ env }) }) : createCloudWorkbenchApplication({ baseUrl: String(env.WORKBENCH_CLOUD_API_URL ?? ""), authorization: String(env.WORKBENCH_CLOUD_API_AUTHORIZATION ?? "") }); const temporalAddress = String(env.WORKBENCH_TEMPORAL_ADDRESS ?? env.TEMPORAL_ADDRESS ?? ""); const temporalNamespace = String(env.WORKBENCH_TEMPORAL_NAMESPACE ?? "unidesk"); diff --git a/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts b/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts index e3878a49..f359814f 100644 --- a/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts @@ -425,7 +425,7 @@ test("timeline model deduplicates authoritative messages with stable row keys", test("timeline model preserves OpenCode turn gap, thinking, retry and error row parity", () => { const rows = buildWorkbenchTimelineRows([ { id: "msg_user_1", messageId: "msg_user_1", role: "user", title: "用户", text: "hi", status: "sent", createdAt: "2026-06-30T00:00:00.000Z", sessionId: "ses_a", traceId: "trc_a" }, - { id: "msg_agent_1", messageId: "msg_agent_1", role: "agent", title: "Code Agent", text: "", status: "running", createdAt: "2026-06-30T00:00:01.000Z", sessionId: "ses_a", traceId: "trc_a" }, + { id: "msg_agent_1", messageId: "msg_agent_1", role: "agent", title: "Code Agent", text: "", status: "running", createdAt: "2026-06-30T00:00:01.000Z", sessionId: "ses_a", traceId: "trc_a", runnerTrace: { traceId: "trc_a", events: [] } }, { id: "msg_user_2", messageId: "msg_user_2", role: "user", title: "用户", text: "again", status: "sent", createdAt: "2026-06-30T00:00:02.000Z", sessionId: "ses_a", traceId: "trc_b" }, { id: "msg_agent_2", messageId: "msg_agent_2", role: "agent", title: "Code Agent", text: "", status: "retry" as never, createdAt: "2026-06-30T00:00:03.000Z", sessionId: "ses_a", traceId: "trc_b", error: { message: "Error: {\"error\":{\"type\":\"Provider\",\"message\":\"offline\"}}" } } ]); @@ -436,6 +436,26 @@ test("timeline model preserves OpenCode turn gap, thinking, retry and error row assert.ok(rows.some((row) => row.type === "Error" && row.text === "Provider: offline")); }); +test("timeline model leaves pre-Kafka startup loading to the message card", () => { + const rows = buildWorkbenchTimelineRows([ + { id: "msg_user", messageId: "msg_user", role: "user", title: "用户", text: "hi", status: "sent", createdAt: "2026-06-30T00:00:00.000Z", sessionId: "ses_start", traceId: "trc_start" }, + { id: "msg_agent", messageId: "msg_agent", role: "agent", title: "Code Agent", text: "", status: "running", createdAt: "2026-06-30T00:00:01.000Z", sessionId: "ses_start", traceId: "trc_start" } + ]); + assert.equal(rows.filter((row) => row.type === "Thinking").length, 0); +}); + +test("timeline model labels Kafka admission as startup and suppresses thinking during infrastructure failure", () => { + const startupRows = buildWorkbenchTimelineRows([ + { id: "msg_agent_start", messageId: "msg_agent_start", role: "agent", title: "Code Agent", text: "", status: "running", createdAt: "2026-06-30T00:00:01.000Z", sessionId: "ses_start", traceId: "trc_start", runnerTrace: { traceId: "trc_start", events: [{ type: "backend", label: "agentrun:backend:request-admitted", message: "请求已接纳,正在启动 AgentRun" }] } } + ]); + assert.ok(startupRows.some((row) => row.type === "Thinking" && row.reasoningHeading === "启动中...")); + + const failureRows = buildWorkbenchTimelineRows([ + { id: "msg_agent_fail", messageId: "msg_agent_fail", role: "agent", title: "Code Agent", text: "", status: "running", createdAt: "2026-06-30T00:00:01.000Z", sessionId: "ses_fail", traceId: "trc_fail", runnerTrace: { traceId: "trc_fail", events: [{ type: "error", label: "agentrun:error:connection-refused", failureDomain: "infrastructure", retryPhase: "retryScheduled" }] } } + ]); + assert.equal(failureRows.filter((row) => row.type === "Thinking").length, 0); +}); + test("timeline model documents OpenCode row parity and preserves comment/diff rows", () => { assert.ok(WORKBENCH_TIMELINE_OPENCODE_PARITY.some((row) => row.openCodeRows === "Permission" && row.status === "not-applicable")); assert.ok(WORKBENCH_TIMELINE_OPENCODE_PARITY.some((row) => row.openCodeRows === "Tool" && row.status === "adapted")); diff --git a/web/hwlab-cloud-web/src/components/workbench/WorkbenchMessageCard.test.ts b/web/hwlab-cloud-web/src/components/workbench/WorkbenchMessageCard.test.ts index 01d79650..0dcbe9c7 100644 --- a/web/hwlab-cloud-web/src/components/workbench/WorkbenchMessageCard.test.ts +++ b/web/hwlab-cloud-web/src/components/workbench/WorkbenchMessageCard.test.ts @@ -15,6 +15,37 @@ function componentStubs() { }; } +describe("WorkbenchMessageCard startup visibility", () => { + it("shows one startup label before the first Kafka event", () => { + const wrapper = mount(WorkbenchMessageCard, { + props: { + detailEnabled: false, + message: { + id: "msg_starting_agent", + messageId: "msg_starting_agent", + role: "agent", + title: "Code Agent", + text: "", + status: "running", + traceId: "trc_starting", + sessionId: "ses_starting", + createdAt: "2026-07-20T10:00:00.000Z", + } + }, + global: { + stubs: { + ...componentStubs(), + LoadingState: { props: ["label"], template: "
{{ label }}
" }, + } + } + }); + expect(wrapper.text()).toContain("启动中..."); + expect(wrapper.text()).not.toContain("思考中..."); + expect(wrapper.findAll(".loading-state")).toHaveLength(1); + wrapper.unmount(); + }); +}); + describe("WorkbenchMessageCard semantic retry visibility", () => { it("shows Git mirror clone progress as runtime preparation", () => { const wrapper = mount(WorkbenchMessageCard, { diff --git a/web/hwlab-cloud-web/src/components/workbench/WorkbenchMessageCard.vue b/web/hwlab-cloud-web/src/components/workbench/WorkbenchMessageCard.vue index 5e9db2b3..8ad56495 100644 --- a/web/hwlab-cloud-web/src/components/workbench/WorkbenchMessageCard.vue +++ b/web/hwlab-cloud-web/src/components/workbench/WorkbenchMessageCard.vue @@ -155,7 +155,7 @@ function traceStorageKey(message: ChatMessage): string { {{ semanticRuntimeStatus.code }} - + diff --git a/web/hwlab-cloud-web/src/stores/workbench-timeline-model.ts b/web/hwlab-cloud-web/src/stores/workbench-timeline-model.ts index 6a3aebd1..a265e314 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-timeline-model.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-timeline-model.ts @@ -189,7 +189,13 @@ function messageHasCommentStrip(message: ChatMessage): boolean { } function messageIsThinking(message: ChatMessage): boolean { - return message.role === "agent" && messageIsActive(message) && !String(message.text ?? "").trim(); + const events = Array.isArray(message.runnerTrace?.events) ? message.runnerTrace.events : []; + const latest = events.at(-1); + return message.role === "agent" + && Boolean(message.runnerTrace) + && typeof latest?.failureDomain !== "string" + && messageIsActive(message) + && !String(message.text ?? "").trim(); } function messageIsRetry(message: ChatMessage): boolean { @@ -223,6 +229,8 @@ function summaryDiffs(message: ChatMessage): Array> { function reasoningHeading(message: ChatMessage): string | null { const traceEvents = Array.isArray(message.runnerTrace?.events) ? message.runnerTrace.events : []; + const latest = traceEvents.at(-1); + if (String(latest?.label ?? "") === "agentrun:backend:request-admitted") return "启动中..."; const reasoning = traceEvents.map((event) => firstNonEmptyString(event.text, event.message, event.outputSummary)).find(Boolean) ?? null; return reasoning ? extractHeading(reasoning) : null; }