Merge pull request #2695 from pikasTech/fix/workbench-worker-failure-visibility
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success
修复 Workbench AgentRun 基础设施错误不可见
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,15 @@ export type WorkbenchActivityConfig = {
|
||||
retryMaximumAttempts: number;
|
||||
};
|
||||
|
||||
export interface WorkbenchEventPublisher {
|
||||
publish(input: {
|
||||
traceId: string;
|
||||
sessionId: string;
|
||||
event: Record<string, unknown>;
|
||||
}): Promise<Record<string, unknown>>;
|
||||
close?(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface WorkbenchApplication {
|
||||
health(): Promise<Record<string, unknown>>;
|
||||
createSession(input: { actor: WorkbenchActor; params: Record<string, unknown> }): Promise<Record<string, unknown>>;
|
||||
|
||||
@@ -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<Record<string, any>> = [];
|
||||
const publisher = createWorkbenchKafkaEventPublisher({
|
||||
env: kafkaEnvironment(),
|
||||
kafkaFactory: (() => ({
|
||||
producer() {
|
||||
return {
|
||||
async connect() {},
|
||||
async disconnect() {},
|
||||
async send(input: Record<string, any>) { 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<string, string> {
|
||||
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",
|
||||
};
|
||||
}
|
||||
@@ -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<string, string | undefined>;
|
||||
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<unknown> | 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 }); }
|
||||
@@ -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<Record<string, any>> = [];
|
||||
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<string, any>;
|
||||
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<string, string> {
|
||||
return {
|
||||
WORKBENCH_MODE: "agentrun-native",
|
||||
|
||||
@@ -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<string, Record<string, any>>;
|
||||
@@ -20,11 +20,13 @@ export function createNativeAgentRunWorkbenchApplication(options: {
|
||||
env: Record<string, string | undefined>;
|
||||
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 <T>(fn: (state: NativeState) => T | Promise<T>) => {
|
||||
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<typeof createCodeAgentTraceStore>, publisher: WorkbenchEventPublisher | null, input: WorkbenchTurnInput) {
|
||||
const pending: Promise<unknown>[] = [];
|
||||
const store = {
|
||||
...base,
|
||||
append(traceId: string, event: Record<string, any> = {}, meta: Record<string, unknown> = {}) {
|
||||
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<string, any>): 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<string, any>) {
|
||||
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<NativeState> {
|
||||
const parsed = JSON.parse(await readFile(stateFile, "utf8").catch(() => '{"sessions":{},"turns":{}}'));
|
||||
return { sessions: object(parsed.sessions), turns: object(parsed.turns) };
|
||||
|
||||
@@ -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<string, string | undefined> = process.env) {
|
||||
export function workbenchRuntime(env: Record<string, string | undefined> = 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");
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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: "<div class='loading-state'>{{ label }}</div>" },
|
||||
}
|
||||
}
|
||||
});
|
||||
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, {
|
||||
|
||||
@@ -155,7 +155,7 @@ function traceStorageKey(message: ChatMessage): string {
|
||||
<code v-if="semanticRuntimeStatus.code && semanticRuntimeStatus.emphasis === 'alert'">{{ semanticRuntimeStatus.code }}</code>
|
||||
</section>
|
||||
<TraceTimeline v-if="message.role === 'agent' && message.runnerTrace" :trace="message.runnerTrace" :auto-expanded="autoExpanded" :storage-key="traceStorageKey(message)" final-response-placement="outer" :outer-final-response-text="visibleText" />
|
||||
<LoadingState v-if="isAwaitingAgentBody(message)" class="message-loading" label="思考中..." compact />
|
||||
<LoadingState v-if="isAwaitingAgentBody(message)" class="message-loading" label="启动中..." compact />
|
||||
<MessageMarkdown v-if="visibleText" class="message-text" :source="visibleText" />
|
||||
<ApiErrorDiagnostic v-if="diagnostic.visible" class="message-diagnostic projection-diagnostic" :error="diagnostic.text" :api-error="diagnostic.apiError" :diagnostic="diagnostic.diagnostic" :show-message="diagnostic.showMessage" compact />
|
||||
</article>
|
||||
|
||||
@@ -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<Record<string, unknown>> {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user