diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index 2e9fe700..4f6e9c08 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -1071,6 +1071,11 @@ function rejectRemovedResourceWorkspaceFiles(value) { throw adapterError("legacy_workspace_files_removed", "workspaceFiles/resourceWorkspaceFiles are removed; use AgentRun ResourceBundleRef kind=gitbundle with bundles[]"); } +function validIsoTimestamp(value) { + const timestamp = firstNonEmpty(value); + return timestamp && Number.isFinite(Date.parse(timestamp)) ? new Date(Date.parse(timestamp)).toISOString() : null; +} + function adapterError(code, message, details = {}) { return Object.assign(new Error(message), { code, @@ -1092,6 +1097,7 @@ function buildAgentRunCommandInput({ params, traceId, backendProfile, sessionId, ...(promptEvidence?.prompt ? { promptMetadata: promptEvidence.prompt } : {}), traceId, userMessageId: agentRunUserMessageId(params, traceId), + submittedAt: validIsoTimestamp(params.submittedAt), projectId: firstNonEmpty(params.projectId) || null, conversationId: safeConversationId(params.conversationId) || null, sessionId: sessionId ?? scopedAgentRunSessionIdForParams(params, traceId), @@ -1121,6 +1127,7 @@ function buildAgentRunSteerCommandInput({ params, traceId, steerTraceId, mapped ...(promptEvidence?.prompt ? { promptMetadata: promptEvidence.prompt } : {}), traceId: steerTraceId, userMessageId: agentRunUserMessageId(params, steerTraceId), + submittedAt: validIsoTimestamp(params.submittedAt), targetTraceId: traceId, targetCommandId: mapped.agentRun.commandId, conversationId: safeConversationId(mapped.conversationId ?? params.conversationId) || null, diff --git a/internal/cloud/kafka-event-bridge.test.ts b/internal/cloud/kafka-event-bridge.test.ts index 8fe4de74..e11be694 100644 --- a/internal/cloud/kafka-event-bridge.test.ts +++ b/internal/cloud/kafka-event-bridge.test.ts @@ -168,6 +168,10 @@ test("projects only explicit AgentRun lifecycle timestamps", () => { }, { source: "hwlab-test" }); assert.equal(withoutFacts.event.startedAt, null); assert.equal(withoutFacts.event.finishedAt, null); + assert.equal(withoutFacts.event.createdAt, "2026-07-09T10:00:05.000Z"); + assert.equal(withoutFacts.event.submittedAt, null); + assert.equal(withoutFacts.event.timingClock, "event-created-at-fallback"); + assert.equal(withoutFacts.event.blocking, false); }); test("projects AgentRun assistant_message Kafka event into HWLAB trace event", () => { @@ -336,6 +340,7 @@ test("projects AgentRun user_message into a stable HWLAB user event", () => { traceId: "trc_user_message", targetTraceId: "trc_user_message_target", userMessageId: "msg_user_message_user", + submittedAt: "2026-07-10T09:59:55.000Z", text: "replay this input", source: "agentrun-turn-command-payload", valuesPrinted: false @@ -352,6 +357,10 @@ test("projects AgentRun user_message into a stable HWLAB user event", () => { assert.equal(projected.event.userMessageId, "msg_user_message_user"); assert.equal(projected.event.messageId, "msg_user_message_user"); assert.equal(projected.event.targetTraceId, "trc_user_message_target"); + assert.equal(projected.event.createdAt, "2026-07-10T09:59:55.000Z"); + assert.equal(projected.event.submittedAt, "2026-07-10T09:59:55.000Z"); + assert.equal(projected.event.timingClock, "user-submitted-at"); + assert.equal(projected.event.blocking, false); assert.equal(projected.event.text, "replay this input"); assert.equal(projected.event.sourceEventId, "evt_user_message"); assert.equal(projected.sourceEvent.partition, 1); diff --git a/internal/cloud/kafka-event-bridge.ts b/internal/cloud/kafka-event-bridge.ts index b358f711..f9ee3a1c 100644 --- a/internal/cloud/kafka-event-bridge.ts +++ b/internal/cloud/kafka-event-bridge.ts @@ -1516,8 +1516,10 @@ function mapAgentRunSourceEventToHwlabEvent({ input, sourceEvent, payload, run, if (type === "user_message") { const userMessageId = firstText(payload.userMessageId); const userText = textPayload(payload, "user message"); + const submittedAt = timestampValue(payload.submittedAt); return { ...base, + createdAt: submittedAt || base.createdAt, type: "user", eventType: "user", status: "sent", @@ -1529,6 +1531,9 @@ function mapAgentRunSourceEventToHwlabEvent({ input, sourceEvent, payload, run, userMessageId, targetTraceId: firstText(payload.targetTraceId), userSource: firstText(payload.source), + submittedAt, + timingClock: submittedAt ? "user-submitted-at" : "event-created-at-fallback", + blocking: false, terminal: false }; } diff --git a/internal/workbench/native-agentrun-application.test.ts b/internal/workbench/native-agentrun-application.test.ts index 4be12351..65754eaf 100644 --- a/internal/workbench/native-agentrun-application.test.ts +++ b/internal/workbench/native-agentrun-application.test.ts @@ -21,6 +21,7 @@ test("L1 AgentRun application dispatches gpt.pika without polling or projecting stateFile, env: agentRunEnvironment(), fetchImpl: fakeAgentRunManager(calls), + now: () => "2026-07-20T01:13:12.232Z", }); const created = await app.createSession({ actor: { id: "usr_native", role: "user" }, params: { providerProfile: "gpt.pika" } }); @@ -29,7 +30,7 @@ test("L1 AgentRun application dispatches gpt.pika without polling or projecting actor: { id: "usr_native", role: "user" }, traceId: "trc_l1_gpt_pika_smoke", sessionId: session.sessionId, - params: { message: "hi", providerProfile: "gpt.pika" }, + params: { message: "hi", submittedAt: "2026-07-20T01:13:07.699Z", providerProfile: "gpt.pika" }, }; const admitted = await app.admitTurn(input); const result = await app.dispatchTurn(admitted); @@ -48,6 +49,7 @@ test("L1 AgentRun application dispatches gpt.pika without polling or projecting const state = JSON.parse(await readFile(stateFile, "utf8")); expect(state.turns[input.traceId]).toMatchObject({ status: "running", + createdAt: "2026-07-20T01:13:07.699Z", terminal: false, runId: "run_l1_gpt_pika", commandId: "cmd_l1_gpt_pika", @@ -94,6 +96,9 @@ function agentRunEnvironment(): Record { AGENTRUN_API_KEY: "test-agentrun-key", HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1", HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID: "NC01", + HWLAB_CODE_AGENT_AGENTRUN_GPT_PIKA_SECRET_NAME: "provider-gpt-pika", + HWLAB_CODE_AGENT_AGENTRUN_GITHUB_TOOL_SECRET_NAME: "agentrun-test-tool-github-pr", + HWLAB_CODE_AGENT_AGENTRUN_UNIDESK_SSH_TOOL_SECRET_NAME: "agentrun-test-tool-unidesk-ssh", HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT: "0123456789abcdef0123456789abcdef01234567", HWLAB_CODE_AGENT_AGENTRUN_REPO_URL: "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git", HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "gpt.pika", @@ -126,6 +131,7 @@ function fakeAgentRunManager(calls: Array<{ method: string; path: string; author } if (method === "POST" && url.pathname === "/api/v1/runs/run_l1_gpt_pika/commands") { expect(body.payload.prompt).toBe("hi"); + expect(body.payload.submittedAt).toBe("2026-07-20T01:13:07.699Z"); return send({ id: "cmd_l1_gpt_pika", runId: "run_l1_gpt_pika", diff --git a/internal/workbench/native-agentrun-application.ts b/internal/workbench/native-agentrun-application.ts index 997541ce..49e6cd79 100644 --- a/internal/workbench/native-agentrun-application.ts +++ b/internal/workbench/native-agentrun-application.ts @@ -76,7 +76,7 @@ export function createNativeAgentRunWorkbenchApplication(options: { async admitTurn(input) { return mutate((state) => { const session = ownedSession(state, input.sessionId, input.actor.id); - const timestamp = now(); + const timestamp = validTimestamp(input.params.submittedAt) ?? now(); const message = requiredText(input.params.message, "message"); state.turns[input.traceId] = { traceId: input.traceId, @@ -93,7 +93,7 @@ export function createNativeAgentRunWorkbenchApplication(options: { session.status = "running"; session.lastTraceId = input.traceId; session.updatedAt = timestamp; - return { ...input, params: { ...input.params, message, providerProfile: text(input.params.providerProfile) || session.providerProfile } }; + return { ...input, params: { ...input.params, message, submittedAt: timestamp, providerProfile: text(input.params.providerProfile) || session.providerProfile } }; }); }, async dispatchTurn(input: WorkbenchTurnInput) { @@ -163,6 +163,7 @@ async function load(stateFile: string): Promise { } function object(value: unknown) { return value && typeof value === "object" && !Array.isArray(value) ? value as Record> : {}; } function text(value: unknown) { return String(value ?? "").trim(); } +function validTimestamp(value: unknown) { const timestamp = text(value); return timestamp && Number.isFinite(Date.parse(timestamp)) ? new Date(Date.parse(timestamp)).toISOString() : null; } function requiredText(value: unknown, field: string) { const result = text(value); if (!result) throw codedError("invalid_input", `${field} is required`); return result; } function validOptionalId(value: unknown, prefix: string) { const id = text(value); if (!id) return null; if (!id.startsWith(prefix)) throw codedError("invalid_input", `${prefix.slice(0, -1)} id is invalid`); return id; } function ownedSession(state: NativeState, sessionId: string, ownerUserId: string) { const session = state.sessions[sessionId]; if (!session || session.ownerUserId !== ownerUserId) throw codedError("session_not_found", "session is not visible to the actor"); return session; } diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index b0c3a713..80b03b2a 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -545,7 +545,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { chatPending.value = true; rememberRecentDraft(value); recordActivity("submit"); - const payload = { message: value, prompt: value, sessionId, threadId: providerThreadId, traceId, userMessageId, providerProfile: providerProfile.value, gatewayShellTimeoutMs: gatewayShellTimeoutMs.value, targetTraceId: composer.value.targetTraceId, steerTraceId }; + const payload = { message: value, prompt: value, submittedAt, sessionId, threadId: providerThreadId, traceId, userMessageId, providerProfile: providerProfile.value, gatewayShellTimeoutMs: gatewayShellTimeoutMs.value, targetTraceId: composer.value.targetTraceId, steerTraceId }; const response = steerMode ? await workbenchColadaMutations.steerAgentMessage({ payload, timeoutMs: codeAgentTimeoutMs.value, activityRef: () => activityRef.value, sessionId, traceId }) : await workbenchColadaMutations.submitAgentMessage({ payload, timeoutMs: codeAgentTimeoutMs.value, activityRef: () => activityRef.value, sessionId, traceId });