From 8f9a23c1ced70edef778731a7e9020f80d3db4da Mon Sep 17 00:00:00 2001 From: Akuma <2374973868@qq.com> Date: Thu, 3 Sep 2026 20:36:54 +0800 Subject: [PATCH 01/11] fix(v2): group AgentRecall runtime sessions --- .../fix-issue-535-runtime-sessions.md | 7 + .../main/agents/runtime/runtime-driver.ts | 2 + .../runtime/runtime-invocation-recorder.ts | 50 +++ .../runtime/runtime-router-invocation.test.ts | 141 ++++++++ .../main/agents/runtime/runtime-router.ts | 300 +++++++++++++++++- .../engine/main/evaluation-runner.ts | 11 +- .../engine/main/hub/agent-hub.test.ts | 4 + .../automation/engine/main/hub/agent-hub.ts | 26 +- .../main/hub/chat/agent-hub-interactive.ts | 5 + .../executor/claude/claude-workflow.ts | 2 + .../executor/codex/codex-executor.test.ts | 1 + .../runtime/executor/codex/codex-workflow.ts | 1 + .../executor/dsh/dsh-capabilities.test.ts | 2 + .../runtime/executor/dsh/dsh-executor.test.ts | 1 + .../runtime/executor/dsh/dsh-workflow.test.ts | 1 + .../hub/runtime/executor/dsh/dsh-workflow.ts | 1 + .../executor/hermes/hermes-workflow.ts | 1 + .../executor/openclaw/openclaw-workflow.ts | 1 + .../executor/opencode/opencode-workflow.ts | 1 + .../runtime-onboarding-contract.test.ts | 8 +- .../main/hub/runtime/run/agent-hub-runner.ts | 19 ++ .../workflow/agent-hub-workflow-agent.test.ts | 1 + .../hub/workflow/agent-hub-workflow-agent.ts | 2 + ...configured-agent-execution-service.test.ts | 2 + .../configured-agent-execution-service.ts | 5 + .../src/automation/engine/shared/types.ts | 15 + .../src/core/evaluation/nodes/contracts.ts | 15 +- .../src/core/evaluation/nodes/judge-nodes.ts | 12 +- .../core/evaluation/nodes/prepare-nodes.ts | 6 + .../postgres/runtime-invocation-repository.ts | 81 +++++ .../main-2.0/src/core/postgres/schema.test.ts | 78 ++++- apps/main-2.0/src/core/postgres/schema.ts | 204 ++++++++++++ .../src/core/postgres/session-records.ts | 111 +++++++ .../src/core/postgres/session-repository.ts | 27 ++ .../postgres/session-search-repository.ts | 30 +- .../src/core/postgres/session-search.test.ts | 77 +++++ apps/main-2.0/src/core/session-store.ts | 7 + apps/main-2.0/src/core/types.ts | 26 ++ .../src/main/ipc/session-catalog.test.ts | 31 ++ apps/main-2.0/src/main/ipc/session-catalog.ts | 18 ++ .../src/main/services/automation-service.ts | 34 +- .../main/services/evaluation-service.test.ts | 6 +- .../src/main/services/evaluation-service.ts | 17 +- .../main/services/session-catalog-service.ts | 6 + .../main/team-chat/team-chat-service.test.ts | 7 + .../src/main/team-chat/team-chat-service.ts | 8 + apps/main-2.0/src/preload/index.ts | 3 + apps/main-2.0/src/renderer/src/App.tsx | 42 ++- .../automation/workflow-feature-page.test.tsx | 48 +++ .../automation/workflow-feature-page.tsx | 20 ++ .../session-detail/detail-panel.test.tsx | 33 +- .../features/session-detail/detail-panel.tsx | 78 +++++ .../src/features/sessions/session-details.tsx | 3 + .../features/sessions/sessions-page.test.tsx | 9 + .../src/features/sessions/sessions-page.tsx | 27 ++ .../sessions/use-session-catalog.test.tsx | 2 +- .../features/sessions/use-session-catalog.ts | 15 +- .../team-chat/team-chat-page.test.tsx | 29 +- .../src/features/team-chat/team-chat-page.tsx | 26 ++ .../renderer/src/styles/session-detail.css | 34 ++ 60 files changed, 1746 insertions(+), 34 deletions(-) create mode 100644 .release-notes/fix-issue-535-runtime-sessions.md create mode 100644 apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-invocation-recorder.ts create mode 100644 apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-router-invocation.test.ts create mode 100644 apps/main-2.0/src/core/postgres/runtime-invocation-repository.ts create mode 100644 apps/main-2.0/src/main/ipc/session-catalog.test.ts diff --git a/.release-notes/fix-issue-535-runtime-sessions.md b/.release-notes/fix-issue-535-runtime-sessions.md new file mode 100644 index 000000000..40f263a4e --- /dev/null +++ b/.release-notes/fix-issue-535-runtime-sessions.md @@ -0,0 +1,7 @@ +# 归类 AgentRecall 发起的 Runtime 会话 + + + +## Bug 修复 + +- AgentRecall 发起的 Runtime 会话现在会在 Session 页面单独归组,并展示调用来源、状态与返回入口,不再混入默认会话列表。 diff --git a/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-driver.ts b/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-driver.ts index 592103c8d..f08274b30 100644 --- a/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-driver.ts +++ b/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-driver.ts @@ -7,6 +7,7 @@ import type { RuntimeContinuationPolicy, RuntimeConversation, RuntimeExecutionMode, + RuntimeExecutionReference, RuntimeRequest, WorkflowAgentEvent, WorkflowAgentResponse, @@ -52,6 +53,7 @@ export interface RuntimeWorkflowRequestContext extends RuntimeRequest { workDir: string; onEvent?: ((event: WorkflowAgentEvent) => void) | undefined; signal?: AbortSignal | undefined; + reportExecutionReference?: ((reference: RuntimeExecutionReference) => void) | undefined; } export interface RuntimeChannelTestContext { diff --git a/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-invocation-recorder.ts b/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-invocation-recorder.ts new file mode 100644 index 000000000..f12198e9e --- /dev/null +++ b/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-invocation-recorder.ts @@ -0,0 +1,50 @@ +import type { + AgentId, + RuntimeInvocationRequest, +} from "../../../shared/types"; + +export type RuntimeInvocationStatus = + | "pending" + | "completed" + | "failed" + | "cancelled" + | "timed_out"; + +export type RuntimeSessionRelation = "created" | "continued"; + +export interface RuntimeInvocationStart { + id: string; + initiator: "agentrecall"; + invocation: RuntimeInvocationRequest; + runtimeId: AgentId; + channelId?: string; + environmentId?: string; + startedAt: number; +} + +export interface RuntimeSessionBinding { + runtimeId: AgentId; + channelId?: string; + environmentId?: string; + sessionId: string; + turnId?: string; + relation: RuntimeSessionRelation; + boundAt: number; +} + +export interface RuntimeInvocationRecorder { + begin(input: RuntimeInvocationStart): Promise; + bind(invocationId: string, binding: RuntimeSessionBinding): Promise; + finish( + invocationId: string, + status: Exclude, + finishedAt: number, + error?: string, + ): Promise; +} + +export const NOOP_RUNTIME_INVOCATION_RECORDER: RuntimeInvocationRecorder = { + begin: async () => undefined, + bind: async () => undefined, + finish: async () => undefined, +}; diff --git a/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-router-invocation.test.ts b/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-router-invocation.test.ts new file mode 100644 index 000000000..6c71d6067 --- /dev/null +++ b/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-router-invocation.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test, vi } from "vitest"; + +import type { AgentRuntime, RuntimeConversation } from "../../../shared/types"; +import { codexRuntimeStateCodec } from "../codex/codex-runtime-state-codec"; +import { RuntimeDriverRegistry, type RuntimeDriver } from "./runtime-driver"; +import type { + RuntimeInvocationRecorder, + RuntimeInvocationStart, + RuntimeInvocationStatus, + RuntimeSessionBinding, +} from "./runtime-invocation-recorder"; +import { RuntimeRouter } from "./runtime-router"; + +const runtime: AgentRuntime = { + id: "codex", + label: "Codex", + command: "codex", + version: "test", + available: true, +}; + +function conversation(sessionId: string): RuntimeConversation { + return codexRuntimeStateCodec.encodeConversation({ native: { threadId: sessionId } }); +} + +function request(runtimeConversation?: RuntimeConversation) { + return { + requestId: "request-1", + prompt: "Run it", + configuredAgentId: "agent-1", + runtimeId: "codex" as const, + executionMode: "oneshot" as const, + continuationPolicy: runtimeConversation ? "resume-required" as const : "fresh" as const, + runtimeConfig: { model: "default" }, + ...(runtimeConversation ? { runtimeConversation } : {}), + invocation: { + surface: "workflow" as const, + role: "node", + ownerReference: { workflowId: "workflow-1", runId: "run-1" }, + }, + runtime, + channelId: "codex-default", + workDir: "/workspace", + }; +} + +function recorder(events: string[]): RuntimeInvocationRecorder { + return { + begin: vi.fn(async (input: RuntimeInvocationStart) => { + events.push(`begin:${input.invocation.surface}`); + }), + bind: vi.fn(async (_invocationId: string, binding: RuntimeSessionBinding) => { + events.push(`bind:${binding.sessionId}:${binding.relation}`); + }), + finish: vi.fn(async ( + _invocationId: string, + status: Exclude, + ) => { + events.push(`finish:${status}`); + }), + }; +} + +function driver(askWorkflow: NonNullable): RuntimeDriver { + return { + runtimeId: "codex", + surfaceSupport: [{ + surface: "workflow", + executionModes: ["oneshot"], + continuationPolicies: ["fresh", "resume-required"], + }], + runtimeStateCodec: codexRuntimeStateCodec, + getCapabilities: () => ({ + runtimeId: "codex", + chatStyle: "oneshot", + taskStyle: "oneshot", + workflowStyle: "oneshot", + testStyle: "oneshot", + supportsInterrupt: true, + supportsContinue: true, + supportsApprovalRequests: true, + supportsUserInputRequests: true, + resume: { + supportsInProcessConversationResume: true, + supportsResumeAfterDetach: true, + supportsResumeAfterAppRestart: true, + supportsTurnResume: true, + }, + }), + askWorkflow, + }; +} + +describe("RuntimeRouter invocation lifecycle", () => { + test("persists pending before dispatch and binds a created Session even when Runtime fails", async () => { + const events: string[] = []; + const runtimeDriver = driver(async (input) => { + events.push("driver"); + input.reportExecutionReference?.({ sessionId: "thread-created", turnId: "turn-1" }); + throw new Error("runtime failed"); + }); + const router = new RuntimeRouter( + new RuntimeDriverRegistry([runtimeDriver]), + recorder(events), + () => 1_000, + () => "invocation-1", + ); + + await expect(router.askWorkflow(request())).rejects.toThrow("runtime failed"); + expect(events).toEqual([ + "begin:workflow", + "driver", + "bind:thread-created:created", + "finish:failed", + ]); + }); + + test("records another invocation as continued when it resumes the same Session", async () => { + const events: string[] = []; + const runtimeDriver = driver(async (input) => { + input.reportExecutionReference?.({ sessionId: "thread-existing", turnId: "turn-2" }); + return { + content: "done", + runtimeConversation: conversation("thread-existing"), + executionReference: { sessionId: "thread-existing", turnId: "turn-2" }, + }; + }); + const router = new RuntimeRouter( + new RuntimeDriverRegistry([runtimeDriver]), + recorder(events), + () => 2_000, + () => "invocation-2", + ); + + await expect(router.askWorkflow(request(conversation("thread-existing")))) + .resolves.toMatchObject({ content: "done" }); + expect(events[0]).toBe("begin:workflow"); + expect(events.filter((event) => event === "bind:thread-existing:continued").length).toBeGreaterThan(0); + expect(events.at(-1)).toBe("finish:completed"); + }); +}); diff --git a/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-router.ts b/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-router.ts index 9e821e63f..36387d437 100644 --- a/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-router.ts +++ b/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-router.ts @@ -1,5 +1,15 @@ +import { randomUUID } from "node:crypto"; import type { AgentExecutionContext, AgentExecutor } from "../../hub/runtime/executor/agent-executor"; -import type { AgentId, AgentRuntime, RuntimeContinuationPolicy, RuntimeConversation, RuntimeExecutionMode } from "../../../shared/types"; +import type { + AgentEvent, + AgentId, + AgentRuntime, + RuntimeContinuationPolicy, + RuntimeConversation, + RuntimeExecutionMode, + RuntimeExecutionReference, + RuntimeInvocationRequest, +} from "../../../shared/types"; import { RuntimeDriverRegistry, } from "./runtime-driver"; @@ -14,6 +24,12 @@ import type { } from "./runtime-driver"; import type { RuntimeCapabilities } from "./runtime-capabilities"; import type { RuntimeStateCodec } from "./runtime-state-codec"; +import { + NOOP_RUNTIME_INVOCATION_RECORDER, + type RuntimeInvocationRecorder, + type RuntimeInvocationStatus, + type RuntimeSessionRelation, +} from "./runtime-invocation-recorder"; type RuntimeRequestLike = { runtimeId: AgentId; @@ -23,7 +39,12 @@ type RuntimeRequestLike = { }; export class RuntimeRouter { - constructor(private readonly registry: RuntimeDriverRegistry) {} + constructor( + private readonly registry: RuntimeDriverRegistry, + private readonly invocationRecorder: RuntimeInvocationRecorder = NOOP_RUNTIME_INVOCATION_RECORDER, + private readonly now: () => number = () => Date.now(), + private readonly createInvocationId: () => string = () => randomUUID(), + ) {} capabilitiesFor(runtime: AgentRuntime): RuntimeCapabilities { return this.registry.driverFor(runtime.id).getCapabilities(runtime); @@ -41,7 +62,44 @@ export class RuntimeRouter { if (!driver.createOneShotExecutor) { throw new Error(`${context.runtimeId} runtime does not provide one-shot execution for ${surface}.`); } - return driver.createOneShotExecutor(input); + let lifecycle: RuntimeInvocationLifecycle | undefined; + let cancelling = false; + const emit = input.emit; + const onExit = input.onExit; + const wrappedInput: AgentExecutionContext = { + ...input, + emit: (event) => { + if (lifecycle) this.observeAgentEvent(lifecycle, event, cancelling); + emit(event); + }, + onExit: (code) => { + if (lifecycle && !lifecycle.isFinished()) { + void lifecycle.finish(code && code !== 0 ? "failed" : "completed"); + } + onExit(code); + }, + }; + const executor = driver.createOneShotExecutor(wrappedInput); + return { + start: async () => { + lifecycle = this.createLifecycle(input, input.channelId); + await lifecycle.begin(input.runtimeConversation); + try { + await executor.start(); + } catch (error) { + await lifecycle.finish(this.statusForError(error), error); + throw error; + } + }, + stop: async () => { + cancelling = true; + try { + await executor.stop(); + } finally { + await lifecycle?.finish("cancelled"); + } + }, + }; } createInteractiveSession(context: InteractiveSessionContext): InteractiveSession { @@ -49,7 +107,68 @@ export class RuntimeRouter { if (!driver.createInteractiveSession) { throw new Error(`${context.runtimeId} runtime does not provide interactive chat sessions.`); } - return driver.createInteractiveSession(input); + let currentInput = input; + let lifecycle: RuntimeInvocationLifecycle | undefined; + let cancelling = false; + const wrap = (next: InteractiveSessionContext): InteractiveSessionContext => ({ + ...next, + emit: (event) => { + if (lifecycle) this.observeAgentEvent(lifecycle, event, cancelling); + next.emit(event); + }, + }); + const session = driver.createInteractiveSession(wrap(input)); + const ensureInvocation = async (): Promise => { + if (lifecycle && !lifecycle.isFinished()) return lifecycle; + cancelling = false; + lifecycle = this.createLifecycle(currentInput, currentInput.channelId); + await lifecycle.begin(currentInput.runtimeConversation); + return lifecycle; + }; + return { + reconfigure: (next) => { + currentInput = next; + session.reconfigure(wrap(next)); + }, + ensureAttached: async () => { + const active = await ensureInvocation(); + try { + await session.ensureAttached(); + } catch (error) { + await active.finish(this.statusForError(error), error); + throw error; + } + }, + sendPrompt: async (prompt) => { + const active = await ensureInvocation(); + try { + await session.sendPrompt(prompt); + await active.finish("completed"); + } catch (error) { + await active.finish(this.statusForError(error), error); + throw error; + } + }, + interrupt: async () => { + cancelling = true; + try { + await session.interrupt(); + } finally { + await lifecycle?.finish("cancelled"); + } + }, + detach: async (reason) => { + try { + await session.detach(reason); + } finally { + if (lifecycle && !lifecycle.isFinished()) { + await lifecycle.finish(reason === "error" ? "failed" : "cancelled"); + } + } + }, + detachIfStillExpired: (detachInput) => session.detachIfStillExpired(detachInput), + snapshot: () => session.snapshot(), + }; } async askWorkflow(input: RuntimeWorkflowRequestContext) { @@ -57,7 +176,31 @@ export class RuntimeRouter { if (!driver.askWorkflow) { throw new Error(`${input.runtimeId} runtime does not provide workflow execution.`); } - return driver.askWorkflow(normalizedInput); + const lifecycle = this.createLifecycle(normalizedInput, normalizedInput.channelId); + await lifecycle.begin(normalizedInput.runtimeConversation); + const reportExecutionReference = normalizedInput.reportExecutionReference; + try { + const response = await driver.askWorkflow({ + ...normalizedInput, + reportExecutionReference: (reference) => { + lifecycle.bindReference(reference); + reportExecutionReference?.(reference); + }, + onEvent: (event) => { + if (event.type === "completed" && event.runtimeConversation) { + lifecycle.bindConversation(event.runtimeConversation); + } + normalizedInput.onEvent?.(event); + }, + }); + if (response.runtimeConversation) lifecycle.bindConversation(response.runtimeConversation); + if (response.executionReference) lifecycle.bindReference(response.executionReference); + await lifecycle.finish("completed"); + return response; + } catch (error) { + await lifecycle.finish(this.statusForError(error, normalizedInput.signal), error); + throw error; + } } async testChannel(runtimeId: AgentId, input: RuntimeChannelTestContext): Promise { @@ -65,7 +208,24 @@ export class RuntimeRouter { if (!driver.testChannel) { throw new Error(`${runtimeId} runtime testing is not configured.`); } - return driver.testChannel(input); + const lifecycle = this.createLifecycle({ + runtimeId, + continuationPolicy: "fresh", + invocation: { + surface: "system", + role: "channel_test", + ownerReference: { channelId: input.channelId }, + }, + }, input.channelId); + await lifecycle.begin(); + try { + const result = await driver.testChannel(input); + await lifecycle.finish("completed"); + return result; + } catch (error) { + await lifecycle.finish(this.statusForError(error), error); + throw error; + } } async deleteSessionArtifacts(runtimeId: AgentId, input: RuntimeSessionCleanupContext): Promise { @@ -183,4 +343,132 @@ export class RuntimeRouter { throw new Error(`${runtimeId} cannot use runtimeConversation owned by ${conversation.runtimeId}.`); } } + + private createLifecycle( + input: { + runtimeId: AgentId; + continuationPolicy: RuntimeContinuationPolicy; + runtimeConversation?: RuntimeConversation; + invocation: RuntimeInvocationRequest; + }, + channelId?: string, + ): RuntimeInvocationLifecycle { + return new RuntimeInvocationLifecycle({ + recorder: this.invocationRecorder, + invocationId: this.createInvocationId(), + invocation: input.invocation, + runtimeId: input.runtimeId, + channelId, + relation: input.runtimeConversation ? "continued" : "created", + now: this.now, + sessionIdFromConversation: (conversation) => this.sessionIdFromConversation(conversation), + }); + } + + private observeAgentEvent( + lifecycle: RuntimeInvocationLifecycle, + event: AgentEvent, + cancelling: boolean, + ): void { + if (event.type === "runtime_conversation") lifecycle.bindConversation(event.runtimeConversation); + else if (event.type === "completed") void lifecycle.finish("completed"); + else if (event.type === "error") { + void lifecycle.finish(cancelling ? "cancelled" : this.statusForError(event.error), event.error); + } + } + + private sessionIdFromConversation(conversation: RuntimeConversation): string | undefined { + const payload = conversation.payload; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined; + const native = (payload as Record).native; + if (!native || typeof native !== "object" || Array.isArray(native)) return undefined; + const record = native as Record; + const value = conversation.runtimeId === "codex" ? record.threadId : record.sessionId; + return typeof value === "string" && value ? value : undefined; + } + + private statusForError(error: unknown, signal?: AbortSignal): Exclude { + const name = error instanceof Error ? error.name : ""; + const message = error instanceof Error ? error.message : String(error); + if (name === "TimeoutError" || /timed out|timeout/iu.test(message)) return "timed_out"; + if (signal?.aborted || name === "AbortError" || /interrupt|cancel/iu.test(message)) return "cancelled"; + return "failed"; + } +} + +class RuntimeInvocationLifecycle { + private writeQueue: Promise = Promise.resolve(); + private finished = false; + + constructor(private readonly options: { + recorder: RuntimeInvocationRecorder; + invocationId: string; + invocation: RuntimeInvocationRequest; + runtimeId: AgentId; + channelId?: string; + relation: RuntimeSessionRelation; + now: () => number; + sessionIdFromConversation: (conversation: RuntimeConversation) => string | undefined; + }) {} + + async begin(conversation?: RuntimeConversation): Promise { + await this.options.recorder.begin({ + id: this.options.invocationId, + initiator: "agentrecall", + invocation: this.options.invocation, + runtimeId: this.options.runtimeId, + ...(this.options.channelId ? { channelId: this.options.channelId } : {}), + environmentId: "local", + startedAt: this.options.now(), + }); + if (conversation) { + const sessionId = this.options.sessionIdFromConversation(conversation); + if (sessionId) await this.bindReference({ sessionId }); + } + } + + bindConversation(conversation: RuntimeConversation): void { + const sessionId = this.options.sessionIdFromConversation(conversation); + if (sessionId) void this.bindReference({ sessionId }); + } + + async bindReference(reference: RuntimeExecutionReference): Promise { + if (!reference.sessionId) return; + const binding = { + runtimeId: this.options.runtimeId, + ...(this.options.channelId ? { channelId: this.options.channelId } : {}), + environmentId: "local", + sessionId: reference.sessionId, + ...(reference.turnId ? { turnId: reference.turnId } : {}), + relation: this.options.relation, + boundAt: this.options.now(), + } as const; + this.writeQueue = this.writeQueue.then(() => + this.options.recorder.bind(this.options.invocationId, binding)); + await this.writeQueue; + } + + isFinished(): boolean { + return this.finished; + } + + async finish( + status: Exclude, + error?: unknown, + ): Promise { + if (this.finished) return this.writeQueue; + this.finished = true; + const message = error === undefined + ? undefined + : error instanceof Error + ? error.message + : String(error); + this.writeQueue = this.writeQueue.then(() => this.options.recorder.finish( + this.options.invocationId, + status, + this.options.now(), + message, + )); + await this.writeQueue; + } } diff --git a/apps/main-2.0/src/automation/engine/main/evaluation-runner.ts b/apps/main-2.0/src/automation/engine/main/evaluation-runner.ts index 971ca83c1..0bfca60a8 100644 --- a/apps/main-2.0/src/automation/engine/main/evaluation-runner.ts +++ b/apps/main-2.0/src/automation/engine/main/evaluation-runner.ts @@ -33,6 +33,8 @@ export type EvaluationExecutionRequest = { configuredAgentId: string; prompt: string; developerInstructions?: string; + role: string; + ownerReference: Record; }; export interface EvaluationExecutionResult { @@ -64,6 +66,8 @@ export interface RunEvaluationInput { executeJudge?: ( runtimeId: string, prompt: string, + role: string, + ownerReference: Record, signal?: AbortSignal, ) => Promise<{ output: string; durationMs: number }>; /** Reads the SKILL.md bytes and hash of the skill this experiment injects. */ @@ -113,6 +117,8 @@ export async function runEvaluation(input: RunEvaluationInput): Promise - input.executeJudge!(request.runtimeId, request.prompt, signal), + input.executeJudge!(request.runtimeId, request.prompt, request.role, { + runId, + ...request.ownerReference, + }, signal), } : {}), ...(input.readSkill ? { readSkill: input.readSkill } : {}), diff --git a/apps/main-2.0/src/automation/engine/main/hub/agent-hub.test.ts b/apps/main-2.0/src/automation/engine/main/hub/agent-hub.test.ts index 80333b517..78635e353 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/agent-hub.test.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/agent-hub.test.ts @@ -528,6 +528,7 @@ test("workflow and generic Agent execution use the correct instruction scope", a })]); const response = await hub.askWorkflowAgent({ + invocation: { surface: "workflow" }, prompt: "Plan the repo", configuredAgentId: "hermes-agent", runtimeId: "hermes", @@ -546,6 +547,7 @@ test("workflow and generic Agent execution use the correct instruction scope", a })); await hub.askConfiguredAgent({ + invocation: { surface: "evaluation" }, prompt: "Evaluate the answer", configuredAgentId: "hermes-agent", runtimeId: "hermes", @@ -3428,6 +3430,7 @@ fs.writeFileSync(${JSON.stringify(argsPath)}, process.argv.slice(2).join("\\n") : agent)); await hub.askWorkflowAgent({ + invocation: { surface: "workflow" }, requestId: "bound-mcp-codex", prompt: "Use the bound server.", configuredAgentId: TEST_CODEX_AGENT_ID, @@ -3558,6 +3561,7 @@ fs.writeFileSync(${JSON.stringify(argsPath)}, process.argv.slice(2).join("\\n") (hub as any).runtimes.set("claude", { id: "claude", label: "Claude", command: "claude", version: "test", available: true }); await hub.askWorkflowAgent({ + invocation: { surface: "workflow" }, requestId: "bound-mcp-claude", prompt: "Use the bound server.", configuredAgentId: "claude-agent", diff --git a/apps/main-2.0/src/automation/engine/main/hub/agent-hub.ts b/apps/main-2.0/src/automation/engine/main/hub/agent-hub.ts index 319273fa3..81a4ae298 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/agent-hub.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/agent-hub.ts @@ -101,6 +101,7 @@ import { InteractiveSessionManager } from "../agents/runtime/interactive-session import type { CodexRpcClient } from "../agents/codex/codex-rpc"; import type { RuntimeCapabilities } from "../agents/runtime/runtime-capabilities"; import type { InteractiveSessionContext, InteractiveSessionSnapshot, RuntimeDriverRegistry, RuntimeSurface } from "../agents/runtime/runtime-driver"; +import type { RuntimeInvocationRecorder } from "../agents/runtime/runtime-invocation-recorder"; import { RuntimeRouter } from "../agents/runtime/runtime-router"; import { createRuntimeDriverRegistry, RuntimeAgentExecutorFactory, type AgentExecutorFactory } from "./runtime/executor/agent-executor"; import { queryProviderBalance, type ProviderBalanceQueryOptions } from "../channels/provider-balance"; @@ -424,6 +425,7 @@ export class AgentHub { runtimeDrivers?: RuntimeDriverRegistry, modelCatalogDiscoverer: ModelCatalogDiscoverer = discoverChannelModels, private readonly workflowMessageProvider?: WorkflowMessageProvider, + runtimeInvocationRecorder?: RuntimeInvocationRecorder, ) { this.executables = resolveRuntimeExecutables(executables); this.modelCatalogDiscoverer = modelCatalogDiscoverer; @@ -437,7 +439,7 @@ export class AgentHub { mcpServersForAgent: (configuredAgentId, allowedMcpTools) => this.boundMcpServersForAgent(configuredAgentId, allowedMcpTools), requestApproval: this.runtimeApprovals.request, }); - this.runtimeRouter = new RuntimeRouter(this.runtimeDrivers); + this.runtimeRouter = new RuntimeRouter(this.runtimeDrivers, runtimeInvocationRecorder); this.workflowStore = new WorkflowStore({ normalizeDraft: (draft) => this.cloneWorkflowDraft(draft), now: () => Date.now(), @@ -590,6 +592,11 @@ export class AgentHub { const executionMode = this.selectExecutionMode(resolved.runtimeAgentId, "workflow", "oneshot"); const response = await this.askWorkflowAgent({ workflowRunId: runId, + invocation: { + surface: "workflow", + role: "recovery_manager", + ownerReference: { workflowId, runId }, + }, prompt: [ "You are the read-only Manager Agent for transaction recovery.", "Use only the supplied evidence. Do not call tools, modify files, send messages, execute compensation, or change transaction state.", @@ -1483,7 +1490,7 @@ export class AgentHub { try { await this.workflowGenerationReviewCoordinator.run({ workflow, - askReviewer: (prompt, onEvent, signal) => this.askWorkflowAgent({ planningWorkflowId: workflow.workflowId, workflowReviewRevision: workflow.revision, prompt, configuredAgentId: workflow.reviewerConfiguredAgentId, runtimeId: reviewer.runtimeAgentId, executionMode, continuationPolicy: this.defaultContinuationPolicy(reviewer.runtimeAgentId, "workflow", executionMode), runtimeConfig: { model: reviewer.modelId, ...(reviewer.reasoningEffort ? { reasoningEffort: reviewer.reasoningEffort } : {}) }, workDir: workflow.workDir || this.workDir }, onEvent, signal), + askReviewer: (prompt, onEvent, signal) => this.askWorkflowAgent({ planningWorkflowId: workflow.workflowId, workflowReviewRevision: workflow.revision, invocation: { surface: "workflow", role: "reviewer", ownerReference: { workflowId: workflow.workflowId, revision: String(workflow.revision) } }, prompt, configuredAgentId: workflow.reviewerConfiguredAgentId, runtimeId: reviewer.runtimeAgentId, executionMode, continuationPolicy: this.defaultContinuationPolicy(reviewer.runtimeAgentId, "workflow", executionMode), runtimeConfig: { model: reviewer.modelId, ...(reviewer.reasoningEffort ? { reasoningEffort: reviewer.reasoningEffort } : {}) }, workDir: workflow.workDir || this.workDir }, onEvent, signal), publish: (next) => { this.workflowStore.workflows.set(next.workflowId, next); this.emitWorkflow(); }, current: () => this.workflowStore.workflows.get(workflow.workflowId), flush: () => this.flushPersistence(), @@ -2356,6 +2363,16 @@ export class AgentHub { workflowRunId: input.runId, workflowNodeId: input.nodeId, workflowNodeExecutionId: completionExecutionId, + invocation: { + surface: "workflow", + role: "node", + ownerReference: { + workflowId: input.workflowId, + runId: input.runId, + nodeId: input.nodeId, + executionId: completionExecutionId, + }, + }, developerInstructions: [WORKFLOW_DEVELOPER_INSTRUCTIONS, resolved.agent.instructions, input.developerInstructions, input.contextDocument ? `# Runtime context\n${input.contextDocument}` : undefined].filter(Boolean).join("\n\n"), emit: (event) => { if (event.type === "runtime_conversation") latestRuntimeConversation = this.runtimeRouter.cloneConversation(event.runtimeConversation); @@ -2480,6 +2497,11 @@ export class AgentHub { channelId: resolved.channel.id, workDir: input.workDir, planningWorkflowId: input.workflowId, + invocation: { + surface: "workflow", + role: "draft", + ownerReference: { workflowId: input.workflowId, requestId: input.requestId }, + }, developerInstructions: [WORKFLOW_DEVELOPER_INSTRUCTIONS, resolved.agent.instructions] .filter(Boolean) .join("\n\n"), diff --git a/apps/main-2.0/src/automation/engine/main/hub/chat/agent-hub-interactive.ts b/apps/main-2.0/src/automation/engine/main/hub/chat/agent-hub-interactive.ts index aa23e32f3..21a85e65c 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/chat/agent-hub-interactive.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/chat/agent-hub-interactive.ts @@ -87,6 +87,11 @@ export function buildInteractiveChatContext(input: { model: input.resolved.modelId, ...(input.resolved.reasoningEffort ? { reasoningEffort: input.resolved.reasoningEffort } : {}), }, + invocation: { + surface: "agent", + role: "chat", + ownerReference: { chatId: input.chat.id }, + }, ...(runtimeConversation ? { runtimeConversation } : {}), runtime: input.resolved.runtime as AgentRuntime, channelId: input.resolved.channel.id, diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/claude/claude-workflow.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/claude/claude-workflow.ts index 51e936acd..70a4a0dc8 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/claude/claude-workflow.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/claude/claude-workflow.ts @@ -74,6 +74,8 @@ export async function runClaudeWorkflow( } if (event.type === "runtime_conversation") { runtimeConversation = cloneClaudeRuntimeConversation(event.runtimeConversation); + const sessionId = claudeSessionIdFromConversation(runtimeConversation); + if (sessionId) input.reportExecutionReference?.({ sessionId }); return; } if (event.type === "tool_call" || event.type === "tool_result") { diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/codex/codex-executor.test.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/codex/codex-executor.test.ts index f8af6c0e2..c27145d1e 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/codex/codex-executor.test.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/codex/codex-executor.test.ts @@ -37,6 +37,7 @@ function context(overrides: Partial = {}): AgentExecution emit: () => undefined, onExit: () => undefined, ...overrides, + invocation: overrides.invocation ?? { surface: "agent", role: "task" }, }; } diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/codex/codex-workflow.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/codex/codex-workflow.ts index bc29c8728..7bd886b6a 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/codex/codex-workflow.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/codex/codex-workflow.ts @@ -187,6 +187,7 @@ export async function runCodexWorkflow( ...(threadId ? { sessionId: threadId } : {}), ...(turnId ? { turnId } : {}), }; + input.reportExecutionReference?.(executionReference); } catch (error) { settle(() => reject(error instanceof Error ? error : new Error(String(error)))); } diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/dsh/dsh-capabilities.test.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/dsh/dsh-capabilities.test.ts index 398e2339a..21868dab5 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/dsh/dsh-capabilities.test.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/dsh/dsh-capabilities.test.ts @@ -79,6 +79,7 @@ describe("DSH runtime driver", () => { executionMode: "oneshot", continuationPolicy: "fresh", runtimeConfig: { model: "default" }, + invocation: { surface: "agent", role: "task" }, runtime, channelId: "dsh-default", prompt: "Inspect the repository.", @@ -105,6 +106,7 @@ describe("DSH runtime driver", () => { executionMode: "interactive", continuationPolicy: "fresh", runtimeConfig: { model: "default" }, + invocation: { surface: "agent", role: "chat" }, runtime, channelId: "dsh-default", workDir: "/work/repository", diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/dsh/dsh-executor.test.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/dsh/dsh-executor.test.ts index b4397b327..a12bdd63e 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/dsh/dsh-executor.test.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/dsh/dsh-executor.test.ts @@ -57,6 +57,7 @@ function executionContext( emit: (event) => events.push(event), onExit: (code) => exits.push(code), ...overrides, + invocation: overrides.invocation ?? { surface: "agent", role: "task" }, }, events, exits, diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/dsh/dsh-workflow.test.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/dsh/dsh-workflow.test.ts index b95009d45..d7f159b55 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/dsh/dsh-workflow.test.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/dsh/dsh-workflow.test.ts @@ -66,6 +66,7 @@ function workflowInput( instructionScope: "agent", onEvent: (event) => events.push(event), ...overrides, + invocation: overrides.invocation ?? { surface: "workflow" }, }, events, }; diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/dsh/dsh-workflow.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/dsh/dsh-workflow.ts index 119458029..d9559cd59 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/dsh/dsh-workflow.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/dsh/dsh-workflow.ts @@ -124,6 +124,7 @@ export async function runDshChannelTest( executionMode: "oneshot", continuationPolicy: "fresh", runtimeConfig: { model: input.modelId }, + invocation: { surface: "system", role: "channel_test", ownerReference: { channelId: input.channelId } }, runtime: input.runtime as AgentRuntime, channelId: input.channelId, workDir: input.workDir, diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/hermes/hermes-workflow.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/hermes/hermes-workflow.ts index 5a7b6ccc2..d454a3a29 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/hermes/hermes-workflow.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/hermes/hermes-workflow.ts @@ -83,6 +83,7 @@ export async function runHermesChannelTest( executionMode: "oneshot", continuationPolicy: "fresh", runtimeConfig: { model: input.modelId }, + invocation: { surface: "system", role: "channel_test", ownerReference: { channelId: input.channelId } }, runtime: input.runtime as AgentRuntime, channelId: input.channelId, workDir: input.workDir, diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/openclaw/openclaw-workflow.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/openclaw/openclaw-workflow.ts index a62d525d3..daabc4b3b 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/openclaw/openclaw-workflow.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/openclaw/openclaw-workflow.ts @@ -66,6 +66,7 @@ export async function runOpenClawChannelTest( executionMode: "oneshot", continuationPolicy: "fresh", runtimeConfig: { model: input.modelId }, + invocation: { surface: "system", role: "channel_test", ownerReference: { channelId: input.channelId } }, runtime: input.runtime as AgentRuntime, channelId: input.channelId, workDir: input.workDir, diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/opencode/opencode-workflow.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/opencode/opencode-workflow.ts index 8bc7c3009..357b73004 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/opencode/opencode-workflow.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/opencode/opencode-workflow.ts @@ -76,6 +76,7 @@ export async function runOpenCodeChannelTest( executionMode: "oneshot", continuationPolicy: "fresh", runtimeConfig: { model: input.modelId }, + invocation: { surface: "system", role: "channel_test", ownerReference: { channelId: input.channelId } }, runtime: input.runtime as AgentRuntime, channelId: input.channelId, workDir: input.workDir, diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/runtime-onboarding-contract.test.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/runtime-onboarding-contract.test.ts index 26e77e8e8..85c30d4fb 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/runtime-onboarding-contract.test.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/runtime-onboarding-contract.test.ts @@ -57,6 +57,7 @@ function buildTaskContext(overrides: Partial = {}): Agent emit: () => undefined, onExit: () => undefined, ...overrides, + invocation: overrides.invocation ?? { surface: "agent", role: "task" }, }; } @@ -76,6 +77,7 @@ function buildInteractiveContext( developerInstructions: "", emit: () => undefined, ...overrides, + invocation: overrides.invocation ?? { surface: "agent", role: "chat" }, }; } @@ -93,6 +95,7 @@ function buildWorkflowContext( channelId: "api-default", workDir: "C:/repo", ...overrides, + invocation: overrides.invocation ?? { surface: "workflow" }, }; } @@ -125,7 +128,10 @@ describe("runtime onboarding contract", () => { } as const; expect(registry.driverFor("api").surfaceSupport).toEqual(declaredSupport); - expect(router.createOneShotExecutor(buildTaskContext())).toBe(executor); + expect(router.createOneShotExecutor(buildTaskContext())).toEqual({ + start: expect.any(Function), + stop: expect.any(Function), + }); await expect(router.askWorkflow(buildWorkflowContext())).resolves.toEqual({ content: "workflow ok" }); expect(() => diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/run/agent-hub-runner.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/run/agent-hub-runner.ts index 97a6bd39e..11ae6ab08 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/runtime/run/agent-hub-runner.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/run/agent-hub-runner.ts @@ -74,6 +74,9 @@ export async function runAgentExecution(input: { ? input.defaultContinuationPolicy(input.resolved.runtimeAgentId, "chat", executionMode) : input.run.continuationPolicy; const runtimeConversation = input.cloneConversationForPolicy(continuationPolicy, input.run.runtimeConversation); + const workflowOwned = input.run.kind === "task" && Boolean( + input.run.planningWorkflowId || input.run.workflowRunId, + ); const executor = input.executorFactory.create({ runId: input.run.id, runKind: input.run.kind, @@ -85,6 +88,22 @@ export async function runAgentExecution(input: { model: input.resolved.modelId, ...(input.resolved.reasoningEffort ? { reasoningEffort: input.resolved.reasoningEffort } : {}), }, + invocation: { + surface: workflowOwned ? "workflow" : "agent", + role: input.run.kind, + ownerReference: { + ...(input.run.kind === "chat" ? { chatId: input.run.id } : { taskId: input.run.id }), + ...(input.run.kind === "task" && input.run.planningWorkflowId + ? { workflowId: input.run.planningWorkflowId } + : {}), + ...(input.run.kind === "task" && input.run.workflowRunId + ? { runId: input.run.workflowRunId } + : {}), + ...(input.run.kind === "task" && input.run.workflowNodeId + ? { nodeId: input.run.workflowNodeId } + : {}), + }, + }, ...(runtimeConversation ? { runtimeConversation } : {}), ...(input.run.kind === "task" && input.run.planningWorkflowId ? { planningWorkflowId: input.run.planningWorkflowId } : {}), ...(input.run.kind === "task" && input.run.workflowReviewRevision ? { workflowReviewRevision: input.run.workflowReviewRevision } : {}), diff --git a/apps/main-2.0/src/automation/engine/main/hub/workflow/agent-hub-workflow-agent.test.ts b/apps/main-2.0/src/automation/engine/main/hub/workflow/agent-hub-workflow-agent.test.ts index 06bcaed8f..c8afc268b 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/workflow/agent-hub-workflow-agent.test.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/workflow/agent-hub-workflow-agent.test.ts @@ -16,6 +16,7 @@ describe("buildWorkflowAgentExecution", () => { workflowRunId: "run", workflowNodeId: "node", workflowNodeExecutionId: "execution", + invocation: { surface: "workflow", role: "node" }, } satisfies WorkflowAgentRequest; const execution = buildWorkflowAgentExecution({ diff --git a/apps/main-2.0/src/automation/engine/main/hub/workflow/agent-hub-workflow-agent.ts b/apps/main-2.0/src/automation/engine/main/hub/workflow/agent-hub-workflow-agent.ts index 9f4f57623..261896b05 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/workflow/agent-hub-workflow-agent.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/workflow/agent-hub-workflow-agent.ts @@ -84,6 +84,7 @@ export function buildWorkflowAgentExecution; @@ -127,6 +128,7 @@ export function buildWorkflowAgentExecution { await service.runOneShot({ configuredAgentId: agent.id, prompt: "Complete the node", + invocation: { surface: "workflow", role: "node" }, workflowExecution: { workflowId: "workflow", runId: "run", @@ -46,6 +47,7 @@ describe("ConfiguredAgentExecutionService", () => { workflowRunId: "run", workflowNodeId: "review", workflowNodeExecutionId: "execution", + invocation: { surface: "workflow", role: "node" }, })); }); }); diff --git a/apps/main-2.0/src/automation/engine/main/platform/configured-agent-execution-service.ts b/apps/main-2.0/src/automation/engine/main/platform/configured-agent-execution-service.ts index 6dcfae2de..e36ba802d 100644 --- a/apps/main-2.0/src/automation/engine/main/platform/configured-agent-execution-service.ts +++ b/apps/main-2.0/src/automation/engine/main/platform/configured-agent-execution-service.ts @@ -3,6 +3,7 @@ import type { AgentChannel, ConfiguredAgent, RuntimeConversation, + RuntimeInvocationRequest, WorkflowAgentEvent, WorkflowAgentRequest, WorkflowAgentResponse, @@ -45,6 +46,7 @@ export class ConfiguredAgentExecutionService { nodeId: string; executionId: string; }; + invocation: RuntimeInvocationRequest; }, onEvent?: (event: WorkflowAgentEvent) => void, signal?: AbortSignal, @@ -71,6 +73,7 @@ export class ConfiguredAgentExecutionService { runtimeConversation?: RuntimeConversation; developerInstructions?: string; agentRecallMcp?: AgentRecallMcpContext; + invocation: RuntimeInvocationRequest; }, onEvent?: (event: WorkflowAgentEvent) => void, signal?: AbortSignal, @@ -91,6 +94,7 @@ export class ConfiguredAgentExecutionService { runtimeConversation?: RuntimeConversation; developerInstructions?: string; agentRecallMcp?: AgentRecallMcpContext; + invocation: RuntimeInvocationRequest; workflowExecution?: { workflowId: string; runId: string; @@ -128,6 +132,7 @@ export class ConfiguredAgentExecutionService { ? { developerInstructions: input.developerInstructions.trim() } : {}), ...(input.agentRecallMcp ? { agentRecallMcp: { ...input.agentRecallMcp } } : {}), + invocation: structuredClone(input.invocation), ...(input.workflowExecution ? { planningWorkflowId: input.workflowExecution.workflowId, workflowRunId: input.workflowExecution.runId, diff --git a/apps/main-2.0/src/automation/engine/shared/types.ts b/apps/main-2.0/src/automation/engine/shared/types.ts index 07bc032e5..886616d26 100644 --- a/apps/main-2.0/src/automation/engine/shared/types.ts +++ b/apps/main-2.0/src/automation/engine/shared/types.ts @@ -346,6 +346,20 @@ export type ExecutionStyle = "oneshot" | "interactive"; export type RuntimeExecutionMode = ExecutionStyle; export type RuntimeContinuationPolicy = "fresh" | "resume-preferred" | "resume-required"; +export type AgentRecallInvocationSurface = + | "workflow" + | "evaluation" + | "team_chat" + | "agent" + | "skill" + | "system"; + +export interface RuntimeInvocationRequest { + surface: AgentRecallInvocationSurface; + role?: string; + ownerReference?: Record; +} + export interface RuntimeConfig { model: string; reasoningEffort?: string; @@ -369,6 +383,7 @@ export interface RuntimeRequest { agentRecallMcp?: AgentRecallMcpContext; workflowNodeExecutionId?: string; allowedMcpTools?: string[]; + invocation: RuntimeInvocationRequest; } export interface RuntimeResumeCapabilities { diff --git a/apps/main-2.0/src/core/evaluation/nodes/contracts.ts b/apps/main-2.0/src/core/evaluation/nodes/contracts.ts index cca399a65..55635d43c 100644 --- a/apps/main-2.0/src/core/evaluation/nodes/contracts.ts +++ b/apps/main-2.0/src/core/evaluation/nodes/contracts.ts @@ -168,7 +168,13 @@ export interface EvaluationNodeDependencies { skillName: string, ) => Promise<{ content: string; hash: string } | null>; runAgent: ( - input: { agentId: string; prompt: string; developerInstructions?: string }, + input: { + agentId: string; + prompt: string; + developerInstructions?: string; + role: string; + ownerReference: Record; + }, signal?: AbortSignal, ) => Promise<{ output: string; @@ -176,7 +182,12 @@ export interface EvaluationNodeDependencies { executionReference?: EvaluationExecutionReference; }>; executeJudge?: ( - input: { runtimeId: string; prompt: string }, + input: { + runtimeId: string; + prompt: string; + role: string; + ownerReference: Record; + }, signal?: AbortSignal, ) => Promise<{ output: string; durationMs: number }>; /** Resolves a runtime-native session id to an indexed AgentRecall session. */ diff --git a/apps/main-2.0/src/core/evaluation/nodes/judge-nodes.ts b/apps/main-2.0/src/core/evaluation/nodes/judge-nodes.ts index 7afd8af47..2e9398a44 100644 --- a/apps/main-2.0/src/core/evaluation/nodes/judge-nodes.ts +++ b/apps/main-2.0/src/core/evaluation/nodes/judge-nodes.ts @@ -202,7 +202,17 @@ export function createLlmJudgeNode( let judged: { output: string; durationMs: number }; try { - judged = await dependencies.executeJudge({ runtimeId, prompt }, context.signal); + judged = await dependencies.executeJudge({ + runtimeId, + prompt, + role: "judge", + ownerReference: { + caseId: task.caseId, + datasetItemId: task.datasetItemId, + repetition: String(task.repetition), + evaluatorId, + }, + }, context.signal); } catch (cause) { return evaluationExcused.infra( cause instanceof Error ? cause.message : String(cause), diff --git a/apps/main-2.0/src/core/evaluation/nodes/prepare-nodes.ts b/apps/main-2.0/src/core/evaluation/nodes/prepare-nodes.ts index 0dd3d4bb5..a8c8f5cc3 100644 --- a/apps/main-2.0/src/core/evaluation/nodes/prepare-nodes.ts +++ b/apps/main-2.0/src/core/evaluation/nodes/prepare-nodes.ts @@ -140,6 +140,12 @@ export function createRunAgentNode( { agentId: context.config.agentId, prompt: task.input, + role: "subject", + ownerReference: { + caseId: task.caseId, + datasetItemId: task.datasetItemId, + repetition: String(task.repetition), + }, ...(instructions.text ? { developerInstructions: instructions.text } : {}), }, context.signal, diff --git a/apps/main-2.0/src/core/postgres/runtime-invocation-repository.ts b/apps/main-2.0/src/core/postgres/runtime-invocation-repository.ts new file mode 100644 index 000000000..f1ec9ff83 --- /dev/null +++ b/apps/main-2.0/src/core/postgres/runtime-invocation-repository.ts @@ -0,0 +1,81 @@ +import type { + RuntimeInvocationRecorder, + RuntimeInvocationStart, + RuntimeInvocationStatus, + RuntimeSessionBinding, +} from "../../automation/engine/main/agents/runtime/runtime-invocation-recorder"; +import type { PostgresDatabase } from "./database"; +import { postgresJsonValue, postgresText } from "./session-records"; + +export class PostgresRuntimeInvocationRepository implements RuntimeInvocationRecorder { + constructor(private readonly database: PostgresDatabase) {} + + async begin(input: RuntimeInvocationStart): Promise { + await this.database.query( + ` + insert into agent_recall.runtime_invocations ( + id, initiator, surface, role, owner_reference, runtime_id, + channel_id, environment_id, status, started_at + ) values ($1, $2, $3, $4, $5, $6, $7, $8, 'pending', $9) + `, + [ + postgresText(input.id), + input.initiator, + input.invocation.surface, + input.invocation.role ? postgresText(input.invocation.role) : null, + postgresJsonValue(input.invocation.ownerReference ?? {}), + input.runtimeId, + input.channelId ? postgresText(input.channelId) : null, + postgresText(input.environmentId ?? "local"), + new Date(input.startedAt).toISOString(), + ], + ); + } + + async bind(invocationId: string, binding: RuntimeSessionBinding): Promise { + await this.database.query( + ` + insert into agent_recall.runtime_session_bindings ( + invocation_id, runtime_id, channel_id, environment_id, + runtime_session_id, runtime_turn_id, relation, bound_at + ) values ($1, $2, $3, $4, $5, $6, $7, $8) + on conflict (invocation_id, runtime_id, channel_id, environment_id, runtime_session_id) + do update set + runtime_turn_id = coalesce(excluded.runtime_turn_id, agent_recall.runtime_session_bindings.runtime_turn_id), + relation = excluded.relation, + bound_at = least(excluded.bound_at, agent_recall.runtime_session_bindings.bound_at) + `, + [ + postgresText(invocationId), + binding.runtimeId, + postgresText(binding.channelId ?? ""), + postgresText(binding.environmentId ?? "local"), + postgresText(binding.sessionId), + binding.turnId ? postgresText(binding.turnId) : null, + binding.relation, + new Date(binding.boundAt).toISOString(), + ], + ); + } + + async finish( + invocationId: string, + status: Exclude, + finishedAt: number, + error?: string, + ): Promise { + await this.database.query( + ` + update agent_recall.runtime_invocations + set status = $2, finished_at = $3, error = $4 + where id = $1 and status = 'pending' + `, + [ + postgresText(invocationId), + status, + new Date(finishedAt).toISOString(), + error ? postgresText(error) : null, + ], + ); + } +} diff --git a/apps/main-2.0/src/core/postgres/schema.test.ts b/apps/main-2.0/src/core/postgres/schema.test.ts index 360eb445e..d3ac865ef 100644 --- a/apps/main-2.0/src/core/postgres/schema.test.ts +++ b/apps/main-2.0/src/core/postgres/schema.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { PostgresDatabase } from "./database"; import { POSTGRES_MIGRATIONS } from "./schema"; import { PGliteTestPool } from "./test-pglite"; +import { PostgresSessionRepository } from "./session-repository"; describe("AgentRecall PostgreSQL schema", () => { it("uses one stable record per migration version", () => { @@ -33,6 +34,8 @@ describe("AgentRecall PostgreSQL schema", () => { "session_attachments", "saved_searches", "search_history", + "runtime_invocations", + "runtime_session_bindings", "trace_spans", "token_events", "skill_usage_events", @@ -68,7 +71,7 @@ describe("AgentRecall PostgreSQL schema", () => { "openviking_operation_events", "openviking_recall_traces", ])); - expect(names).toHaveLength(65); + expect(names).toHaveLength(67); const sessionColumns = await database.query<{ column_name: string; is_nullable: string; @@ -1021,4 +1024,77 @@ describe("AgentRecall PostgreSQL schema", () => { expect(dimensions.rows.map((row) => row.dimension)).toEqual(["judge", "正确性"]); await upgradedDatabase.close(); }); + + it("backfills only durable historical evaluation Session links", async () => { + const pool = new PGliteTestPool(); + const legacyDatabase = new PostgresDatabase(pool, { + migrationLock: false, + migrations: POSTGRES_MIGRATIONS.filter((migration) => migration.version <= 46), + }); + await legacyDatabase.initialize(); + const sessions = new PostgresSessionRepository(legacyDatabase); + await sessions.upsertIndexedSession({ + sessionKey: "codex:historical-runtime", + rawId: "historical-runtime", + source: "codex-cli", + projectPath: "/workspace", + filePath: "/fixtures/historical-runtime.jsonl", + originalTitle: "Historical evaluation", + firstQuestion: "Evaluate this", + timestamp: Date.parse("2026-08-01T00:00:00.000Z"), + fileMtimeMs: Date.parse("2026-08-01T00:00:00.000Z"), + fileSize: 1, + prUrl: null, + prNumber: null, + }, []); + await legacyDatabase.query(` + insert into agent_recall.evaluation_datasets ( + id, name, description, created_at, updated_at + ) values ('dataset-history', 'history', '', now(), now()); + insert into agent_recall.evaluation_experiments ( + id, name, dataset_id, agent_id, repetitions, created_at, updated_at + ) values ('experiment-history', 'history', 'dataset-history', 'agent', 1, now(), now()); + insert into agent_recall.evaluation_runs ( + id, experiment_id, status, started_at, finished_at + ) values ('run-history', 'experiment-history', 'completed', now(), now()); + insert into agent_recall.evaluation_case_results ( + id, run_id, dataset_item_id, repetition, input, output, duration_ms, session_key + ) values ( + 'case-history', 'run-history', 'item', 1, 'input', 'output', 1, + 'codex:historical-runtime' + ); + `); + + const upgradedDatabase = new PostgresDatabase(pool, { + migrationLock: false, + migrations: POSTGRES_MIGRATIONS, + }); + await upgradedDatabase.initialize(); + const linked = await upgradedDatabase.query<{ + surface: string; + runtime_id: string; + runtime_session_id: string; + relation: string; + }>(` + select invocations.surface, bindings.runtime_id, + bindings.runtime_session_id, bindings.relation + from agent_recall.runtime_invocations invocations + join agent_recall.runtime_session_bindings bindings + on bindings.invocation_id = invocations.id + where invocations.id = 'legacy-evaluation:case-history' + `); + expect(linked.rows).toEqual([{ + surface: "evaluation", + runtime_id: "codex", + runtime_session_id: "historical-runtime", + relation: "created", + }]); + + await upgradedDatabase.query(` + insert into agent_recall.runtime_invocations ( + id, initiator, surface, runtime_id, status, started_at + ) values ('future-surface', 'agentrecall', 'future_surface', 'codex', 'completed', now()) + `); + await upgradedDatabase.close(); + }); }); diff --git a/apps/main-2.0/src/core/postgres/schema.ts b/apps/main-2.0/src/core/postgres/schema.ts index f2ba3af76..39e8c29a1 100644 --- a/apps/main-2.0/src/core/postgres/schema.ts +++ b/apps/main-2.0/src/core/postgres/schema.ts @@ -1810,4 +1810,208 @@ export const POSTGRES_MIGRATIONS: readonly PostgresMigration[] = [{ ADD PRIMARY KEY (case_result_id, evaluator_id, dimension); `, ], +}, { + version: 47, + name: "record AgentRecall Runtime invocations and Session bindings", + statements: [ + ` + CREATE TABLE agent_recall.runtime_invocations ( + id text PRIMARY KEY, + initiator text NOT NULL CHECK (initiator = 'agentrecall'), + surface text NOT NULL, + role text, + owner_reference jsonb NOT NULL DEFAULT '{}'::jsonb, + runtime_id text NOT NULL, + channel_id text, + environment_id text NOT NULL DEFAULT 'local', + status text NOT NULL CHECK (status IN ('pending', 'completed', 'failed', 'cancelled', 'timed_out')), + started_at timestamptz NOT NULL, + finished_at timestamptz, + error text + ); + + CREATE TABLE agent_recall.runtime_session_bindings ( + invocation_id text NOT NULL REFERENCES agent_recall.runtime_invocations(id) ON DELETE CASCADE, + runtime_id text NOT NULL, + channel_id text NOT NULL DEFAULT '', + environment_id text NOT NULL DEFAULT 'local', + runtime_session_id text NOT NULL, + runtime_turn_id text, + relation text NOT NULL CHECK (relation IN ('created', 'continued')), + bound_at timestamptz NOT NULL, + PRIMARY KEY (invocation_id, runtime_id, channel_id, environment_id, runtime_session_id) + ); + + CREATE INDEX runtime_session_bindings_session_idx + ON agent_recall.runtime_session_bindings + (environment_id, runtime_id, runtime_session_id, relation); + CREATE INDEX runtime_invocations_owner_idx + ON agent_recall.runtime_invocations USING gin (owner_reference); + CREATE INDEX runtime_invocations_started_idx + ON agent_recall.runtime_invocations (started_at DESC, id DESC); + `, + ` + WITH linked_evaluations AS ( + SELECT + case_results.id AS case_result_id, + case_results.run_id, + runs.experiment_id, + sessions.environment_id, + sessions.raw_id AS runtime_session_id, + CASE + WHEN sessions.source IN ('codex-cli', 'codex-app', 'stepcode-codex', 'tcodex-cli') THEN 'codex' + WHEN sessions.source IN ('claude-cli', 'claude-app', 'stepcode-claude', 'tclaude-cli') THEN 'claude' + WHEN sessions.source = 'deepseek-cli' THEN 'dsh' + WHEN sessions.source = 'hermes' THEN 'hermes' + WHEN sessions.source = 'opencode-cli' THEN 'opencode' + WHEN sessions.source = 'openclaw' THEN 'openclaw' + ELSE NULL + END AS runtime_id, + runs.started_at, + runs.finished_at, + case_results.error + FROM agent_recall.evaluation_case_results case_results + JOIN agent_recall.evaluation_runs runs ON runs.id = case_results.run_id + JOIN agent_recall.sessions sessions ON sessions.session_key = case_results.session_key + WHERE case_results.session_key IS NOT NULL + ) + INSERT INTO agent_recall.runtime_invocations ( + id, initiator, surface, role, owner_reference, runtime_id, + environment_id, status, started_at, finished_at, error + ) + SELECT + 'legacy-evaluation:' || case_result_id, + 'agentrecall', + 'evaluation', + 'subject', + jsonb_build_object( + 'experimentId', experiment_id, + 'runId', run_id, + 'caseResultId', case_result_id + ), + runtime_id, + environment_id, + CASE WHEN error IS NULL THEN 'completed' ELSE 'failed' END, + started_at, + coalesce(finished_at, started_at), + error + FROM linked_evaluations + WHERE runtime_id IS NOT NULL + ON CONFLICT (id) DO NOTHING; + + WITH linked_evaluations AS ( + SELECT + case_results.id AS case_result_id, + sessions.environment_id, + sessions.raw_id AS runtime_session_id, + CASE + WHEN sessions.source IN ('codex-cli', 'codex-app', 'stepcode-codex', 'tcodex-cli') THEN 'codex' + WHEN sessions.source IN ('claude-cli', 'claude-app', 'stepcode-claude', 'tclaude-cli') THEN 'claude' + WHEN sessions.source = 'deepseek-cli' THEN 'dsh' + WHEN sessions.source = 'hermes' THEN 'hermes' + WHEN sessions.source = 'opencode-cli' THEN 'opencode' + WHEN sessions.source = 'openclaw' THEN 'openclaw' + ELSE NULL + END AS runtime_id, + coalesce(runs.finished_at, runs.started_at) AS bound_at + FROM agent_recall.evaluation_case_results case_results + JOIN agent_recall.evaluation_runs runs ON runs.id = case_results.run_id + JOIN agent_recall.sessions sessions ON sessions.session_key = case_results.session_key + WHERE case_results.session_key IS NOT NULL + ) + INSERT INTO agent_recall.runtime_session_bindings ( + invocation_id, runtime_id, environment_id, runtime_session_id, relation, bound_at + ) + SELECT + 'legacy-evaluation:' || case_result_id, + runtime_id, + environment_id, + runtime_session_id, + 'created', + bound_at + FROM linked_evaluations + WHERE runtime_id IS NOT NULL + ON CONFLICT DO NOTHING; + `, + ` + WITH attempts AS ( + SELECT + execution_attempts.*, + dispatches.room_id, + dispatches.source_message_id, + dispatches.task_id, + room_agents.channel_id, + row_number() OVER ( + PARTITION BY execution_attempts.runtime_id, execution_attempts.runtime_session_ref + ORDER BY execution_attempts.started_at, execution_attempts.id + ) AS session_sequence + FROM agent_recall.chat_dispatch_attempts execution_attempts + JOIN agent_recall.chat_dispatches dispatches ON dispatches.id = execution_attempts.dispatch_id + LEFT JOIN agent_recall.chat_room_agents room_agents + ON room_agents.room_id = dispatches.room_id + AND room_agents.agent_id = dispatches.target_agent_id + WHERE execution_attempts.runtime_session_ref IS NOT NULL + ) + INSERT INTO agent_recall.runtime_invocations ( + id, initiator, surface, role, owner_reference, runtime_id, channel_id, + environment_id, status, started_at, finished_at, error + ) + SELECT + 'legacy-team-chat:' || id, + 'agentrecall', + 'team_chat', + 'member', + jsonb_strip_nulls(jsonb_build_object( + 'roomId', room_id, + 'messageId', source_message_id, + 'dispatchId', dispatch_id, + 'taskId', task_id, + 'attemptId', id + )), + runtime_id, + channel_id, + 'local', + CASE status + WHEN 'completed' THEN 'completed' + WHEN 'interrupted' THEN 'cancelled' + ELSE 'failed' + END, + started_at, + coalesce(finished_at, started_at), + error + FROM attempts + ON CONFLICT (id) DO NOTHING; + + WITH attempts AS ( + SELECT + execution_attempts.*, + room_agents.channel_id, + row_number() OVER ( + PARTITION BY execution_attempts.runtime_id, execution_attempts.runtime_session_ref + ORDER BY execution_attempts.started_at, execution_attempts.id + ) AS session_sequence + FROM agent_recall.chat_dispatch_attempts execution_attempts + JOIN agent_recall.chat_dispatches dispatches ON dispatches.id = execution_attempts.dispatch_id + LEFT JOIN agent_recall.chat_room_agents room_agents + ON room_agents.room_id = dispatches.room_id + AND room_agents.agent_id = dispatches.target_agent_id + WHERE execution_attempts.runtime_session_ref IS NOT NULL + ) + INSERT INTO agent_recall.runtime_session_bindings ( + invocation_id, runtime_id, channel_id, environment_id, + runtime_session_id, runtime_turn_id, relation, bound_at + ) + SELECT + 'legacy-team-chat:' || id, + runtime_id, + coalesce(channel_id, ''), + 'local', + runtime_session_ref, + native_turn_id, + CASE WHEN session_sequence = 1 THEN 'created' ELSE 'continued' END, + started_at + FROM attempts + ON CONFLICT DO NOTHING; + `, + ], }]; diff --git a/apps/main-2.0/src/core/postgres/session-records.ts b/apps/main-2.0/src/core/postgres/session-records.ts index 45060faa4..15ce0f756 100644 --- a/apps/main-2.0/src/core/postgres/session-records.ts +++ b/apps/main-2.0/src/core/postgres/session-records.ts @@ -2,6 +2,7 @@ import { cleanTitle } from "../format-adapters"; import type { EnvironmentKind, SessionSearchResult, + RuntimeInvocationSummary, SessionSource, SessionTurnMatch, SessionTurnStatus, @@ -57,6 +58,8 @@ export interface SessionRow extends Record { best_turn_started_at?: Date | string | null; best_turn_search_text?: string | null; turn_match_count?: number | string | null; + created_by_agent_recall: boolean; + runtime_invocations: unknown; } export interface SessionTurnSummaryRow extends Record { @@ -154,6 +157,35 @@ export const SESSION_ACTIVITY_SQL = ` ) `; +export const RUNTIME_SESSION_BINDING_MATCH_SQL = ` + bindings.environment_id = sessions.environment_id + and bindings.runtime_session_id = sessions.raw_id + and ( + (bindings.runtime_id = 'codex' and sessions.source in ( + 'codex-cli', 'codex-app', 'stepcode-codex', 'tcodex-cli' + )) + or (bindings.runtime_id = 'claude' and sessions.source in ( + 'claude-cli', 'claude-app', 'stepcode-claude', 'tclaude-cli' + )) + or (bindings.runtime_id = 'dsh' and sessions.source = 'deepseek-cli') + or (bindings.runtime_id = 'hermes' and sessions.source = 'hermes') + or (bindings.runtime_id = 'opencode' and sessions.source = 'opencode-cli') + or (bindings.runtime_id = 'openclaw' and sessions.source = 'openclaw') + ) +`; + +export const AGENTRECALL_CREATED_SESSION_SQL = ` + exists ( + select 1 + from agent_recall.runtime_session_bindings bindings + join agent_recall.runtime_invocations invocations + on invocations.id = bindings.invocation_id + where ${RUNTIME_SESSION_BINDING_MATCH_SQL} + and bindings.relation = 'created' + and invocations.initiator = 'agentrecall' + ) +`; + export const SESSION_SELECT_SQL = ` sessions.*, coalesce( @@ -189,6 +221,36 @@ export const SESSION_SELECT_SQL = ` ), array[]::text[] ) as tag_names + ,${AGENTRECALL_CREATED_SESSION_SQL} as created_by_agent_recall + ,coalesce( + ( + select jsonb_agg( + jsonb_build_object( + 'invocationId', invocations.id, + 'surface', invocations.surface, + 'role', invocations.role, + 'ownerReference', invocations.owner_reference, + 'runtimeId', bindings.runtime_id, + 'channelId', nullif(bindings.channel_id, ''), + 'environmentId', bindings.environment_id, + 'status', invocations.status, + 'startedAt', extract(epoch from invocations.started_at) * 1000, + 'finishedAt', case when invocations.finished_at is null then null + else extract(epoch from invocations.finished_at) * 1000 end, + 'error', invocations.error, + 'relation', bindings.relation, + 'runtimeSessionId', bindings.runtime_session_id, + 'runtimeTurnId', bindings.runtime_turn_id + ) order by invocations.started_at desc, invocations.id desc + ) + from agent_recall.runtime_session_bindings bindings + join agent_recall.runtime_invocations invocations + on invocations.id = bindings.invocation_id + where ${RUNTIME_SESSION_BINDING_MATCH_SQL} + and invocations.initiator = 'agentrecall' + ), + '[]'::jsonb + ) as runtime_invocations `; export function numberValue(value: unknown): number { @@ -465,7 +527,56 @@ export function hydrateSession( parentSessionId: row.parent_session_id, bestTurn, turnMatchCount: numberValue(row.turn_match_count), + createdByAgentRecall: Boolean(row.created_by_agent_recall), + runtimeInvocations: runtimeInvocationSummaries(row.runtime_invocations), }; if (queryTerms.length > 0 && !bestTurn) result.metadataMatch = metadataMatch(result, queryTerms); return result; } + +function runtimeInvocationSummaries(value: unknown): RuntimeInvocationSummary[] { + let entries: unknown = value; + if (typeof entries === "string") { + try { + entries = JSON.parse(entries); + } catch { + return []; + } + } + if (!Array.isArray(entries)) return []; + return entries.flatMap((entry): RuntimeInvocationSummary[] => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return []; + const record = entry as Record; + if ( + typeof record.invocationId !== "string" + || typeof record.surface !== "string" + || typeof record.runtimeId !== "string" + || typeof record.environmentId !== "string" + || typeof record.runtimeSessionId !== "string" + || (record.relation !== "created" && record.relation !== "continued") + || !["pending", "completed", "failed", "cancelled", "timed_out"].includes(String(record.status)) + ) return []; + const ownerReference = jsonValue(record.ownerReference); + return [{ + invocationId: record.invocationId, + surface: record.surface, + role: typeof record.role === "string" ? record.role : null, + ownerReference: Object.fromEntries( + Object.entries(ownerReference).flatMap(([key, nested]) => + typeof nested === "string" ? [[key, nested]] : []), + ), + runtimeId: record.runtimeId, + channelId: typeof record.channelId === "string" ? record.channelId : null, + environmentId: record.environmentId, + status: record.status as RuntimeInvocationSummary["status"], + startedAt: numberValue(record.startedAt), + finishedAt: record.finishedAt === null || record.finishedAt === undefined + ? null + : numberValue(record.finishedAt), + error: typeof record.error === "string" ? record.error : null, + relation: record.relation, + runtimeSessionId: record.runtimeSessionId, + runtimeTurnId: typeof record.runtimeTurnId === "string" ? record.runtimeTurnId : null, + }]; + }); +} diff --git a/apps/main-2.0/src/core/postgres/session-repository.ts b/apps/main-2.0/src/core/postgres/session-repository.ts index 8388371a2..bc5b393dd 100644 --- a/apps/main-2.0/src/core/postgres/session-repository.ts +++ b/apps/main-2.0/src/core/postgres/session-repository.ts @@ -31,6 +31,7 @@ import type { PostgresDatabase, PostgresQueryable } from "./database"; import { SESSION_ACTIVITY_SQL, SESSION_SELECT_SQL, + RUNTIME_SESSION_BINDING_MATCH_SQL, hydrateSession, numberValue, postgresJsonValue, @@ -1911,6 +1912,32 @@ export class PostgresSessionRepository { return result.rows[0] ? hydrateSession(result.rows[0]) : null; } + async findByRuntimeInvocationOwner( + ownerReference: Record, + ): Promise { + if (Object.keys(ownerReference).length === 0) return null; + const result = await this.database.query( + ` + select ${SESSION_SELECT_SQL} + from agent_recall.sessions sessions + join agent_recall.environments environments on environments.id = sessions.environment_id + where exists ( + select 1 + from agent_recall.runtime_session_bindings bindings + join agent_recall.runtime_invocations invocations + on invocations.id = bindings.invocation_id + where ${RUNTIME_SESSION_BINDING_MATCH_SQL} + and invocations.initiator = 'agentrecall' + and invocations.owner_reference @> $1::jsonb + ) + order by ${SESSION_ACTIVITY_SQL} desc, sessions.session_key + limit 1 + `, + [postgresJsonValue(ownerReference)], + ); + return result.rows[0] ? hydrateSession(result.rows[0]) : null; + } + async setAiSummary(sessionKey: string, summary: string, model: string): Promise { const result = await this.database.query<{ file_mtime_ms: number | string }>( "select file_mtime_ms from agent_recall.sessions where session_key = $1", diff --git a/apps/main-2.0/src/core/postgres/session-search-repository.ts b/apps/main-2.0/src/core/postgres/session-search-repository.ts index 79c01397a..0ddaa8216 100644 --- a/apps/main-2.0/src/core/postgres/session-search-repository.ts +++ b/apps/main-2.0/src/core/postgres/session-search-repository.ts @@ -10,6 +10,7 @@ import type { PostgresDatabase } from "./database"; import { SESSION_ACTIVITY_SQL, SESSION_SELECT_SQL, + AGENTRECALL_CREATED_SESSION_SQL, escapeLike, hydrateSession, isoValue, @@ -141,6 +142,10 @@ export class PostgresSessionSearchRepository { filters.push(`(best_turn.id is not null or (${metadataPredicates.join(" and ")}))`); } + const originCountFilters = [...filters]; + const originCountValues = [...values]; + if (options.origin === "ordinary") filters.push(`not (${AGENTRECALL_CREATED_SESSION_SQL})`); + else if (options.origin === "agentrecall") filters.push(AGENTRECALL_CREATED_SESSION_SQL); const countValues = [...values]; const sortBy = options.sortBy ?? "smart"; let rankingColumns = ""; @@ -238,7 +243,7 @@ export class PostgresSessionSearchRepository { null::text as best_turn_search_text, null::bigint as turn_match_count, `; - const filteredSessionsSql = ` + const buildFilteredSessionsSql = (activeFilters: readonly string[]): string => ` from ( select base_sessions.*, @@ -284,8 +289,10 @@ export class PostgresSessionSearchRepository { join agent_recall.environments environments on environments.id = sessions.environment_id ${bestTurnJoin} where sessions.source_rank = 1 - and ${filters.join(" and ")} + and ${activeFilters.join(" and ")} `; + const filteredSessionsSql = buildFilteredSessionsSql(filters); + const originFilteredSessionsSql = buildFilteredSessionsSql(originCountFilters); const result = await this.database.query( ` select @@ -308,10 +315,29 @@ export class PostgresSessionSearchRepository { `select count(*) as total_count ${filteredSessionsSql}`, countValues, )).rows[0]?.total_count); + const originCountRow = (await this.database.query<{ + ordinary_count: number | string; + agentrecall_count: number | string; + all_count: number | string; + }>( + ` + select + count(*) filter (where not (${AGENTRECALL_CREATED_SESSION_SQL})) as ordinary_count, + count(*) filter (where ${AGENTRECALL_CREATED_SESSION_SQL}) as agentrecall_count, + count(*) as all_count + ${originFilteredSessionsSql} + `, + originCountValues, + )).rows[0]; return { sessions, totalCount, hasMore: offset + sessions.length < totalCount, + originCounts: { + ordinary: numberValue(originCountRow?.ordinary_count), + agentRecall: numberValue(originCountRow?.agentrecall_count), + all: numberValue(originCountRow?.all_count), + }, }; } diff --git a/apps/main-2.0/src/core/postgres/session-search.test.ts b/apps/main-2.0/src/core/postgres/session-search.test.ts index 063d9ceea..9313677d0 100644 --- a/apps/main-2.0/src/core/postgres/session-search.test.ts +++ b/apps/main-2.0/src/core/postgres/session-search.test.ts @@ -6,6 +6,7 @@ import { PostgresSessionRepository } from "./session-repository"; import { PostgresSessionSearchRepository } from "./session-search-repository"; import { POSTGRES_MIGRATIONS } from "./schema"; import { PGliteTestPool } from "./test-pglite"; +import { PostgresRuntimeInvocationRepository } from "./runtime-invocation-repository"; function session( sessionKey: string, @@ -240,6 +241,82 @@ describe("PostgreSQL Turn search", () => { expect(emptyPage.hasMore).toBe(false); }); + it("groups only sessions explicitly created by AgentRecall and keeps continued user sessions ordinary", async () => { + const invocations = new PostgresRuntimeInvocationRepository(database); + await invocations.begin({ + id: "inv-created", + initiator: "agentrecall", + invocation: { + surface: "evaluation", + role: "subject", + ownerReference: { runId: "run-1", caseResultId: "case-1" }, + }, + runtimeId: "codex", + channelId: "codex-default", + environmentId: "local", + startedAt: Date.parse("2026-07-20T08:00:00.000Z"), + }); + await invocations.bind("inv-created", { + runtimeId: "codex", + channelId: "codex-default", + environmentId: "local", + sessionId: "one", + turnId: "turn-1", + relation: "created", + boundAt: Date.parse("2026-07-20T08:00:01.000Z"), + }); + await invocations.finish("inv-created", "completed", Date.parse("2026-07-20T08:00:02.000Z")); + + await invocations.begin({ + id: "inv-continued", + initiator: "agentrecall", + invocation: { surface: "team_chat", role: "member", ownerReference: { roomId: "room-1" } }, + runtimeId: "claude", + channelId: "claude-default", + environmentId: "local", + startedAt: Date.parse("2026-07-21T08:00:00.000Z"), + }); + await invocations.bind("inv-continued", { + runtimeId: "claude", + channelId: "claude-default", + environmentId: "local", + sessionId: "two", + relation: "continued", + boundAt: Date.parse("2026-07-21T08:00:01.000Z"), + }); + await invocations.finish("inv-continued", "cancelled", Date.parse("2026-07-21T08:00:02.000Z")); + + const ordinary = await searchRepository.searchSessionPage({ origin: "ordinary", excludeSubagents: true }); + expect(ordinary.sessions.map((item) => item.sessionKey).sort()).toEqual(["codex:roles", "codex:two"]); + expect(ordinary.originCounts).toEqual({ ordinary: 2, agentRecall: 1, all: 3 }); + expect(ordinary.sessions.find((item) => item.sessionKey === "codex:two")).toMatchObject({ + createdByAgentRecall: false, + runtimeInvocations: [expect.objectContaining({ + invocationId: "inv-continued", + relation: "continued", + surface: "team_chat", + status: "cancelled", + })], + }); + + const created = await searchRepository.searchSessionPage({ origin: "agentrecall", excludeSubagents: true }); + expect(created.sessions).toHaveLength(1); + expect(created.sessions[0]).toMatchObject({ + sessionKey: "codex:one", + createdByAgentRecall: true, + runtimeInvocations: [expect.objectContaining({ + invocationId: "inv-created", + relation: "created", + runtimeTurnId: "turn-1", + ownerReference: { runId: "run-1", caseResultId: "case-1" }, + })], + }); + await expect(repository.findByRuntimeInvocationOwner({ runId: "run-1" })) + .resolves.toMatchObject({ sessionKey: "codex:one" }); + await expect(repository.findByRuntimeInvocationOwner({ runId: "missing" })) + .resolves.toBeNull(); + }); + it("filters both Claude and Codex StepCode variants as one source", async () => { await repository.upsertIndexedSession( session("stepcode-claude:two", "StepCode Claude", "2026-07-24T08:00:00.000Z", { diff --git a/apps/main-2.0/src/core/session-store.ts b/apps/main-2.0/src/core/session-store.ts index f020c377a..90e150d3e 100644 --- a/apps/main-2.0/src/core/session-store.ts +++ b/apps/main-2.0/src/core/session-store.ts @@ -608,6 +608,13 @@ export class SessionStore { return this.sessions.findByRawId(rawId); } + async findByRuntimeInvocationOwner( + ownerReference: Record, + ): Promise { + await this.ready; + return this.sessions.findByRuntimeInvocationOwner(ownerReference); + } + async setAiSummary(sessionKey: string, summary: string, model: string): Promise { await this.ready; return this.sessions.setAiSummary(sessionKey, summary, model); diff --git a/apps/main-2.0/src/core/types.ts b/apps/main-2.0/src/core/types.ts index 7fd90ccea..14f96e5f3 100644 --- a/apps/main-2.0/src/core/types.ts +++ b/apps/main-2.0/src/core/types.ts @@ -365,6 +365,7 @@ export interface LoadedSession { } export type SessionSourceFilter = SessionSource | "claude" | "codex" | "stepcode" | "all"; +export type SessionOriginFilter = "ordinary" | "agentrecall" | "all"; export interface SearchOptions { query?: string; @@ -382,6 +383,7 @@ export interface SearchOptions { offset?: number; excludeSubagents?: boolean; prioritizeFavorites?: boolean; + origin?: SessionOriginFilter; } export interface ProjectQueryOptions { @@ -440,6 +442,25 @@ export interface SessionSearchResult extends IndexedSession { metadataMatch?: "title" | "project" | "summary" | null; bestTurn?: SessionTurnMatch | null; turnMatchCount?: number; + createdByAgentRecall?: boolean; + runtimeInvocations?: RuntimeInvocationSummary[]; +} + +export interface RuntimeInvocationSummary { + invocationId: string; + surface: string; + role: string | null; + ownerReference: Record; + runtimeId: string; + channelId: string | null; + environmentId: string; + status: "pending" | "completed" | "failed" | "cancelled" | "timed_out"; + startedAt: number; + finishedAt: number | null; + error: string | null; + relation: "created" | "continued"; + runtimeSessionId: string; + runtimeTurnId: string | null; } export interface SessionMatchHit { @@ -465,6 +486,11 @@ export interface SessionSearchPage { sessions: SessionSearchResult[]; totalCount: number; hasMore: boolean; + originCounts: { + ordinary: number; + agentRecall: number; + all: number; + }; } export interface SessionStatsSummary extends TokenUsage { diff --git a/apps/main-2.0/src/main/ipc/session-catalog.test.ts b/apps/main-2.0/src/main/ipc/session-catalog.test.ts new file mode 100644 index 000000000..9ba7ebe0e --- /dev/null +++ b/apps/main-2.0/src/main/ipc/session-catalog.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test, vi } from "vitest"; + +import type { SessionCatalogService } from "../services/session-catalog-service"; +import { registerSessionCatalogIpc } from "./session-catalog"; + +describe("Session catalog IPC Runtime owner boundary", () => { + test("accepts a bounded string map and rejects malformed owner references", async () => { + const handlers = new Map unknown>(); + const findByRuntimeInvocationOwner = vi.fn(async () => ({ sessionKey: "codex:one" })); + registerSessionCatalogIpc({ + handle: (channel, listener) => { + handlers.set(channel, listener as (...args: unknown[]) => unknown); + return undefined as never; + }, + }, { + findByRuntimeInvocationOwner, + } as unknown as SessionCatalogService); + const handler = handlers.get("session:find-by-runtime-owner"); + expect(handler).toBeTypeOf("function"); + + await expect(handler?.({}, { workflowId: "workflow-1", runId: "run-1" })) + .resolves.toEqual({ sessionKey: "codex:one" }); + expect(findByRuntimeInvocationOwner).toHaveBeenCalledWith({ + workflowId: "workflow-1", + runId: "run-1", + }); + expect(() => handler?.({}, [])).toThrow(/must be an object/i); + expect(() => handler?.({}, { workflowId: 1 })).toThrow(/invalid field/i); + expect(() => handler?.({}, {})).toThrow(/between 1 and 32 fields/i); + }); +}); diff --git a/apps/main-2.0/src/main/ipc/session-catalog.ts b/apps/main-2.0/src/main/ipc/session-catalog.ts index a915fdd40..45a2cc02b 100644 --- a/apps/main-2.0/src/main/ipc/session-catalog.ts +++ b/apps/main-2.0/src/main/ipc/session-catalog.ts @@ -25,6 +25,8 @@ export function registerSessionCatalogIpc( ipc.handle("search:session-page", (_event, options: SearchOptions) => service.searchPage(options)); ipc.handle("session:get", (_event, sessionKey: string) => service.get(sessionKey)); ipc.handle("session:find-by-raw-id", (_event, rawId: string) => service.findByRawId(rawId)); + ipc.handle("session:find-by-runtime-owner", (_event, ownerReference: unknown) => + service.findByRuntimeInvocationOwner(runtimeInvocationOwnerReference(ownerReference))); ipc.handle("session:turns", (_event, sessionKey: string) => service.listTurns(sessionKey)); ipc.handle("session:turn", (_event, sessionKey: string, turnId: string) => service.getTurn(sessionKey, turnId)); @@ -70,3 +72,19 @@ export function registerSessionCatalogIpc( ipc.handle("index:refresh", () => service.refreshIndex()); ipc.handle("index:status", () => service.getIndexStatus()); } + +function runtimeInvocationOwnerReference(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Runtime invocation owner reference must be an object."); + } + const entries = Object.entries(value); + if (entries.length === 0 || entries.length > 32) { + throw new Error("Runtime invocation owner reference must contain between 1 and 32 fields."); + } + for (const [key, nested] of entries) { + if (!key || key.length > 80 || typeof nested !== "string" || !nested || nested.length > 1_000) { + throw new Error("Runtime invocation owner reference contains an invalid field."); + } + } + return Object.fromEntries(entries) as Record; +} diff --git a/apps/main-2.0/src/main/services/automation-service.ts b/apps/main-2.0/src/main/services/automation-service.ts index eeebee64a..379513243 100644 --- a/apps/main-2.0/src/main/services/automation-service.ts +++ b/apps/main-2.0/src/main/services/automation-service.ts @@ -53,6 +53,7 @@ import { import { resolveAutomationPaths, type AutomationPaths } from "./automation-paths"; import { EvaluationService, type EvaluationServiceDependencies } from "./evaluation-service"; import type { PostgresDatabase } from "../../core/postgres/database"; +import { PostgresRuntimeInvocationRepository } from "../../core/postgres/runtime-invocation-repository"; import { TeamChatService } from "../team-chat/team-chat-service"; import { PostgresTeamChatStore } from "../team-chat/postgres-team-chat-store"; import { McpAutomationModule } from "./mcp-automation-module"; @@ -307,7 +308,14 @@ export class NativeAutomationService { dependencies: AutomationServiceDependencies = {}, ) { this.paths = resolveAutomationPaths(options.userDataPath); - this.hubInstance = dependencies.hub ?? new AgentHub(); + this.hubInstance = dependencies.hub ?? new AgentHub( + {}, + undefined, + undefined, + undefined, + undefined, + new PostgresRuntimeInvocationRepository(options.database), + ); this.appStore = new PostgresAppStore(options.database, this.paths.fileStoragePath); this.registryInstance = dependencies.registry ?? new McpRegistryStore(options.database); this.loadWorkflows = dependencies.loadBundledWorkflows ?? loadBundledWorkflows; @@ -351,6 +359,11 @@ export class NativeAutomationService { ].join("\n"), workDir, workflowExecution: { workflowId, runId, nodeId, executionId }, + invocation: { + surface: "workflow", + role: "node", + ownerReference: { workflowId, runId, nodeId, executionId }, + }, }, onEvent, signal); const submitted = this.workflowCoreOutputs.finish(executionId); return submitted ?? parseWorkflowAgentOutputs(response.output); @@ -386,6 +399,11 @@ export class NativeAutomationService { { configuredAgentId: input.configuredAgentId, prompt: input.prompt, + invocation: { + surface: "evaluation", + role: input.role, + ownerReference: input.ownerReference, + }, ...(input.developerInstructions ? { developerInstructions: input.developerInstructions } : {}), @@ -419,7 +437,14 @@ export class NativeAutomationService { this.teamChat = dependencies.teamChats ?? new TeamChatService({ storeFactory: () => new PostgresTeamChatStore(options.database), configuredAgents: () => this.hubInstance.snapshot().configuredAgents, - executeAgent: (input, onEvent, signal) => this.configuredAgentExecutor.runConversation(input, onEvent, signal), + executeAgent: (input, onEvent, signal) => this.configuredAgentExecutor.runConversation({ + ...input, + invocation: { + surface: "team_chat", + role: "member", + ownerReference: input.ownerReference, + }, + }, onEvent, signal), }); this.runtime = this.hubInstance; this.workflows = this.hubInstance; @@ -773,6 +798,11 @@ export class NativeAutomationService { const result = await this.configuredAgentExecutor.runOneShot({ configuredAgentId: agent.id, prompt, + invocation: { + surface: "skill", + role: "discovery", + ownerReference: { channelId: channel.id }, + }, }); return result.output; } diff --git a/apps/main-2.0/src/main/services/evaluation-service.test.ts b/apps/main-2.0/src/main/services/evaluation-service.test.ts index 972293182..0ac7959d7 100644 --- a/apps/main-2.0/src/main/services/evaluation-service.test.ts +++ b/apps/main-2.0/src/main/services/evaluation-service.test.ts @@ -113,12 +113,12 @@ describe("EvaluationService", () => { expect(executeAgent).toHaveBeenNthCalledWith( 1, - { configuredAgentId: "target-agent", prompt: "Explain the result" }, + expect.objectContaining({ configuredAgentId: "target-agent", prompt: "Explain the result", role: "subject" }), expect.any(AbortSignal), ); expect(executeAgent).toHaveBeenNthCalledWith( 2, - { configuredAgentId: "judge-agent", prompt: expect.stringContaining("subject output") }, + expect.objectContaining({ configuredAgentId: "judge-agent", prompt: expect.stringContaining("subject output"), role: "judge" }), expect.any(AbortSignal), ); expect(saveRun).toHaveBeenCalledWith(expect.objectContaining({ @@ -158,7 +158,7 @@ describe("EvaluationService", () => { await expect(service.runExperiment("experiment-1")).resolves.toMatchObject({ passRate: 1 }); expect(executeAgent).toHaveBeenCalledWith( - { configuredAgentId: "judge-agent", prompt: expect.any(String) }, + expect.objectContaining({ configuredAgentId: "judge-agent", prompt: expect.any(String), role: "judge" }), expect.any(AbortSignal), ); }); diff --git a/apps/main-2.0/src/main/services/evaluation-service.ts b/apps/main-2.0/src/main/services/evaluation-service.ts index 3af9d85f3..536601953 100644 --- a/apps/main-2.0/src/main/services/evaluation-service.ts +++ b/apps/main-2.0/src/main/services/evaluation-service.ts @@ -34,6 +34,8 @@ export type EvaluationAgentExecution = ( prompt: string; /** Injected with the task; carries the selected skill's instructions. */ developerInstructions?: string; + role: string; + ownerReference: Record; }, signal?: AbortSignal, ) => Promise<{ @@ -369,11 +371,7 @@ export class EvaluationService { readArtifactFiles?: EvaluationServiceDependencies["readArtifactFiles"]; runJudgeScript?: EvaluationServiceDependencies["runJudgeScript"]; execute: EvaluationAgentExecution; - executeJudge: ( - runtimeId: string, - prompt: string, - signal?: AbortSignal, - ) => Promise<{ output: string; durationMs: number }>; + executeJudge: NonNullable; }> { const experiment = (await this.dependencies.store.listExperiments()).find( (item) => item.id === experimentId, @@ -442,14 +440,19 @@ export class EvaluationService { ? { runJudgeScript: this.dependencies.runJudgeScript } : {}), execute: this.dependencies.executeAgent, - executeJudge: (runtimeId, prompt, signal) => { + executeJudge: (runtimeId, prompt, role, ownerReference, signal) => { const judge = judgesByRuntime.get(runtimeId); if (!judge) { throw new Error( `Runtime channel ${runtimeId} does not have an execution Agent for LLM Judge.`, ); } - return this.dependencies.executeAgent({ configuredAgentId: judge.id, prompt }, signal); + return this.dependencies.executeAgent({ + configuredAgentId: judge.id, + prompt, + role, + ownerReference, + }, signal); }, }; } diff --git a/apps/main-2.0/src/main/services/session-catalog-service.ts b/apps/main-2.0/src/main/services/session-catalog-service.ts index 0f02d053e..c4b032be1 100644 --- a/apps/main-2.0/src/main/services/session-catalog-service.ts +++ b/apps/main-2.0/src/main/services/session-catalog-service.ts @@ -80,6 +80,12 @@ export class SessionCatalogService { return this.dependencies.store.findByRawId(rawId); } + async findByRuntimeInvocationOwner( + ownerReference: Record, + ): Promise { + return this.dependencies.store.findByRuntimeInvocationOwner(ownerReference); + } + async get(sessionKey: string): Promise { await this.dependencies.store.markOpened(sessionKey); return this.dependencies.store.getSession(sessionKey); diff --git a/apps/main-2.0/src/main/team-chat/team-chat-service.test.ts b/apps/main-2.0/src/main/team-chat/team-chat-service.test.ts index 9a3d76d82..453b340aa 100644 --- a/apps/main-2.0/src/main/team-chat/team-chat-service.test.ts +++ b/apps/main-2.0/src/main/team-chat/team-chat-service.test.ts @@ -423,6 +423,7 @@ type ExecuteInput = { runtimeConversation?: RuntimeConversation; developerInstructions?: string; agentRecallMcp?: { studioToken?: string }; + ownerReference: Record; }; async function createFixture(options: { @@ -979,6 +980,12 @@ describe("TeamChatService studio employees", () => { } expect(calls[0]?.runtimeConversation).toBeUndefined(); + expect(calls[0]?.ownerReference).toMatchObject({ + roomId: fixture.room.id, + messageId: expect.any(String), + dispatchId: expect.any(String), + attemptId: expect.any(String), + }); expect(calls[1]?.runtimeConversation).toBeUndefined(); expect(calls[2]?.runtimeConversation).toEqual(conversation("one-thread-1")); expect(calls[3]?.runtimeConversation).toEqual(conversation("two-thread-2")); diff --git a/apps/main-2.0/src/main/team-chat/team-chat-service.ts b/apps/main-2.0/src/main/team-chat/team-chat-service.ts index c49eb6c22..ead376fa7 100644 --- a/apps/main-2.0/src/main/team-chat/team-chat-service.ts +++ b/apps/main-2.0/src/main/team-chat/team-chat-service.ts @@ -71,6 +71,7 @@ interface TeamChatServiceDependencies { runtimeConversation?: RuntimeConversation; developerInstructions?: string; agentRecallMcp?: AgentRecallMcpContext; + ownerReference: Record; }, onEvent?: (event: WorkflowAgentEvent) => void, signal?: AbortSignal, @@ -1067,6 +1068,13 @@ export class TeamChatService { ...(currentSession ? { runtimeConversation: currentSession.runtimeConversation } : {}), developerInstructions: buildStudioDeveloperInstructions(input.room, target), agentRecallMcp: { studioToken }, + ownerReference: { + roomId: input.room.id, + messageId: input.sourceMessage.id, + dispatchId, + attemptId, + ...(dispatch.taskId ? { taskId: dispatch.taskId } : {}), + }, }, (event) => { if (eventSequence < MAX_ATTEMPT_EVENTS) { diff --git a/apps/main-2.0/src/preload/index.ts b/apps/main-2.0/src/preload/index.ts index 90b09a837..a4e14758f 100644 --- a/apps/main-2.0/src/preload/index.ts +++ b/apps/main-2.0/src/preload/index.ts @@ -62,6 +62,9 @@ const api = { searchSessionPage: (options: SearchOptions): Promise => ipcRenderer.invoke("search:session-page", options), getSession: (sessionKey: string): Promise => ipcRenderer.invoke("session:get", sessionKey), findSessionByRawId: (rawId: string): Promise => ipcRenderer.invoke("session:find-by-raw-id", rawId), + findSessionByRuntimeInvocationOwner: ( + ownerReference: Record, + ): Promise => ipcRenderer.invoke("session:find-by-runtime-owner", ownerReference), getSessionContextComponents: (sessionKey: string): Promise => ipcRenderer.invoke("session:context-components", sessionKey), getMessages: (sessionKey: string, offset?: number, limit?: number): Promise => diff --git a/apps/main-2.0/src/renderer/src/App.tsx b/apps/main-2.0/src/renderer/src/App.tsx index 631a0d890..bc420505f 100644 --- a/apps/main-2.0/src/renderer/src/App.tsx +++ b/apps/main-2.0/src/renderer/src/App.tsx @@ -321,6 +321,9 @@ export function App(): ReactElement { setQuery, source, setSource, + origin, + setOrigin, + originCounts, environmentId, setEnvironmentId, tag, @@ -1863,6 +1866,8 @@ export function App(): ReactElement { collapsedProjectGroups, expandedTreeProjects: collapsedTreeProjects, source, + origin, + originCounts, sourceFilters: visibleSourceFilters, visibility, searchRef, @@ -1913,6 +1918,7 @@ export function App(): ReactElement { }, deleteTag: setDeleteTagName, setSource, + setOrigin, setTag, setVisibility, search: setQuery, @@ -2003,10 +2009,23 @@ export function App(): ReactElement { runtimeReviewEnabled={Boolean(appSettings?.workflowRuntimeReviewEnabled)} initialRequest={workflowInitialRequest} onInitialRequestConsumed={() => setWorkflowInitialRequest(undefined)} + onOpenSession={(sessionKey) => { + void window.sessionSearch.getSession(sessionKey).then((session) => { + if (session) void openDetail(session); + }); + }} /> : null} {activePage === "team-chat" ? ( - + { + void window.sessionSearch.getSession(sessionKey).then((session) => { + if (session) void openDetail(session); + }); + }} + /> ) : null} {activePage === "evaluation" ? ( @@ -2146,6 +2165,27 @@ export function App(): ReactElement { t("Plain text copied.", "纯文本已复制。"), ), deleteSession: requestDeleteSession, + openInvocationOwner: (invocation) => { + closeDetail(); + if (invocation.surface === "workflow") { + const workflowId = invocation.ownerReference.workflowId; + void openWorkflows(workflowId ? { workflowId } : undefined); + return; + } + if (invocation.surface === "team_chat") { + setPreferredTeamChatRoomId(invocation.ownerReference.roomId); + void navigateToPage("team-chat"); + return; + } + const page: AppPage = invocation.surface === "evaluation" + ? "evaluation" + : invocation.surface === "skill" + ? "skills" + : invocation.surface === "system" + ? "runtimes" + : "workbench"; + void navigateToPage(page); + }, reveal: (session) => void runAction( `Opening ${FILE_MANAGER_LABEL}`, () => window.sessionSearch.revealSession(session.sessionKey), diff --git a/apps/main-2.0/src/renderer/src/features/automation/workflow-feature-page.test.tsx b/apps/main-2.0/src/renderer/src/features/automation/workflow-feature-page.test.tsx index 4e8da9ac5..040f4d65e 100644 --- a/apps/main-2.0/src/renderer/src/features/automation/workflow-feature-page.test.tsx +++ b/apps/main-2.0/src/renderer/src/features/automation/workflow-feature-page.test.tsx @@ -13,6 +13,10 @@ const api = vi.hoisted(() => ({ saveWorkflowDefinition: vi.fn(), })); +const sessionSearch = vi.hoisted(() => ({ + findSessionByRuntimeInvocationOwner: vi.fn(), +})); + vi.mock("../../../../automation/engine/renderer/src/app/services/agent-recall-service", () => ({ agentRecallAutomationService: () => api, })); @@ -118,6 +122,10 @@ describe("WorkflowFeaturePage live output", () => { root = createRoot(container); const snapshot = runningWorkflow(); api.getWorkflowCore.mockResolvedValue({ definitions: [snapshot.definition], runs: [snapshot.run] }); + Object.defineProperty(window, "sessionSearch", { + configurable: true, + value: sessionSearch, + }); }); afterEach(async () => { @@ -225,6 +233,46 @@ describe("WorkflowFeaturePage live output", () => { expect(output?.textContent).not.toContain("最新一次的结果"); }); + it("opens the Session recorded for the selected Workflow run", async () => { + const snapshot = completedWorkflow(); + const onOpenSession = vi.fn(); + sessionSearch.findSessionByRuntimeInvocationOwner.mockResolvedValue({ sessionKey: "session-1" }); + api.getWorkflowCore.mockResolvedValue({ definitions: [snapshot.definition], runs: [snapshot.run] }); + + await act(async () => { + root.render( + , + ); + await Promise.resolve(); + }); + const runTab = [...container.querySelectorAll(".workflow-core-mode button")] + .find((button) => button.textContent?.includes("运行记录")); + if (!runTab) throw new Error("Run history tab was not rendered"); + await act(async () => { + runTab.click(); + await Promise.resolve(); + }); + const sessionButton = [...container.querySelectorAll(".workflow-core-toolbar-actions button")] + .find((button) => button.textContent?.includes("Session")); + if (!sessionButton) throw new Error("Session button was not rendered"); + + await act(async () => { + sessionButton.click(); + await Promise.resolve(); + }); + + expect(sessionSearch.findSessionByRuntimeInvocationOwner).toHaveBeenCalledWith({ + workflowId: "workflow-1", + runId: "run-1", + }); + expect(onOpenSession).toHaveBeenCalledWith("session-1"); + }); + it("checks the right-clicked Workflow before enabling deletion", async () => { const selected = runningWorkflow(); selected.definition.id = "workflow-a"; diff --git a/apps/main-2.0/src/renderer/src/features/automation/workflow-feature-page.tsx b/apps/main-2.0/src/renderer/src/features/automation/workflow-feature-page.tsx index a04978094..f423b282e 100644 --- a/apps/main-2.0/src/renderer/src/features/automation/workflow-feature-page.tsx +++ b/apps/main-2.0/src/renderer/src/features/automation/workflow-feature-page.tsx @@ -341,12 +341,14 @@ export function WorkflowFeaturePage({ language, initialRequest, onInitialRequestConsumed, + onOpenSession, }: { language: LanguageMode; globalReviewEnabled: boolean; runtimeReviewEnabled: boolean; initialRequest?: WorkflowInitialRequest; onInitialRequestConsumed?: () => void; + onOpenSession?: (sessionKey: string) => void; }): ReactElement { const api = useMemo(() => agentRecallAutomationService(), []); const automation = useAutomationStoreSnapshot(); @@ -574,6 +576,23 @@ export function WorkflowFeaturePage({ } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); } finally { setBusy(false); } }; const menuDefinition = personalMenu ? personalDefinitions.find((definition) => definition.id === personalMenu.definitionId) : undefined; + const openSelectedRunSession = async (): Promise => { + if (!draft || !selectedRun) return; + setError(undefined); + try { + const session = await window.sessionSearch.findSessionByRuntimeInvocationOwner({ + workflowId: draft.id, + runId: selectedRun.id, + }); + if (!session) { + setError(localize(language, "The Session for this run has not been indexed yet.", "该运行对应的 Session 尚未完成索引。")); + return; + } + onOpenSession?.(session.sessionKey); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } + }; return

Workflow

{localize(language, "Build dependable automations from explicit inputs, nodes, and described outputs.", "用明确的输入、节点和带描述的输出构建可靠自动化。")}

@@ -587,6 +606,7 @@ export function WorkflowFeaturePage({
: null} {!draft ?
Create a Workflow to begin.
:
{!isTemplate ?
: 模板预览}
+ {!isTemplate && mode === "run" && selectedRun ? : null} {isTemplate ? : <>{mode === "definition" ? : null}{controllableRun?.status === "running" ? <> : controllableRun?.status === "paused" ? <> : controllableRun?.status === "waiting" ? : }}
{error ?
{error}
: null} diff --git a/apps/main-2.0/src/renderer/src/features/session-detail/detail-panel.test.tsx b/apps/main-2.0/src/renderer/src/features/session-detail/detail-panel.test.tsx index 815779170..403181812 100644 --- a/apps/main-2.0/src/renderer/src/features/session-detail/detail-panel.test.tsx +++ b/apps/main-2.0/src/renderer/src/features/session-detail/detail-panel.test.tsx @@ -92,10 +92,30 @@ describe("DetailPanel Turn controls", () => { }); it("opens in-conversation find with Ctrl+F and exposes role filters in Turn mode", async () => { + const onOpenInvocationOwner = vi.fn(); await act(async () => { root.render( { onReveal={vi.fn()} readOnly sessionFamily={{ parent: null, children: [], truncated: false }} + onOpenInvocationOwner={onOpenInvocationOwner} />, ); }); + expect(container.querySelector(".runtime-invocation-history")?.textContent) + .toContain("Created by AgentRecall"); + const sourceButton = [...container.querySelectorAll(".runtime-invocation-history button")] + .find((button) => button.textContent?.includes("Back to source")); + await act(async () => sourceButton?.click()); + expect(onOpenInvocationOwner).toHaveBeenCalledWith(expect.objectContaining({ + invocationId: "invocation-1", + ownerReference: { workflowId: "workflow-1", runId: "run-1" }, + })); + const roleGroup = container.querySelector('[role="group"][aria-label="Conversation role filter"]'); expect(roleGroup?.querySelectorAll("button")).toHaveLength(3); expect(roleGroup?.textContent).toContain("All"); diff --git a/apps/main-2.0/src/renderer/src/features/session-detail/detail-panel.tsx b/apps/main-2.0/src/renderer/src/features/session-detail/detail-panel.tsx index 56855f5db..ad2c55d22 100644 --- a/apps/main-2.0/src/renderer/src/features/session-detail/detail-panel.tsx +++ b/apps/main-2.0/src/renderer/src/features/session-detail/detail-panel.tsx @@ -5,6 +5,7 @@ import { formatMessageTime } from "../../../../core/format-session"; import { traceCompactionSummary, traceDetailText, traceDurationLabel, tracePresentation } from "../../../../core/trace-presentation"; import type { SessionMessage, + RuntimeInvocationSummary, SessionSearchResult, SessionTraceEvent, SessionTurnDetail, @@ -127,6 +128,50 @@ function conversationRoleEmptyLabel(filter: Exclude = { + workflow: ["Workflow", "工作流"], + evaluation: ["Evaluation", "评测"], + team_chat: ["Team Chat", "团队会话"], + agent: ["Agent", "智能体"], + skill: ["Skill", "技能"], + system: ["System", "系统"], + }; + const label = labels[surface]; + return label ? localize(language, label[0], label[1]) : surface; +} + +function invocationRoleLabel(role: string | null, language: LanguageMode): string | null { + if (!role) return null; + const labels: Record = { + recovery_manager: ["Recovery manager", "恢复管理"], + reviewer: ["Reviewer", "审核"], + node: ["Node", "节点"], + draft: ["Draft", "草稿生成"], + chat: ["Chat", "对话"], + task: ["Task", "任务"], + subject: ["Subject", "受测对象"], + judge: ["Judge", "裁判"], + member: ["Member", "成员"], + discovery: ["Discovery", "探索"], + channel_test: ["Connection test", "连接测试"], + }; + const label = labels[role]; + return label ? localize(language, label[0], label[1]) : role; +} + +function invocationStatusLabel(status: RuntimeInvocationSummary["status"], language: LanguageMode): string { + const labels: Record = { + pending: ["Running", "进行中"], + completed: ["Completed", "已完成"], + failed: ["Failed", "失败"], + cancelled: ["Cancelled", "已取消"], + timed_out: ["Timed out", "已超时"], + }; + const label = labels[status]; + return localize(language, label[0], label[1]); +} + export function DetailPanel({ session, turns, @@ -176,6 +221,7 @@ export function DetailPanel({ onOpenFamilySession, sessionFamilyLoadFailed = false, onRetrySessionFamily, + onOpenInvocationOwner, }: { session: SessionSearchResult; turns: SessionTurnSummary[] | null; @@ -225,6 +271,7 @@ export function DetailPanel({ onOpenFamilySession?: (sessionKey: string) => void; sessionFamilyLoadFailed?: boolean; onRetrySessionFamily?: () => void; + onOpenInvocationOwner?: (invocation: RuntimeInvocationSummary) => void; }): ReactElement { const context = matchedContextMessages; const actionRunning = actionStatus?.kind === "running"; @@ -530,6 +577,37 @@ export function DetailPanel({ + {(session.runtimeInvocations?.length ?? 0) > 0 ? ( +
+
+ {session.createdByAgentRecall + ? l("Created by AgentRecall", "由 AgentRecall 创建") + : l("Continued by AgentRecall", "曾由 AgentRecall 续接")} + {l( + `${session.runtimeInvocations?.length ?? 0} invocations`, + `${session.runtimeInvocations?.length ?? 0} 次调用`, + )} +
+
+ {session.runtimeInvocations?.map((invocation) => ( +
+ {invocationSurfaceLabel(invocation.surface, language)} + {invocationRoleLabel(invocation.role, language) + ? ` · ${invocationRoleLabel(invocation.role, language)}` + : ""} + {invocationStatusLabel(invocation.status, language)} · {new Date(invocation.startedAt).toLocaleString( + language === "zh" ? "zh-CN" : "en-US", + )} + {onOpenInvocationOwner && Object.keys(invocation.ownerReference).length > 0 ? ( + + ) : null} +
+ ))} +
+
+ ) : null} {!readOnly ?
{canResume ? ( diff --git a/apps/main-2.0/src/renderer/src/features/sessions/session-details.tsx b/apps/main-2.0/src/renderer/src/features/sessions/session-details.tsx index c9af2a5d8..27ad128c4 100644 --- a/apps/main-2.0/src/renderer/src/features/sessions/session-details.tsx +++ b/apps/main-2.0/src/renderer/src/features/sessions/session-details.tsx @@ -3,6 +3,7 @@ import type { RemoteSessionDetailSnapshot } from "../../../../core/remote-sessio import type { SessionFamily } from "../../../../core/session-family"; import type { SessionSearchResult, + RuntimeInvocationSummary, SessionTurnDetail, SessionTurnSummary, } from "../../../../core/types"; @@ -50,6 +51,7 @@ export interface SessionDetailsActions { copyPlain(session: SessionSearchResult): void; deleteSession(session: SessionSearchResult): void; reveal(session: SessionSearchResult): void; + openInvocationOwner(invocation: RuntimeInvocationSummary): void; } export function SessionDetails({ @@ -147,6 +149,7 @@ export function SessionDetails({ }} sessionFamilyLoadFailed={familyLoadFailed} onRetrySessionFamily={() => setFamilyRetryVersion((current) => current + 1)} + onOpenInvocationOwner={actions.openInvocationOwner} messages={[]} matchedContextMessages={[]} matchedMessageIndex={matchedMessageIndex} diff --git a/apps/main-2.0/src/renderer/src/features/sessions/sessions-page.test.tsx b/apps/main-2.0/src/renderer/src/features/sessions/sessions-page.test.tsx index a0ade0f93..797c064d6 100644 --- a/apps/main-2.0/src/renderer/src/features/sessions/sessions-page.test.tsx +++ b/apps/main-2.0/src/renderer/src/features/sessions/sessions-page.test.tsx @@ -57,6 +57,12 @@ describe("SessionsPage search tools", () => { await act(async () => root.render()); expect(container.querySelector(".toolbar-primary .searchbox")).not.toBeNull(); expect(container.querySelector(".toolbar-secondary .toolbar-filters")).not.toBeNull(); + const invocationGroup = [...container.querySelectorAll(".session-origin-filter button")] + .find((button) => button.textContent?.includes("AgentRecall calls")); + expect(invocationGroup?.textContent).toContain("(2)"); + expect(invocationGroup?.getAttribute("aria-expanded")).toBe("false"); + await act(async () => invocationGroup?.click()); + expect(actions.setOrigin).toHaveBeenCalledWith("agentrecall"); const advancedButton = buttonByLabel(container, "Advanced search"); await act(async () => advancedButton.click()); @@ -111,6 +117,8 @@ function createModel(): SessionsPageModel { language: "en", indexStatus: null, sessionTotalCount: 0, + origin: "ordinary", + originCounts: { ordinary: 3, agentRecall: 2, all: 5 }, sidebarSections: { environments: false, remaining: false, sources: false, views: false }, environmentId: "all", tags: ["important"], @@ -156,6 +164,7 @@ function createActions(): SessionsPageActions { toggleProjectTag: vi.fn(), deleteTag: vi.fn(), setSource: vi.fn(), + setOrigin: vi.fn(), setTag: vi.fn(), setVisibility: vi.fn(), search: vi.fn(), diff --git a/apps/main-2.0/src/renderer/src/features/sessions/sessions-page.tsx b/apps/main-2.0/src/renderer/src/features/sessions/sessions-page.tsx index a5e1faeb7..0f9bfea3c 100644 --- a/apps/main-2.0/src/renderer/src/features/sessions/sessions-page.tsx +++ b/apps/main-2.0/src/renderer/src/features/sessions/sessions-page.tsx @@ -102,6 +102,8 @@ export interface SessionsPageModel { collapsedProjectGroups: Set; expandedTreeProjects: Set; source: SearchOptions["source"]; + origin: NonNullable; + originCounts: { ordinary: number; agentRecall: number; all: number }; sourceFilters: Array<{ label: string; value: SearchOptions["source"] }>; visibility: "default" | "favorites" | "hidden"; searchRef: RefObject; @@ -135,6 +137,7 @@ export interface SessionsPageActions { toggleProjectTag(project: ProjectSummary, tagName: string): void; deleteTag(tagName: string): void; setSource(source: SearchOptions["source"]): void; + setOrigin(origin: NonNullable): void; setTag(tag: string | undefined): void; setVisibility(visibility: SessionsPageModel["visibility"]): void; search(query: string): void; @@ -470,6 +473,30 @@ export function SessionsPage({
+
+ + + +
{model.bulkSelectionActive ? 0 && model.sessions.every((session) => model.bulkSelectedKeys.has(session.sessionKey))} diff --git a/apps/main-2.0/src/renderer/src/features/sessions/use-session-catalog.test.tsx b/apps/main-2.0/src/renderer/src/features/sessions/use-session-catalog.test.tsx index 95c798a39..a06e458ad 100644 --- a/apps/main-2.0/src/renderer/src/features/sessions/use-session-catalog.test.tsx +++ b/apps/main-2.0/src/renderer/src/features/sessions/use-session-catalog.test.tsx @@ -55,7 +55,7 @@ describe("useSessionCatalog pagination", () => { } await act(async () => root.render(createElement(Harness))); - await vi.waitFor(() => expect(searchSessionPage).toHaveBeenCalledWith(expect.objectContaining({ offset: 0 }))); + await vi.waitFor(() => expect(searchSessionPage).toHaveBeenCalledWith(expect.objectContaining({ offset: 0, origin: "ordinary" }))); await act(async () => catalog.goToPage(3)); await vi.waitFor(() => expect(searchSessionPage).toHaveBeenCalledWith(expect.objectContaining({ offset: 60 }))); diff --git a/apps/main-2.0/src/renderer/src/features/sessions/use-session-catalog.ts b/apps/main-2.0/src/renderer/src/features/sessions/use-session-catalog.ts index b801c1c7d..ad2897ba5 100644 --- a/apps/main-2.0/src/renderer/src/features/sessions/use-session-catalog.ts +++ b/apps/main-2.0/src/renderer/src/features/sessions/use-session-catalog.ts @@ -41,6 +41,7 @@ export function useSessionCatalog({ }) { const [query, setQuery] = useState(""); const [source, setSource] = useState("all"); + const [origin, setOrigin] = useState>("ordinary"); const [environmentId, setEnvironmentId] = useState("all"); const [tag, setTag] = useState(); const [projectPath, setProjectPath] = useState(); @@ -57,6 +58,7 @@ export function useSessionCatalog({ page: 1, }); const [sessionTotalCount, setSessionTotalCount] = useState(0); + const [originCounts, setOriginCounts] = useState({ ordinary: 0, agentRecall: 0, all: 0 }); const [results, setResults] = useState([]); const [resultsScopeKey, setResultsScopeKey] = useState(null); const [selectedKey, setSelectedKey] = useState(null); @@ -74,6 +76,7 @@ export function useSessionCatalog({ JSON.stringify([ query, source, + origin, environmentId, tag ?? "", projectPath ?? "", @@ -88,6 +91,7 @@ export function useSessionCatalog({ [ query, source, + origin, environmentId, tag, projectPath, @@ -121,6 +125,7 @@ export function useSessionCatalog({ const options: SearchOptions = { query, source, + origin, tag, projectPath: searchScope.projectPath, environmentId: searchScope.environmentId, @@ -134,7 +139,7 @@ export function useSessionCatalog({ liveSessionKeys: liveDetectionFailed ? [] : liveSearchKeys, }; const page = searchScope.projectEnvironmentConflict - ? { sessions: [], totalCount: 0, hasMore: false } + ? { sessions: [], totalCount: 0, hasMore: false, originCounts: { ordinary: 0, agentRecall: 0, all: 0 } } : await window.sessionSearch.searchSessionPage(options); if (requestId !== loadSeqRef.current) return; const lastPage = Math.max(1, Math.ceil(page.totalCount / SESSION_PAGE_SIZE)); @@ -147,6 +152,7 @@ export function useSessionCatalog({ setResults(page.sessions); setResultsScopeKey(requestScopeKey); setSessionTotalCount(page.totalCount); + setOriginCounts(page.originCounts ?? { ordinary: page.totalCount, agentRecall: 0, all: page.totalCount }); setSelectedKey((current) => current && !page.sessions.some((session) => session.sessionKey === current) @@ -157,6 +163,7 @@ export function useSessionCatalog({ }, [ query, source, + origin, environmentId, tag, projectPath, @@ -183,6 +190,7 @@ export function useSessionCatalog({ const page = await window.sessionSearch.searchSessionPage({ query, source, + origin, tag, projectPath: searchScope.projectPath, environmentId: searchScope.environmentId, @@ -196,7 +204,7 @@ export function useSessionCatalog({ }); if (page.hasMore) throw new Error("More than 100,000 sessions match. Narrow the filters first."); return page.sessions; - }, [environmentId, projectPath, projectEnvironmentId, customDateRange, dateRange, query, source, tag, visibility, sortBy, liveStatus, liveDetectionFailed, liveSearchKeys]); + }, [environmentId, projectPath, projectEnvironmentId, customDateRange, dateRange, query, source, origin, tag, visibility, sortBy, liveStatus, liveDetectionFailed, liveSearchKeys]); const clearProjectFilter = useCallback((): void => { setProjectPath(undefined); @@ -324,6 +332,9 @@ export function useSessionCatalog({ setQuery, source, setSource, + origin, + setOrigin, + originCounts, environmentId, setEnvironmentId, tag, diff --git a/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.test.tsx b/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.test.tsx index 1528ecaf6..d58f6a4dc 100644 --- a/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.test.tsx +++ b/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.test.tsx @@ -46,6 +46,7 @@ describe("TeamChatPage rooms", () => { let root: Root; let fixture: ReturnType; let teamChat: ReturnType; + let findSessionByRuntimeInvocationOwner = vi.fn(); beforeEach(() => { Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true); @@ -54,9 +55,10 @@ describe("TeamChatPage rooms", () => { root = createRoot(container); fixture = createTeamChatFixture(); teamChat = fixture; + findSessionByRuntimeInvocationOwner = vi.fn(async () => null); Object.defineProperty(window, "sessionSearch", { configurable: true, - value: { teamChat }, + value: { teamChat, findSessionByRuntimeInvocationOwner }, }); vi.spyOn(window, "confirm").mockReturnValue(true); }); @@ -139,6 +141,31 @@ describe("TeamChatPage rooms", () => { }); }); + it("opens the latest Session recorded for the active room", async () => { + fixture.setRooms([roomFixture("room-alpha", "Alpha")]); + findSessionByRuntimeInvocationOwner.mockResolvedValue({ sessionKey: "session-1" }); + const onOpenSession = vi.fn(); + + await act(async () => root.render( + , + )); + await vi.waitFor(() => expect( + container.querySelector(".team-chat-room-title strong")?.textContent, + ).toBe("Alpha")); + const sessionButton = container.querySelector( + 'button[aria-label="Open latest Session"]', + ); + if (!sessionButton) throw new Error("Open latest Session button was not rendered"); + + await act(async () => { + sessionButton.click(); + await Promise.resolve(); + }); + + expect(findSessionByRuntimeInvocationOwner).toHaveBeenCalledWith({ roomId: "room-alpha" }); + expect(onOpenSession).toHaveBeenCalledWith("session-1"); + }); + it("clears deleted room details after switching rooms during a pending delete", async () => { fixture.setRooms([ roomFixture("room-alpha", "Alpha"), diff --git a/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.tsx b/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.tsx index ade1c553b..347a691fc 100644 --- a/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.tsx +++ b/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.tsx @@ -161,9 +161,11 @@ export function TeamChatRoomTitle({ export function TeamChatPage({ language, preferredRoomId, + onOpenSession, }: { language: LanguageMode; preferredRoomId?: string; + onOpenSession?: (sessionKey: string) => void; }): ReactElement { const l = useCallback((en: string, zh: string) => localize(language, en, zh), [language]); const api = useMemo(() => window.sessionSearch.teamChat, []); @@ -218,6 +220,22 @@ export function TeamChatPage({ const transcriptEndRef = useRef(null); const skipNextAutoScrollRef = useRef(false); + const openLatestRoomSession = async (): Promise => { + if (!activeRoom) return; + try { + const session = await window.sessionSearch.findSessionByRuntimeInvocationOwner({ + roomId: activeRoom.id, + }); + if (!session) { + setContextFeedback(l("This room's latest Session has not been indexed yet.", "该工作室最近的 Session 尚未完成索引。")); + return; + } + onOpenSession?.(session.sessionKey); + } catch (error) { + setContextFeedback(errorMessage(error)); + } + }; + const isCurrentRoomScope = useCallback((roomId: string, epoch: number): boolean => selectedRoomIdRef.current === roomId && roomEpochRef.current === epoch, []); @@ -1012,6 +1030,14 @@ export function TeamChatPage({ {activeRoom.workDir || l("No working directory", "未设置工作目录")}
+
- getLiveSessionState( - session, - model.liveSessionKeys, - model.liveDetectionFailed, - )} - language={model.language} - onOpenMatch={actions.openMatch} - onSelect={actions.selectSession} - onOpen={actions.openSession} - onRename={actions.renameSession} - onFavorite={actions.toggleFavorite} - onContextMenu={actions.openContextMenu} - bulkSelectionActive={model.bulkSelectionActive} - bulkSelectedKeys={model.bulkSelectedKeys} - onToggleBulk={actions.toggleBulkSession} - /> + {model.origin === "all" ? ( + <> + {renderSessionResults(ordinarySessions)} + {model.originCounts.agentRecall > 0 ? ( +
+ + {agentRecallGroupOpen ? ( + renderSessionResults(agentRecallSessions) + ) : null} +
+ ) : null} + + ) : ( + renderSessionResults(model.sessions) + )} {model.sessions.length === 0 ?
{l("No sessions found.", "没有找到会话。")}
: null} diff --git a/apps/main-2.0/src/renderer/src/features/sessions/use-session-catalog.test.tsx b/apps/main-2.0/src/renderer/src/features/sessions/use-session-catalog.test.tsx index a06e458ad..e1a78b76f 100644 --- a/apps/main-2.0/src/renderer/src/features/sessions/use-session-catalog.test.tsx +++ b/apps/main-2.0/src/renderer/src/features/sessions/use-session-catalog.test.tsx @@ -55,7 +55,7 @@ describe("useSessionCatalog pagination", () => { } await act(async () => root.render(createElement(Harness))); - await vi.waitFor(() => expect(searchSessionPage).toHaveBeenCalledWith(expect.objectContaining({ offset: 0, origin: "ordinary" }))); + await vi.waitFor(() => expect(searchSessionPage).toHaveBeenCalledWith(expect.objectContaining({ offset: 0, origin: "all" }))); await act(async () => catalog.goToPage(3)); await vi.waitFor(() => expect(searchSessionPage).toHaveBeenCalledWith(expect.objectContaining({ offset: 60 }))); diff --git a/apps/main-2.0/src/renderer/src/features/sessions/use-session-catalog.ts b/apps/main-2.0/src/renderer/src/features/sessions/use-session-catalog.ts index ad2897ba5..a62fb2f73 100644 --- a/apps/main-2.0/src/renderer/src/features/sessions/use-session-catalog.ts +++ b/apps/main-2.0/src/renderer/src/features/sessions/use-session-catalog.ts @@ -41,7 +41,7 @@ export function useSessionCatalog({ }) { const [query, setQuery] = useState(""); const [source, setSource] = useState("all"); - const [origin, setOrigin] = useState>("ordinary"); + const [origin, setOrigin] = useState>("all"); const [environmentId, setEnvironmentId] = useState("all"); const [tag, setTag] = useState(); const [projectPath, setProjectPath] = useState(); diff --git a/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.test.tsx b/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.test.tsx index d58f6a4dc..f66c0bf0a 100644 --- a/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.test.tsx +++ b/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.test.tsx @@ -166,6 +166,39 @@ describe("TeamChatPage rooms", () => { expect(onOpenSession).toHaveBeenCalledWith("session-1"); }); + it("opens the Session recorded for an individual agent message", async () => { + fixture.setRooms([roomFixture("room-alpha", "Alpha")]); + fixture.setRoomMessages("room-alpha", [{ + ...messageFixture("agent-message", "room-alpha", 2, "Done"), + senderType: "agent", + senderAgentId: "member-1", + senderName: "Builder", + sourceMessageId: "human-message", + }]); + findSessionByRuntimeInvocationOwner.mockResolvedValue({ sessionKey: "session-message" }); + const onOpenSession = vi.fn(); + + await act(async () => root.render( + , + )); + await vi.waitFor(() => expect(container.textContent).toContain("Done")); + const sessionButton = container.querySelector( + 'button[aria-label="Open Session for Builder"]', + ); + if (!sessionButton) throw new Error("Message Session button was not rendered"); + + await act(async () => { + sessionButton.click(); + await Promise.resolve(); + }); + + expect(findSessionByRuntimeInvocationOwner).toHaveBeenCalledWith({ + roomId: "room-alpha", + messageId: "human-message", + }); + expect(onOpenSession).toHaveBeenCalledWith("session-message"); + }); + it("clears deleted room details after switching rooms during a pending delete", async () => { fixture.setRooms([ roomFixture("room-alpha", "Alpha"), diff --git a/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.tsx b/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.tsx index 347a691fc..97bf5398a 100644 --- a/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.tsx +++ b/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.tsx @@ -161,10 +161,12 @@ export function TeamChatRoomTitle({ export function TeamChatPage({ language, preferredRoomId, + preferredMessageId, onOpenSession, }: { language: LanguageMode; preferredRoomId?: string; + preferredMessageId?: string; onOpenSession?: (sessionKey: string) => void; }): ReactElement { const l = useCallback((en: string, zh: string) => localize(language, en, zh), [language]); @@ -218,6 +220,7 @@ export function TeamChatPage({ const roomSelectButtonRefs = useRef(new Map()); const composerRef = useRef(null); const transcriptEndRef = useRef(null); + const focusedMessageIdRef = useRef(undefined); const skipNextAutoScrollRef = useRef(false); const openLatestRoomSession = async (): Promise => { @@ -236,6 +239,23 @@ export function TeamChatPage({ } }; + const openMessageSession = async (message: TeamChatMessage): Promise => { + const messageId = message.sourceMessageId ?? message.id; + try { + const session = await window.sessionSearch.findSessionByRuntimeInvocationOwner({ + roomId: message.roomId, + messageId, + }); + if (!session) { + setContextFeedback(l("This message's Session has not been indexed yet.", "该消息对应的 Session 尚未完成索引。")); + return; + } + onOpenSession?.(session.sessionKey); + } catch (error) { + setContextFeedback(errorMessage(error)); + } + }; + const isCurrentRoomScope = useCallback((roomId: string, epoch: number): boolean => selectedRoomIdRef.current === roomId && roomEpochRef.current === epoch, []); @@ -523,6 +543,16 @@ export function TeamChatPage({ transcriptEndRef.current?.scrollIntoView({ block: "end" }); }, [messages.length, streams]); + useEffect(() => { + if (!preferredMessageId || focusedMessageIdRef.current === preferredMessageId) return; + const target = [...document.querySelectorAll("[data-message-id]")] + .find((element) => element.dataset.messageId === preferredMessageId); + if (!target) return; + focusedMessageIdRef.current = preferredMessageId; + target.scrollIntoView?.({ block: "center" }); + target.focus?.(); + }, [messages, preferredMessageId]); + const loadEarlierMessages = useCallback(async (): Promise => { const roomId = selectedRoomIdRef.current; if (!roomId || activeRoom?.id !== roomId || !nextBefore || loadingEarlierRef.current) return; @@ -1095,6 +1125,7 @@ export function TeamChatPage({ member={activeRoom.agents.find((member) => member.agentId === message.senderAgentId)} recipient={activeRoom.agents.find((member) => member.agentId === message.recipientMemberId)} language={language} + onOpenSession={message.senderType === "agent" ? () => void openMessageSession(message) : undefined} /> ))} {Object.values(streams).map((stream) => ( @@ -1651,19 +1682,31 @@ function TeamChatMessageCard({ member, recipient, language, + onOpenSession, }: { message: TeamChatMessage; member?: TeamChatRoomAgent; recipient?: TeamChatRoomAgent; language: LanguageMode; + onOpenSession?: () => void; }): ReactElement { return ( -
+
{message.senderName} {recipient ? → {recipient.displayName} : null} {message.deliveryType === "post" ? {localize(language, "post", "公告")} : null} {member ? {member.runtimeId} : null} + {onOpenSession ? ( + + ) : null}
From 7b893c072ae497115b642d70fdf2b777427b8561 Mon Sep 17 00:00:00 2001 From: Akuma <2374973868@qq.com> Date: Fri, 4 Sep 2026 09:19:03 +0800 Subject: [PATCH 04/11] fix(v2): complete runtime session attribution --- .../fix-issue-535-runtime-sessions.md | 3 +- .../engine/main/agents/dsh/dsh-runner.test.ts | 30 +++ .../engine/main/agents/dsh/dsh-runner.ts | 87 +++++++- .../agents/dsh/dsh-session-discovery.test.ts | 108 ++++++++++ .../main/agents/dsh/dsh-session-discovery.ts | 186 ++++++++++++++++++ .../main/agents/hermes/hermes-runner.ts | 24 ++- .../main/agents/openclaw/openclaw-runner.ts | 17 +- .../main/agents/opencode/opencode-runner.ts | 19 ++ .../runtime/native-session-reporting.test.ts | 119 +++++++++++ .../main/agents/runtime/runtime-driver.ts | 2 + .../runtime/runtime-router-invocation.test.ts | 45 +++++ .../main/agents/runtime/runtime-router.ts | 12 +- .../acp-workflow-one-shot-executor.test.ts | 59 ++++++ .../acp-workflow-one-shot-executor.ts | 16 +- .../executor/claude/claude-test.test.ts | 42 ++++ .../runtime/executor/claude/claude-test.ts | 6 + .../runtime/executor/dsh/dsh-workflow.test.ts | 29 ++- .../hub/runtime/executor/dsh/dsh-workflow.ts | 26 ++- .../executor/hermes/create-hermes-driver.ts | 1 + .../executor/hermes/hermes-workflow.ts | 25 ++- .../openclaw/create-openclaw-driver.ts | 1 + .../executor/openclaw/openclaw-executor.ts | 2 +- .../executor/openclaw/openclaw-workflow.ts | 26 ++- .../opencode/create-opencode-driver.ts | 1 + .../executor/opencode/opencode-workflow.ts | 26 ++- .../src/automation/engine/shared/types.ts | 19 +- .../postgres/runtime-invocation-repository.ts | 15 ++ apps/main-2.0/src/core/postgres/schema.ts | 2 + .../src/core/postgres/session-repository.ts | 49 ++++- .../postgres/session-search-repository.ts | 45 +++++ .../src/core/postgres/session-search.test.ts | 68 ++++++- .../postgres/support-repositories.test.ts | 40 ++++ apps/main-2.0/src/core/session-store.ts | 9 +- apps/main-2.0/src/core/types.ts | 19 +- .../src/main/ipc/session-catalog.test.ts | 13 +- apps/main-2.0/src/main/ipc/session-catalog.ts | 4 +- .../src/main/services/automation-service.ts | 5 +- .../main/services/session-catalog-service.ts | 9 +- .../main/team-chat/team-chat-service.test.ts | 1 + .../src/main/team-chat/team-chat-service.ts | 1 + apps/main-2.0/src/preload/index.ts | 7 +- apps/main-2.0/src/renderer/src/App.tsx | 45 ++++- .../automation/runtime-feature-page.tsx | 15 +- .../automation/workflow-feature-page.test.tsx | 9 +- .../automation/workflow-feature-page.tsx | 9 +- .../renderer/src/features/eval/eval-page.tsx | 15 +- .../src/features/eval/eval-runs-page.test.tsx | 22 +++ .../src/features/eval/eval-runs-page.tsx | 21 +- .../features/session-detail/detail-panel.tsx | 20 +- .../runtime-session-resolution.test.ts | 30 +++ .../sessions/runtime-session-resolution.ts | 27 +++ .../features/sessions/sessions-page.test.tsx | 52 ++++- .../src/features/sessions/sessions-page.tsx | 111 +++++++---- .../sessions/use-session-catalog.test.tsx | 10 +- .../features/sessions/use-session-catalog.ts | 32 ++- .../team-chat/team-chat-page.test.tsx | 58 +++++- .../src/features/team-chat/team-chat-page.tsx | 103 ++++++++-- .../src/renderer/src/styles/sessions.css | 63 ++++++ .../main-2.0/src/shared/runtime-invocation.ts | 11 ++ docs/v2/guide.md | 9 + 60 files changed, 1708 insertions(+), 172 deletions(-) create mode 100644 apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-session-discovery.test.ts create mode 100644 apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-session-discovery.ts create mode 100644 apps/main-2.0/src/automation/engine/main/agents/runtime/native-session-reporting.test.ts create mode 100644 apps/main-2.0/src/automation/engine/main/hub/runtime/executor/acp-workflow-one-shot-executor.test.ts create mode 100644 apps/main-2.0/src/automation/engine/main/hub/runtime/executor/claude/claude-test.test.ts create mode 100644 apps/main-2.0/src/renderer/src/features/sessions/runtime-session-resolution.test.ts create mode 100644 apps/main-2.0/src/renderer/src/features/sessions/runtime-session-resolution.ts create mode 100644 apps/main-2.0/src/shared/runtime-invocation.ts diff --git a/.release-notes/fix-issue-535-runtime-sessions.md b/.release-notes/fix-issue-535-runtime-sessions.md index 40f263a4e..d5b37e8c5 100644 --- a/.release-notes/fix-issue-535-runtime-sessions.md +++ b/.release-notes/fix-issue-535-runtime-sessions.md @@ -4,4 +4,5 @@ ## Bug 修复 -- AgentRecall 发起的 Runtime 会话现在会在 Session 页面单独归组,并展示调用来源、状态与返回入口,不再混入默认会话列表。 +- AgentRecall 发起的 Codex、Claude Code、Hermes、OpenCode、OpenClaw 与 DeepSeek Harness 会话现在归入默认收起的独立分组,可按 Workflow、Eval、Team Chat、Agent、Skill 和系统任务筛选,不再挤占普通会话列表。 +- Session 详情和业务记录现在提供双向入口,并明确区分 Runtime 未返回 Session 引用、Session 尚未完成索引和没有调用记录。 diff --git a/apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-runner.test.ts b/apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-runner.test.ts index ac0ba122c..b15a5b1ca 100644 --- a/apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-runner.test.ts +++ b/apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-runner.test.ts @@ -65,6 +65,11 @@ function createRunner( ...overrides, }, { platform: "linux", + createSessionDiscovery: () => ({ + prepare: () => undefined, + observe: () => undefined, + finish: async () => undefined, + }), ...dependencies, }), events, @@ -101,6 +106,31 @@ describe("DshRunner", () => { expect(exits).toEqual([0]); }); + test("reports the Session discovered during the owned headless run", async () => { + const proc = createProcess(); + const createSessionDiscovery = vi.fn((_sessionsRoot: string, onSessionId: (sessionId: string) => void) => ({ + prepare: () => undefined, + observe: () => onSessionId("session-dsh-created"), + finish: async () => undefined, + })); + const { runner, events } = createRunner({}, { createSessionDiscovery }); + const started = runner.start(); + + proc.stdout.write("Done"); + proc.emit("close", 0, null); + await started; + + expect(createSessionDiscovery).toHaveBeenCalledOnce(); + expect(events[0]).toMatchObject({ + type: "runtime_conversation", + runtimeConversation: { + runtimeId: "dsh", + payload: { native: { sessionId: "session-dsh-created" } }, + }, + }); + expect(events[1]).toEqual({ type: "completed", content: "Done" }); + }); + test("reports a successful process that produced no assistant text", async () => { const proc = createProcess(); const { runner, events, exits } = createRunner(); diff --git a/apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-runner.ts b/apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-runner.ts index 167bd1ffe..5dce5ebb4 100644 --- a/apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-runner.ts +++ b/apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-runner.ts @@ -3,6 +3,11 @@ import { StringDecoder } from "node:string_decoder"; import type { AgentEvent } from "../../../shared/types"; import { spawnCli } from "../../platform/cli-launcher"; import { resolveWindowsDshInvocation } from "./dsh-windows-launcher"; +import { + DshSessionDiscovery, + dshSessionsRoot, + type DshSessionDiscoveryHandle, +} from "./dsh-session-discovery"; const MAX_STDERR_CHARS = 8_000; const MAX_POSIX_PROMPT_BYTES = 120_000; @@ -24,6 +29,10 @@ interface DshRunnerDependencies { spawnProcess: typeof spawn; killProcess: typeof process.kill; resolveWindowsInvocation: typeof resolveWindowsDshInvocation; + createSessionDiscovery: ( + sessionsRoot: string, + onSessionId: (sessionId: string) => void, + ) => DshSessionDiscoveryHandle; } interface ActiveDshRun { @@ -39,18 +48,26 @@ interface ActiveDshRun { stopTimer?: ReturnType; terminalTimer?: ReturnType; stopPromise?: Promise; + sessionDiscovery: DshSessionDiscoveryHandle; } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function abortError(): Error { + const error = new Error("DSH runner start was cancelled."); + error.name = "AbortError"; + return error; +} + function boundedAppend(current: string, text: string): string { return `${current}${text}`.slice(-MAX_STDERR_CHARS); } export class DshRunner { private active: ActiveDshRun | undefined; + private pendingStartAbort: AbortController | undefined; private readonly dependencies: DshRunnerDependencies; constructor( @@ -62,12 +79,16 @@ export class DshRunner { spawnProcess: spawn, killProcess: process.kill, resolveWindowsInvocation: resolveWindowsDshInvocation, + createSessionDiscovery: (sessionsRoot, onSessionId) => + new DshSessionDiscovery(sessionsRoot, onSessionId), ...dependencies, }; } async start(): Promise { - if (this.active) throw new Error("DSH runner is already running."); + if (this.active || this.pendingStartAbort) { + throw new Error("DSH runner is already running."); + } let exitReported = false; const reportExit = (code: number | null): void => { @@ -78,6 +99,20 @@ export class DshRunner { let proc: ChildProcess; let activeCreated = false; + const startAbort = new AbortController(); + this.pendingStartAbort = startAbort; + const environment = this.options.env ?? process.env; + const sessionDiscovery = this.dependencies.createSessionDiscovery( + dshSessionsRoot(environment), + (sessionId) => this.options.onEvent({ + type: "runtime_conversation", + runtimeConversation: { + runtimeId: "dsh", + codecVersion: "v1", + payload: { native: { sessionId } }, + }, + }), + ); try { if ( this.dependencies.platform !== "win32" @@ -87,6 +122,9 @@ export class DshRunner { "The DSH prompt is too large for a single command-line argument. Shorten the request or attached instructions.", ); } + const preparation = sessionDiscovery.prepare(startAbort.signal); + if (preparation) await preparation; + if (startAbort.signal.aborted) throw abortError(); let executable = this.options.executable; let args = ["--profile", "headless", this.options.prompt]; let stdin: string | undefined; @@ -95,7 +133,7 @@ export class DshRunner { const invocation = this.dependencies.resolveWindowsInvocation({ executable, args, - environment: this.options.env ?? process.env, + environment, workingDirectory: this.options.cwd, }); executable = invocation.executable; @@ -107,7 +145,7 @@ export class DshRunner { executable, args, cwd: this.options.cwd, - env: this.options.env ?? process.env, + env: environment, stdio: supportsIpcInterrupt ? [stdin === undefined ? "ignore" : "pipe", "pipe", "pipe", "ipc"] : [stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"], @@ -128,9 +166,12 @@ export class DshRunner { rejectCompletion, finished: false, stopping: false, + sessionDiscovery, }; this.active = active; + this.pendingStartAbort = undefined; activeCreated = true; + sessionDiscovery.observe(); const stdoutDecoder = new StringDecoder("utf8"); const stderrDecoder = new StringDecoder("utf8"); @@ -146,10 +187,10 @@ export class DshRunner { this.options.onStderr?.(text); }); - const finish = ( + const finish = async ( code: number | null, processError?: unknown, - ): void => { + ): Promise => { if (active.finished) return; active.finished = true; stdout += stdoutDecoder.end(); @@ -158,6 +199,11 @@ export class DshRunner { if (this.active === active) this.active = undefined; let callbackError: unknown; + try { + await active.sessionDiscovery.finish(); + } catch (error) { + callbackError = error; + } try { if (active.terminalError) { this.options.onEvent({ @@ -190,7 +236,7 @@ export class DshRunner { } } } catch (error) { - callbackError = error; + callbackError ??= error; } try { reportExit( @@ -215,12 +261,12 @@ export class DshRunner { this.forceTerminateProcessTree(active); active.terminalTimer = setTimeout(() => { active.terminalTimer = undefined; - finish(null, new Error(message)); + void finish(null, new Error(message)); }, TERMINATION_GRACE_MS + TERMINATION_CLOSE_MS); }; - proc.once("close", (code) => finish(code)); - proc.once("error", (error) => finish(null, error)); + proc.once("close", (code) => void finish(code)); + proc.once("error", (error) => void finish(null, error)); const missingPipes = [ !proc.stdout ? "stdout" : undefined, @@ -250,8 +296,25 @@ export class DshRunner { await completion; return; } catch (error) { + let discoveryCleanupError: unknown; + try { + await sessionDiscovery.finish(); + } catch (cleanupError) { + discoveryCleanupError = cleanupError; + } + if (this.pendingStartAbort === startAbort) this.pendingStartAbort = undefined; + if (startAbort.signal.aborted) { + reportExit(null); + return; + } if (activeCreated) throw error; - const runtimeError = new Error(`DSH process error: ${errorMessage(error)}`); + const runtimeError = new Error( + `DSH process error: ${errorMessage(error)}${ + discoveryCleanupError === undefined + ? "" + : `; Session discovery cleanup failed: ${errorMessage(discoveryCleanupError)}` + }`, + ); try { this.options.onEvent({ type: "error", error: runtimeError.message }); } finally { @@ -262,6 +325,10 @@ export class DshRunner { } async stop(): Promise { + if (this.pendingStartAbort) { + this.pendingStartAbort.abort(); + return; + } const active = this.active; if (!active) return; if (!active.stopPromise) { diff --git a/apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-session-discovery.test.ts b/apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-session-discovery.test.ts new file mode 100644 index 000000000..1e2353fe7 --- /dev/null +++ b/apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-session-discovery.test.ts @@ -0,0 +1,108 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DshSessionDiscovery } from "./dsh-session-discovery"; + +describe("DshSessionDiscovery", () => { + afterEach(() => vi.useRealTimers()); + + it("reports the only Session created after the owned DSH process starts", async () => { + vi.useFakeTimers(); + let ids = new Set(["session-existing"]); + const onSessionId = vi.fn(); + const discovery = new DshSessionDiscovery("/dsh/sessions", onSessionId, { + readSessionIds: vi.fn(async () => new Set(ids)), + setInterval, + clearInterval, + }); + + await discovery.prepare(new AbortController().signal); + discovery.observe(); + ids = new Set([...ids, "session-created"]); + await vi.advanceTimersByTimeAsync(100); + await discovery.finish(); + + expect(onSessionId).toHaveBeenCalledOnce(); + expect(onSessionId).toHaveBeenCalledWith("session-created"); + }); + + it("does not guess when more than one Session appears in the discovery window", async () => { + vi.useFakeTimers(); + let ids = new Set(); + const onSessionId = vi.fn(); + const discovery = new DshSessionDiscovery("/dsh/ambiguous", onSessionId, { + readSessionIds: vi.fn(async () => new Set(ids)), + setInterval, + clearInterval, + }); + + await discovery.prepare(new AbortController().signal); + discovery.observe(); + ids = new Set(["session-one", "session-two"]); + await vi.advanceTimersByTimeAsync(100); + await discovery.finish(); + + expect(onSessionId).not.toHaveBeenCalled(); + }); + + it("does not attribute Sessions when the owned process never entered observation", async () => { + let ids = new Set(); + const onSessionId = vi.fn(); + const discovery = new DshSessionDiscovery("/dsh/not-started", onSessionId, { + readSessionIds: vi.fn(async () => new Set(ids)), + setInterval, + clearInterval, + }); + + await discovery.prepare(new AbortController().signal); + ids = new Set(["session-external"]); + await discovery.finish(); + + expect(onSessionId).not.toHaveBeenCalled(); + }); + + it("surfaces polling failures during deterministic cleanup", async () => { + vi.useFakeTimers(); + const readSessionIds = vi.fn() + .mockResolvedValueOnce(new Set()) + .mockRejectedValueOnce(new Error("read denied")); + const discovery = new DshSessionDiscovery("/dsh/unreadable", vi.fn(), { + readSessionIds, + setInterval, + clearInterval, + }); + + await discovery.prepare(new AbortController().signal); + discovery.observe(); + await vi.advanceTimersByTimeAsync(100); + + await expect(discovery.finish()).rejects.toThrow("read denied"); + }); + + it("keeps later discovery queued when an intermediate waiter is cancelled", async () => { + const dependencies = { + readSessionIds: vi.fn(async () => new Set()), + setInterval, + clearInterval, + }; + const first = new DshSessionDiscovery("/dsh/serialized", vi.fn(), dependencies); + const cancelled = new DshSessionDiscovery("/dsh/serialized", vi.fn(), dependencies); + const last = new DshSessionDiscovery("/dsh/serialized", vi.fn(), dependencies); + const cancelledController = new AbortController(); + + await first.prepare(new AbortController().signal); + const cancelledPreparation = cancelled.prepare(cancelledController.signal); + cancelledController.abort(); + await expect(cancelledPreparation).rejects.toMatchObject({ name: "AbortError" }); + + let lastPrepared = false; + const lastPreparation = last.prepare(new AbortController().signal).then(() => { + lastPrepared = true; + }); + await Promise.resolve(); + expect(lastPrepared).toBe(false); + + await first.finish(); + await lastPreparation; + expect(lastPrepared).toBe(true); + await last.finish(); + }); +}); diff --git a/apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-session-discovery.ts b/apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-session-discovery.ts new file mode 100644 index 000000000..58a71da06 --- /dev/null +++ b/apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-session-discovery.ts @@ -0,0 +1,186 @@ +import { readdir } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const DEFAULT_DSH_HOME_NAME = ".dsh"; +const DISCOVERY_INTERVAL_MS = 100; +const discoveryTails = new Map>(); + +export interface DshSessionDiscoveryHandle { + prepare(signal: AbortSignal): Promise | void; + observe(): void; + finish(): Promise; +} + +interface DshSessionDiscoveryDependencies { + readSessionIds: (sessionsRoot: string) => Promise>; + setInterval: typeof setInterval; + clearInterval: typeof clearInterval; +} + +function abortError(): Error { + const error = new Error("DSH Session discovery was cancelled."); + error.name = "AbortError"; + return error; +} + +async function waitForTurn(previous: Promise, signal: AbortSignal): Promise { + if (signal.aborted) throw abortError(); + await new Promise((resolve, reject) => { + const handleAbort = (): void => { + reject(abortError()); + }; + signal.addEventListener("abort", handleAbort, { once: true }); + void previous.then( + () => { + signal.removeEventListener("abort", handleAbort); + resolve(); + }, + (error: unknown) => { + signal.removeEventListener("abort", handleAbort); + reject(error); + }, + ); + }); +} + +async function acquireDiscoveryTurn(key: string, signal: AbortSignal): Promise<() => void> { + const previous = discoveryTails.get(key) ?? Promise.resolve(); + let releaseSlot!: () => void; + const slot = new Promise((resolve) => { + releaseSlot = resolve; + }); + const tail = previous.then(() => slot); + discoveryTails.set(key, tail); + try { + await waitForTurn(previous, signal); + } catch (error) { + releaseSlot(); + void tail.then(() => { + if (discoveryTails.get(key) === tail) discoveryTails.delete(key); + }); + throw error; + } + let released = false; + return () => { + if (released) return; + released = true; + releaseSlot(); + if (discoveryTails.get(key) === tail) discoveryTails.delete(key); + }; +} + +async function readDshSessionIds(sessionsRoot: string): Promise> { + const ids = new Set(); + let projects; + try { + projects = await readdir(sessionsRoot, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return ids; + throw error; + } + await Promise.all(projects.filter((entry) => entry.isDirectory()).map(async (project) => { + let sessions; + try { + sessions = await readdir(join(sessionsRoot, project.name), { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + for (const session of sessions) { + if (session.isDirectory() && session.name) ids.add(session.name); + } + })); + return ids; +} + +export function dshSessionsRoot(environment: NodeJS.ProcessEnv): string { + const dshHome = environment.DSH_HOME?.trim() || join(homedir(), DEFAULT_DSH_HOME_NAME); + return join(dshHome, "sessions"); +} + +/** + * Attributes the one fresh Session created by an official DSH headless run. + * AgentRecall serializes only this short discovery window. More than one new + * Session is intentionally left unbound because ownership would be ambiguous. + */ +export class DshSessionDiscovery implements DshSessionDiscoveryHandle { + private baseline: Set | undefined; + private releaseTurn: (() => void) | undefined; + private timer: ReturnType | undefined; + private checking: Promise = Promise.resolve(); + private discoveryError: unknown; + private observing = false; + private reported = false; + + constructor( + private readonly sessionsRoot: string, + private readonly onSessionId: (sessionId: string) => void, + private readonly dependencies: DshSessionDiscoveryDependencies = { + readSessionIds: readDshSessionIds, + setInterval, + clearInterval, + }, + ) {} + + async prepare(signal: AbortSignal): Promise { + this.releaseTurn = await acquireDiscoveryTurn(this.sessionsRoot, signal); + try { + this.baseline = await this.dependencies.readSessionIds(this.sessionsRoot); + } catch (error) { + this.release(); + throw error; + } + } + + observe(): void { + if (!this.baseline || this.timer) return; + this.observing = true; + this.timer = this.dependencies.setInterval(() => { + this.enqueueCheck(); + }, DISCOVERY_INTERVAL_MS); + this.enqueueCheck(); + } + + async finish(): Promise { + this.clearTimer(); + if (this.observing) this.enqueueCheck(); + try { + await this.checking; + } finally { + this.release(); + } + if (this.discoveryError !== undefined) throw this.discoveryError; + } + + private enqueueCheck(): void { + if (!this.baseline || this.reported || this.discoveryError) return; + this.checking = this.checking + .then(async () => { + if (!this.baseline || this.reported || this.discoveryError) return; + const current = await this.dependencies.readSessionIds(this.sessionsRoot); + const created = [...current].filter((sessionId) => !this.baseline?.has(sessionId)); + if (created.length !== 1) return; + this.reported = true; + this.onSessionId(created[0]!); + this.clearTimer(); + this.release(); + }) + .catch((error: unknown) => { + this.discoveryError ??= error; + this.clearTimer(); + this.release(); + }); + } + + private clearTimer(): void { + if (!this.timer) return; + this.dependencies.clearInterval(this.timer); + this.timer = undefined; + } + + private release(): void { + this.releaseTurn?.(); + this.releaseTurn = undefined; + } +} diff --git a/apps/main-2.0/src/automation/engine/main/agents/hermes/hermes-runner.ts b/apps/main-2.0/src/automation/engine/main/agents/hermes/hermes-runner.ts index e672bf0c2..22082676c 100644 --- a/apps/main-2.0/src/automation/engine/main/agents/hermes/hermes-runner.ts +++ b/apps/main-2.0/src/automation/engine/main/agents/hermes/hermes-runner.ts @@ -2,6 +2,7 @@ import type { ChildProcess } from "node:child_process"; import type { AgentEvent } from "../../../shared/types"; import { runtimeModelId } from "../../../shared/models"; import { spawnCli } from "../../platform/cli-launcher"; +import { hermesRuntimeStateCodec } from "./hermes-runtime-state-codec"; export interface HermesRunOptions { executable: string; @@ -21,11 +22,12 @@ export class HermesRunner { constructor(private readonly options: HermesRunOptions) {} async start(): Promise { - const args = ["-z", this.options.prompt]; + const args = ["chat", "--quiet", "--query", this.options.prompt]; const modelArg = runtimeModelId(this.options.modelId ?? ""); if (modelArg) { args.push("--model", modelArg); } + args.push("--source", "tool"); const proc = spawnCli({ executable: this.options.executable, @@ -65,6 +67,19 @@ export class HermesRunner { finish(() => { const content = stdout.trim(); if (!this.stopping && code === 0) { + const sessionId = hermesSessionIdFromStderr(stderr); + if (sessionId) { + this.options.onEvent({ + type: "runtime_conversation", + runtimeConversation: hermesRuntimeStateCodec.encodeConversation({ + native: { sessionId }, + appContext: { + cwd: this.options.cwd, + ...(this.options.modelId ? { modelId: this.options.modelId } : {}), + }, + }), + }); + } if (content) this.options.onEvent({ type: "completed", content }); else this.options.onEvent({ type: "error", error: "Hermes completed without assistant text." }); } else if (!this.stopping) { @@ -93,3 +108,10 @@ export class HermesRunner { this.proc?.kill("SIGINT"); } } + +/** Reads the machine-readable session reference emitted by `hermes chat --quiet`. */ +export function hermesSessionIdFromStderr(stderr: string): string | undefined { + let sessionId: string | undefined; + for (const match of stderr.matchAll(/^session_id:\s*(\S+)\s*$/gm)) sessionId = match[1]; + return sessionId; +} diff --git a/apps/main-2.0/src/automation/engine/main/agents/openclaw/openclaw-runner.ts b/apps/main-2.0/src/automation/engine/main/agents/openclaw/openclaw-runner.ts index 04951f506..608f5ab21 100644 --- a/apps/main-2.0/src/automation/engine/main/agents/openclaw/openclaw-runner.ts +++ b/apps/main-2.0/src/automation/engine/main/agents/openclaw/openclaw-runner.ts @@ -2,6 +2,7 @@ import type { ChildProcess } from "node:child_process"; import type { AgentEvent } from "../../../shared/types"; import { runtimeModelId } from "../../../shared/models"; import { spawnCli } from "../../platform/cli-launcher"; +import { openClawRuntimeStateCodec } from "./openclaw-runtime-state-codec"; const MAX_STDERR_CHARS = 8_000; @@ -10,7 +11,7 @@ export interface OpenClawRunOptions { cwd: string; env?: NodeJS.ProcessEnv; prompt: string; - sessionKey: string; + sessionId: string; modelId?: string; onEvent: (event: AgentEvent) => void; onStderr?: (text: string) => void; @@ -61,8 +62,8 @@ export class OpenClawRunner { async start(): Promise { const args = [ "agent", - "--session-key", - this.options.sessionKey, + "--session-id", + this.options.sessionId, "--message", this.options.prompt, "--json", @@ -82,6 +83,16 @@ export class OpenClawRunner { this.proc = undefined; throw new Error("OpenClaw runner failed to create stdout/stderr pipes."); } + this.options.onEvent({ + type: "runtime_conversation", + runtimeConversation: openClawRuntimeStateCodec.encodeConversation({ + native: { sessionId: this.options.sessionId }, + appContext: { + cwd: this.options.cwd, + ...(this.options.modelId ? { modelId: this.options.modelId } : {}), + }, + }), + }); let stdout = ""; let stderr = ""; diff --git a/apps/main-2.0/src/automation/engine/main/agents/opencode/opencode-runner.ts b/apps/main-2.0/src/automation/engine/main/agents/opencode/opencode-runner.ts index 9b7a0af64..4a4236428 100644 --- a/apps/main-2.0/src/automation/engine/main/agents/opencode/opencode-runner.ts +++ b/apps/main-2.0/src/automation/engine/main/agents/opencode/opencode-runner.ts @@ -2,6 +2,7 @@ import type { ChildProcess } from "node:child_process"; import type { AgentEvent } from "../../../shared/types"; import { runtimeModelId } from "../../../shared/models"; import { spawnCli } from "../../platform/cli-launcher"; +import { openCodeRuntimeStateCodec } from "./opencode-runtime-state-codec"; const MAX_STDERR_CHARS = 8_000; @@ -98,6 +99,7 @@ export class OpenCodeRunner { let content = ""; let stderr = ""; let runtimeError: string | undefined; + let reportedSessionId: string | undefined; const handleLine = (line: string): void => { const trimmed = line.trim(); if (!trimmed) return; @@ -109,6 +111,23 @@ export class OpenCodeRunner { this.options.onEvent({ type: "error", error: runtimeError }); return; } + if ( + typeof record.sessionID === "string" + && record.sessionID + && record.sessionID !== reportedSessionId + ) { + reportedSessionId = record.sessionID; + this.options.onEvent({ + type: "runtime_conversation", + runtimeConversation: openCodeRuntimeStateCodec.encodeConversation({ + native: { sessionId: record.sessionID }, + appContext: { + cwd: this.options.cwd, + ...(this.options.modelId ? { modelId: this.options.modelId } : {}), + }, + }), + }); + } for (const event of agentEventsFromOpenCodeJson(record)) { if (event.type === "delta") content += event.content; if (event.type === "error") runtimeError = event.error; diff --git a/apps/main-2.0/src/automation/engine/main/agents/runtime/native-session-reporting.test.ts b/apps/main-2.0/src/automation/engine/main/agents/runtime/native-session-reporting.test.ts new file mode 100644 index 000000000..cc6943077 --- /dev/null +++ b/apps/main-2.0/src/automation/engine/main/agents/runtime/native-session-reporting.test.ts @@ -0,0 +1,119 @@ +import type { ChildProcess } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AgentEvent } from "../../../shared/types"; + +const cli = vi.hoisted(() => ({ spawnCli: vi.fn() })); + +vi.mock("../../platform/cli-launcher", () => ({ spawnCli: cli.spawnCli })); + +import { HermesRunner } from "../hermes/hermes-runner"; +import { OpenClawRunner } from "../openclaw/openclaw-runner"; +import { OpenCodeRunner } from "../opencode/opencode-runner"; + +class FakeChildProcess extends EventEmitter { + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + readonly kill = vi.fn(() => true); +} + +function createProcess(): FakeChildProcess { + const process = new FakeChildProcess(); + cli.spawnCli.mockReturnValue(process as unknown as ChildProcess); + return process; +} + +describe("native Runtime Session reporting", () => { + beforeEach(() => { + cli.spawnCli.mockReset(); + }); + + it("reports OpenCode's sessionID once before completion", async () => { + const process = createProcess(); + const events: AgentEvent[] = []; + const runner = new OpenCodeRunner({ + executable: "opencode", + cwd: "/repo", + prompt: "Review", + onEvent: (event) => events.push(event), + onExit: vi.fn(), + }); + + const started = runner.start(); + process.stdout.write(`${JSON.stringify({ type: "step_start", sessionID: "session-open-code", part: {} })}\n`); + process.stdout.write(`${JSON.stringify({ type: "text", sessionID: "session-open-code", part: { type: "text", text: "Done" } })}\n`); + process.emit("exit", 0); + await started; + + expect(events.filter((event) => event.type === "runtime_conversation")).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "runtime_conversation", + runtimeConversation: { + runtimeId: "opencode", + payload: { native: { sessionId: "session-open-code" } }, + }, + }); + expect(events.at(-1)).toEqual({ type: "completed", content: "Done" }); + }); + + it("selects and reports an explicit OpenClaw session id", async () => { + const process = createProcess(); + const events: AgentEvent[] = []; + const runner = new OpenClawRunner({ + executable: "openclaw", + cwd: "/repo", + prompt: "Review", + sessionId: "invocation-1", + onEvent: (event) => events.push(event), + onExit: vi.fn(), + }); + + const started = runner.start(); + expect(cli.spawnCli).toHaveBeenCalledWith(expect.objectContaining({ + args: ["agent", "--session-id", "invocation-1", "--message", "Review", "--json"], + })); + process.stdout.write(JSON.stringify({ status: "ok", payloads: [{ text: "Done" }] })); + process.emit("exit", 0); + await started; + + expect(events[0]).toMatchObject({ + type: "runtime_conversation", + runtimeConversation: { + runtimeId: "openclaw", + payload: { native: { sessionId: "invocation-1" } }, + }, + }); + expect(events[1]).toEqual({ type: "completed", content: "Done" }); + }); + + it("reports the session id from Hermes' quiet machine-readable output", async () => { + const process = createProcess(); + const events: AgentEvent[] = []; + const runner = new HermesRunner({ + executable: "hermes", + cwd: "/repo", + prompt: "Review", + onEvent: (event) => events.push(event), + onExit: vi.fn(), + }); + + const started = runner.start(); + expect(cli.spawnCli).toHaveBeenCalledWith(expect.objectContaining({ + args: ["chat", "--quiet", "--query", "Review", "--source", "tool"], + })); + process.stdout.write("Done\n"); + process.stderr.write("\nsession_id: session-hermes\n"); + process.emit("exit", 0); + await started; + + expect(events[0]).toMatchObject({ + type: "runtime_conversation", + runtimeConversation: { + runtimeId: "hermes", + payload: { native: { sessionId: "session-hermes" } }, + }, + }); + expect(events[1]).toEqual({ type: "completed", content: "Done" }); + }); +}); diff --git a/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-driver.ts b/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-driver.ts index 0c0c6c62b..5278e5e6e 100644 --- a/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-driver.ts +++ b/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-driver.ts @@ -65,6 +65,8 @@ export interface RuntimeChannelTestContext { invocationId?: string; /** Execution environment used to scope native Session identifiers. */ environmentId?: string; + /** Reports a native Session or Turn created while testing the channel. */ + reportExecutionReference?: ((reference: RuntimeExecutionReference) => void) | undefined; emit: (event: Omit) => void; } diff --git a/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-router-invocation.test.ts b/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-router-invocation.test.ts index 0a430062c..9ea2ed1c5 100644 --- a/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-router-invocation.test.ts +++ b/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-router-invocation.test.ts @@ -164,6 +164,51 @@ describe("RuntimeRouter invocation lifecycle", () => { })); }); + test("binds the native Session created by a channel test", async () => { + const events: string[] = []; + const runtimeDriver: RuntimeDriver = { + ...driver(async () => ({ content: "unused" })), + surfaceSupport: [{ + surface: "channel-test", + executionModes: ["oneshot"], + continuationPolicies: ["fresh"], + }], + testChannel: async (input) => { + expect(input.invocationId).toBe("invocation-channel-test"); + input.emit({ type: "phase", content: "Runtime started" }); + input.reportExecutionReference?.({ sessionId: "thread-channel-test" }); + return "OK"; + }, + }; + const router = new RuntimeRouter( + new RuntimeDriverRegistry([runtimeDriver]), + recorder(events), + () => 3_500, + () => "invocation-channel-test", + ); + + const emit = vi.fn(); + await expect(router.testChannel("codex", { + runtime, + channelId: "codex-default", + modelId: "default", + workDir: "/workspace", + emit, + })).resolves.toBe("OK"); + + expect(emit).toHaveBeenCalledWith({ + type: "phase", + content: "Runtime started", + invocationId: "invocation-channel-test", + }); + + expect(events).toEqual([ + "begin:system", + "bind:thread-channel-test:created", + "finish:completed", + ]); + }); + test("marks a resume-preferred fallback as created when the Runtime returns a new Session", async () => { const events: string[] = []; const runtimeDriver = driver(async (input) => { diff --git a/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-router.ts b/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-router.ts index 2bec8d5df..0994f9b4f 100644 --- a/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-router.ts +++ b/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-router.ts @@ -276,8 +276,18 @@ export class RuntimeRouter { }, }, input.channelId); await lifecycle.begin(); + let callbackQueue = Promise.resolve(); try { - const result = await driver.testChannel({ ...input, invocationId: lifecycle.id }); + const result = await driver.testChannel({ + ...input, + invocationId: lifecycle.id, + emit: (event) => input.emit({ ...event, invocationId: lifecycle.id }), + reportExecutionReference: (reference) => { + callbackQueue = callbackQueue.then(() => lifecycle.bindReference(reference)); + void callbackQueue.catch(() => undefined); + }, + }); + await callbackQueue; await lifecycle.finish("completed"); return result; } catch (error) { diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/acp-workflow-one-shot-executor.test.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/acp-workflow-one-shot-executor.test.ts new file mode 100644 index 000000000..fa77cefd1 --- /dev/null +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/acp-workflow-one-shot-executor.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AgentEvent } from "../../../../shared/types"; +import { openCodeRuntimeStateCodec } from "../../../agents/opencode/opencode-runtime-state-codec"; +import { AcpWorkflowOneShotExecutor } from "./acp-workflow-one-shot-executor"; +import type { AgentExecutionContext } from "./agent-executor-types"; + +describe("AcpWorkflowOneShotExecutor", () => { + it("publishes the native session returned by ACP attach", async () => { + const events: AgentEvent[] = []; + const exits: Array = []; + const client = { + attach: vi.fn(async () => "session-acp"), + prompt: vi.fn(async () => undefined), + interrupt: vi.fn(async () => undefined), + detach: vi.fn(async () => undefined), + }; + const context: AgentExecutionContext = { + runId: "task-1", + runKind: "task", + configuredAgentId: "configured-opencode", + runtimeId: "opencode", + executionMode: "oneshot", + continuationPolicy: "fresh", + runtimeConfig: { model: "model-1" }, + invocation: { surface: "workflow", role: "node", ownerReference: { workflowId: "workflow-1" } }, + runtime: { id: "opencode", label: "OpenCode", version: "1", available: true, command: "opencode" }, + channelId: "opencode-default", + prompt: "Review", + workDir: "/repo", + developerInstructions: "Follow the workflow contract.", + emit: (event) => events.push(event), + onExit: (code) => exits.push(code), + }; + + await new AcpWorkflowOneShotExecutor(context, { + executable: "opencode", + args: ["acp"], + mcpServers: [], + modelId: "model-1", + runtimeStateCodec: openCodeRuntimeStateCodec, + createClient: () => client, + }).start(); + + expect(events).toEqual([{ + type: "runtime_conversation", + runtimeConversation: { + runtimeId: "opencode", + codecVersion: "v1", + payload: { + native: { sessionId: "session-acp" }, + appContext: { cwd: "/repo", modelId: "model-1", transport: "acp" }, + }, + }, + }]); + expect(client.prompt).toHaveBeenCalledWith("Follow the workflow contract.\n\nUser request:\nReview"); + expect(client.detach).toHaveBeenCalledOnce(); + expect(exits).toEqual([0]); + }); +}); diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/acp-workflow-one-shot-executor.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/acp-workflow-one-shot-executor.ts index b3d17776d..4da6e600f 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/acp-workflow-one-shot-executor.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/acp-workflow-one-shot-executor.ts @@ -6,6 +6,8 @@ import { import type { AgentExecutionContext, AgentExecutor } from "./agent-executor-types"; import { promptWithDeveloperInstructions } from "./runtime-instructions"; import { workflowMcpScopeForContext } from "../../../../shared/workflow-mcp-policy"; +import type { AcpRuntimeConversationPayload } from "../../../agents/acp/acp-runtime-state-codec"; +import type { RuntimeStateCodec } from "../../../agents/runtime/runtime-state-codec"; interface AcpOneShotClient { attach(): Promise; @@ -19,6 +21,7 @@ interface AcpWorkflowOneShotOptions { args: string[]; mcpServers: acp.McpServer[]; modelId?: string; + runtimeStateCodec: RuntimeStateCodec; requestApproval?: AcpInteractiveClientOptions["requestApproval"]; createClient?: (options: AcpInteractiveClientOptions) => AcpOneShotClient; } @@ -48,7 +51,18 @@ export class AcpWorkflowOneShotExecutor implements AgentExecutor { this.client = client; this.detachPromise = undefined; try { - await client.attach(); + const sessionId = await client.attach(); + this.context.emit({ + type: "runtime_conversation", + runtimeConversation: this.options.runtimeStateCodec.encodeConversation({ + native: { sessionId }, + appContext: { + cwd: this.context.workDir, + ...(this.options.modelId ? { modelId: this.options.modelId } : {}), + transport: "acp", + }, + }), + }); await client.prompt(promptWithDeveloperInstructions(this.context.prompt, this.context.developerInstructions)); this.context.onExit(0); } catch (error) { diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/claude/claude-test.test.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/claude/claude-test.test.ts new file mode 100644 index 000000000..dc003cce8 --- /dev/null +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/claude/claude-test.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ClaudeAgentSdkAdapter } from "../../../../agents/claude/claude-agent-sdk"; +import { runClaudeChannelTest } from "./claude-test"; + +describe("runClaudeChannelTest", () => { + it("reports the native Session before returning the test result", async () => { + const reportExecutionReference = vi.fn(); + const emit = vi.fn(); + const adapter = { + runOneShot: vi.fn(async (input: Parameters[0]) => { + input.onEvent({ + type: "runtime_conversation", + runtimeConversation: { + runtimeId: "claude", + codecVersion: "v1", + payload: { native: { sessionId: "session-claude-test" } }, + }, + }); + input.onEvent({ type: "completed", content: "OK" }); + }), + }; + + await expect(runClaudeChannelTest({ + runtime: { id: "claude", label: "Claude", command: "claude", version: "test", available: true }, + channelId: "claude-default", + modelId: "default", + workDir: "/workspace", + reportExecutionReference, + emit, + }, { + executables: {} as never, + channelById: () => ({ + id: "claude-default", + label: "Claude", + agentId: "claude", + models: [], + }), + }, adapter)).resolves.toBe("OK"); + + expect(reportExecutionReference).toHaveBeenCalledWith({ sessionId: "session-claude-test" }); + }); +}); diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/claude/claude-test.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/claude/claude-test.ts index bf663f3c3..a9d27cd24 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/claude/claude-test.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/claude/claude-test.ts @@ -2,6 +2,7 @@ import { claudeCliModelForChannel } from "../../../../agents/claude/claude-env"; import type { ClaudeAgentSdkAdapter } from "../../../../agents/claude/claude-agent-sdk"; import type { RuntimeChannelTestContext } from "../../../../agents/runtime/runtime-driver"; import type { RuntimeAgentExecutorFactoryOptions } from "../agent-executor-types"; +import { claudeSessionIdFromConversation } from "../agent-executor-conversation"; import { RUNTIME_CHANNEL_TEST_PROMPT } from "../runtime-test-constants"; export async function runClaudeChannelTest( @@ -25,6 +26,11 @@ export async function runClaudeChannelTest( cwd: input.workDir, ...(sdkModel ? { modelId: sdkModel } : {}), onEvent: (event) => { + if (event.type === "runtime_conversation") { + const sessionId = claudeSessionIdFromConversation(event.runtimeConversation); + if (sessionId) input.reportExecutionReference?.({ sessionId }); + return; + } if (event.type === "delta") { output += event.content; input.emit({ type: "assistant_delta", content: event.content }); diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/dsh/dsh-workflow.test.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/dsh/dsh-workflow.test.ts index d7f159b55..f62d44703 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/dsh/dsh-workflow.test.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/dsh/dsh-workflow.test.ts @@ -104,14 +104,30 @@ describe("DSH workflow execution", () => { test("runs with developer instructions, channel environment, and completed output", async () => { runnerMock.start.mockImplementation(async (options: Record) => { + options.onEvent({ + type: "runtime_conversation", + runtimeConversation: { + runtimeId: "dsh", + codecVersion: "v1", + payload: { native: { sessionId: "session-dsh-workflow" } }, + }, + }); options.onEvent({ type: "completed", content: " concise answer " }); options.onExit(0); }); - const { input, events } = workflowInput(); + const reportExecutionReference = vi.fn(); + const { input, events } = workflowInput({ reportExecutionReference }); await expect(runDshWorkflow(input, workflowOptions())).resolves.toEqual({ content: "concise answer", + runtimeConversation: { + runtimeId: "dsh", + codecVersion: "v1", + payload: { native: { sessionId: "session-dsh-workflow" } }, + }, + executionReference: { sessionId: "session-dsh-workflow" }, }); + expect(reportExecutionReference).toHaveBeenCalledWith({ sessionId: "session-dsh-workflow" }); expect(runnerMock.options).toHaveLength(1); expect(runnerMock.options[0]).toMatchObject({ @@ -212,21 +228,32 @@ describe("DSH workflow execution", () => { test("uses the standard channel-test prompt and returns the assistant response", async () => { runnerMock.start.mockImplementation(async (options: Record) => { + options.onEvent({ + type: "runtime_conversation", + runtimeConversation: { + runtimeId: "dsh", + codecVersion: "v1", + payload: { native: { sessionId: "session-dsh-test" } }, + }, + }); options.onEvent({ type: "completed", content: "OK" }); options.onExit(0); }); const emitted: Array> = []; + const reportExecutionReference = vi.fn(); const input: RuntimeChannelTestContext = { runtime, channelId: "dsh-default", modelId: "default", workDir: "/work/repository", + reportExecutionReference, emit: (event) => emitted.push(event), }; await expect(runDshChannelTest(input, workflowOptions())).resolves.toBe("OK"); expect(runnerMock.options[0]?.prompt).toBe(RUNTIME_CHANNEL_TEST_PROMPT); + expect(reportExecutionReference).toHaveBeenCalledWith({ sessionId: "session-dsh-test" }); expect(emitted).toEqual([ { type: "phase", diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/dsh/dsh-workflow.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/dsh/dsh-workflow.ts index d9559cd59..4e293a693 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/dsh/dsh-workflow.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/dsh/dsh-workflow.ts @@ -1,4 +1,4 @@ -import type { AgentRuntime, WorkflowAgentResponse } from "../../../../../shared/types"; +import type { AgentRuntime, RuntimeConversation, WorkflowAgentResponse } from "../../../../../shared/types"; import { DshRunner } from "../../../../agents/dsh/dsh-runner"; import type { RuntimeChannelTestContext, @@ -42,6 +42,8 @@ export async function runDshWorkflow( if (input.signal?.aborted) throw workflowAbortError(input.signal); let content = ""; + let runtimeConversation: RuntimeConversation | undefined; + let sessionId: string | undefined; let runnerError: string | undefined; const runner = createRunner({ executable: input.runtime.command || options.executables.dsh, @@ -52,6 +54,21 @@ export async function runDshWorkflow( developerInstructionsForWorkflowRequest(input), ), onEvent: (event) => { + if (event.type === "runtime_conversation") { + runtimeConversation = structuredClone(event.runtimeConversation); + const payload = runtimeConversation.payload; + const native = payload && typeof payload === "object" + ? (payload as Record).native + : undefined; + const nativeRecord = native && typeof native === "object" + ? native as Record + : undefined; + sessionId = typeof nativeRecord?.sessionId === "string" + ? nativeRecord.sessionId + : undefined; + if (sessionId) input.reportExecutionReference?.({ sessionId }); + return; + } if (event.type === "completed") { content = event.content?.trim() ?? ""; input.onEvent?.({ @@ -102,7 +119,11 @@ export async function runDshWorkflow( if (input.signal?.aborted) throw workflowAbortError(input.signal); if (runnerError) throw new Error(runnerError); if (!content) throw new Error("DSH completed without assistant text."); - return { content }; + return { + content, + ...(runtimeConversation ? { runtimeConversation } : {}), + ...(sessionId ? { executionReference: { sessionId } } : {}), + }; } export async function runDshChannelTest( @@ -130,6 +151,7 @@ export async function runDshChannelTest( workDir: input.workDir, instructionScope: "agent", signal: AbortSignal.timeout(RUNTIME_CHANNEL_TEST_TIMEOUT_MS), + reportExecutionReference: input.reportExecutionReference, onEvent: (event) => { if (event.type === "error") { input.emit({ type: "error", content: event.error }); diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/hermes/create-hermes-driver.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/hermes/create-hermes-driver.ts index d29513f90..552bfc5a8 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/hermes/create-hermes-driver.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/hermes/create-hermes-driver.ts @@ -27,6 +27,7 @@ export function createHermesDriver(options: RuntimeAgentExecutorFactoryOptions): ? new AcpWorkflowOneShotExecutor(context, { executable: context.runtime.command || options.executables.hermes, args: ["acp"], + runtimeStateCodec: hermesRuntimeStateCodec, modelId: context.runtimeConfig.model, mcpServers: [ ...acpMcpServers(context.configuredAgentId ? options.mcpServersForAgent?.(context.configuredAgentId, context.allowedMcpTools) ?? [] : []), diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/hermes/hermes-workflow.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/hermes/hermes-workflow.ts index d454a3a29..d6cb1f533 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/hermes/hermes-workflow.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/hermes/hermes-workflow.ts @@ -1,9 +1,11 @@ import type { AgentRuntime, + RuntimeConversation, WorkflowAgentResponse, } from "../../../../../shared/types"; import { runtimeModelId } from "../../../../../shared/models"; import { HermesRunner } from "../../../../agents/hermes/hermes-runner"; +import { hermesRuntimeStateCodec } from "../../../../agents/hermes/hermes-runtime-state-codec"; import type { RuntimeChannelTestContext, RuntimeWorkflowRequestContext, @@ -25,6 +27,7 @@ export async function runHermesWorkflow( let exitCode: number | null = 0; let stderr = ""; let runnerError: string | undefined; + let runtimeConversation: RuntimeConversation | undefined; const runner = new HermesRunner({ executable: input.runtime.command || options.executables.hermes, @@ -35,9 +38,20 @@ export async function runHermesWorkflow( ), modelId: modelFromRuntimeConfig(input.runtimeConfig), onEvent: (event) => { + if (event.type === "runtime_conversation") { + runtimeConversation = event.runtimeConversation; + const sessionId = hermesRuntimeStateCodec.decodeConversation(runtimeConversation)?.native.sessionId; + if (sessionId) input.reportExecutionReference?.({ sessionId }); + return; + } if (event.type === "completed") { content = typeof event.content === "string" ? event.content : content; - input.onEvent?.({ requestId: input.requestId, type: "completed", content: content.trim() }); + input.onEvent?.({ + requestId: input.requestId, + type: "completed", + content: content.trim(), + ...(runtimeConversation ? { runtimeConversation } : {}), + }); return; } if (event.type === "error") { @@ -65,7 +79,12 @@ export async function runHermesWorkflow( throw new Error(`Hermes exited with ${exitCode ?? "unknown"}: ${(stderr.trim() || output || "no output").slice(0, 800)}`); } if (!output) throw new Error("Hermes completed without assistant text."); - return { content: output }; + const sessionId = hermesRuntimeStateCodec.decodeConversation(runtimeConversation)?.native.sessionId; + return { + content: output, + ...(runtimeConversation ? { runtimeConversation } : {}), + ...(sessionId ? { executionReference: { sessionId } } : {}), + }; } export async function runHermesChannelTest( @@ -83,10 +102,12 @@ export async function runHermesChannelTest( executionMode: "oneshot", continuationPolicy: "fresh", runtimeConfig: { model: input.modelId }, + invocationId: input.invocationId, invocation: { surface: "system", role: "channel_test", ownerReference: { channelId: input.channelId } }, runtime: input.runtime as AgentRuntime, channelId: input.channelId, workDir: input.workDir, + reportExecutionReference: input.reportExecutionReference, onEvent: (event) => { if (event.type === "error") input.emit({ type: "error", content: event.error }); }, diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/openclaw/create-openclaw-driver.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/openclaw/create-openclaw-driver.ts index 5cb6ae092..72fcd26a5 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/openclaw/create-openclaw-driver.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/openclaw/create-openclaw-driver.ts @@ -25,6 +25,7 @@ export function createOpenClawDriver(options: RuntimeAgentExecutorFactoryOptions ? new AcpWorkflowOneShotExecutor(context, { executable: context.runtime.command || options.executables.openclaw, args: ["acp"], + runtimeStateCodec: openClawRuntimeStateCodec, modelId: context.runtimeConfig.model, mcpServers: [ ...acpMcpServers(context.configuredAgentId ? options.mcpServersForAgent?.(context.configuredAgentId, context.allowedMcpTools) ?? [] : []), diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/openclaw/openclaw-executor.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/openclaw/openclaw-executor.ts index 206192896..a57600e54 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/openclaw/openclaw-executor.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/openclaw/openclaw-executor.ts @@ -18,7 +18,7 @@ export class OpenClawAgentExecutor implements AgentExecutor { this.context.prompt, this.context.developerInstructions, ), - sessionKey: `agent-recall-${this.context.runId}`, + sessionId: this.context.invocationId ?? this.context.runId, modelId: modelFromRuntimeConfig(this.context.runtimeConfig), onEvent: this.context.emit, onExit: this.context.onExit, diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/openclaw/openclaw-workflow.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/openclaw/openclaw-workflow.ts index daabc4b3b..c276e6bd1 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/openclaw/openclaw-workflow.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/openclaw/openclaw-workflow.ts @@ -1,4 +1,4 @@ -import type { AgentRuntime, WorkflowAgentResponse } from "../../../../../shared/types"; +import type { AgentRuntime, RuntimeConversation, WorkflowAgentResponse } from "../../../../../shared/types"; import { runtimeModelId } from "../../../../../shared/models"; import { OpenClawRunner } from "../../../../agents/openclaw/openclaw-runner"; import type { RuntimeChannelTestContext, RuntimeWorkflowRequestContext } from "../../../../agents/runtime/runtime-driver"; @@ -15,6 +15,8 @@ export async function runOpenClawWorkflow( let exitCode: number | null = 0; let stderr = ""; let runnerError: string | undefined; + let runtimeConversation: RuntimeConversation | undefined; + const sessionId = input.invocationId ?? input.requestId; const runner = new OpenClawRunner({ executable: input.runtime.command || options.executables.openclaw, cwd: input.workDir, @@ -22,12 +24,20 @@ export async function runOpenClawWorkflow( input.prompt, developerInstructionsForWorkflowRequest(input), ), - sessionKey: `agent-recall-${input.requestId}`, + sessionId, modelId: modelFromRuntimeConfig(input.runtimeConfig), onEvent: (event) => { - if (event.type === "completed") { + if (event.type === "runtime_conversation") { + runtimeConversation = event.runtimeConversation; + input.reportExecutionReference?.({ sessionId }); + } else if (event.type === "completed") { content = event.content ?? content; - input.onEvent?.({ requestId: input.requestId, type: "completed", content: content.trim() }); + input.onEvent?.({ + requestId: input.requestId, + type: "completed", + content: content.trim(), + ...(runtimeConversation ? { runtimeConversation } : {}), + }); } else if (event.type === "error") { runnerError = event.error; input.onEvent?.({ requestId: input.requestId, type: "error", error: event.error }); @@ -49,7 +59,11 @@ export async function runOpenClawWorkflow( if (runnerError) throw new Error(runnerError); if (exitCode !== 0) throw new Error(`OpenClaw exited with ${exitCode ?? "unknown"}: ${(stderr.trim() || output || "no output").slice(0, 800)}`); if (!output) throw new Error("OpenClaw completed without assistant text."); - return { content: output }; + return { + content: output, + ...(runtimeConversation ? { runtimeConversation } : {}), + executionReference: { sessionId }, + }; } export async function runOpenClawChannelTest( @@ -66,10 +80,12 @@ export async function runOpenClawChannelTest( executionMode: "oneshot", continuationPolicy: "fresh", runtimeConfig: { model: input.modelId }, + invocationId: input.invocationId, invocation: { surface: "system", role: "channel_test", ownerReference: { channelId: input.channelId } }, runtime: input.runtime as AgentRuntime, channelId: input.channelId, workDir: input.workDir, + reportExecutionReference: input.reportExecutionReference, onEvent: (event) => { if (event.type === "error") input.emit({ type: "error", content: event.error }); }, diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/opencode/create-opencode-driver.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/opencode/create-opencode-driver.ts index 2143fdddc..072b6e6b5 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/opencode/create-opencode-driver.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/opencode/create-opencode-driver.ts @@ -27,6 +27,7 @@ export function createOpenCodeDriver(options: RuntimeAgentExecutorFactoryOptions ? new AcpWorkflowOneShotExecutor(context, { executable: context.runtime.command || options.executables.opencode, args: ["acp", "--cwd", context.workDir], + runtimeStateCodec: openCodeRuntimeStateCodec, modelId: context.runtimeConfig.model, mcpServers: [ ...acpMcpServers(context.configuredAgentId ? options.mcpServersForAgent?.(context.configuredAgentId, context.allowedMcpTools) ?? [] : []), diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/opencode/opencode-workflow.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/opencode/opencode-workflow.ts index 357b73004..1c1ce4bcb 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/opencode/opencode-workflow.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/opencode/opencode-workflow.ts @@ -1,6 +1,7 @@ -import type { AgentRuntime, WorkflowAgentResponse } from "../../../../../shared/types"; +import type { AgentRuntime, RuntimeConversation, WorkflowAgentResponse } from "../../../../../shared/types"; import { runtimeModelId } from "../../../../../shared/models"; import { OpenCodeRunner } from "../../../../agents/opencode/opencode-runner"; +import { openCodeRuntimeStateCodec } from "../../../../agents/opencode/opencode-runtime-state-codec"; import type { RuntimeChannelTestContext, RuntimeWorkflowRequestContext } from "../../../../agents/runtime/runtime-driver"; import { developerInstructionsForWorkflowRequest, modelFromRuntimeConfig, type RuntimeWorkflowExecutionOptions } from "../workflow/agent-executor-workflow-shared"; import { promptWithDeveloperInstructions } from "../runtime-instructions"; @@ -15,6 +16,7 @@ export async function runOpenCodeWorkflow( let exitCode: number | null = 0; let stderr = ""; let runnerError: string | undefined; + let runtimeConversation: RuntimeConversation | undefined; const runner = new OpenCodeRunner({ executable: input.runtime.command || options.executables.opencode, @@ -25,6 +27,12 @@ export async function runOpenCodeWorkflow( ), modelId: modelFromRuntimeConfig(input.runtimeConfig), onEvent: (event) => { + if (event.type === "runtime_conversation") { + runtimeConversation = event.runtimeConversation; + const sessionId = openCodeRuntimeStateCodec.decodeConversation(runtimeConversation)?.native.sessionId; + if (sessionId) input.reportExecutionReference?.({ sessionId }); + return; + } if (event.type === "delta") { content += event.content; input.onEvent?.({ requestId: input.requestId, type: "delta", content: event.content }); @@ -32,7 +40,12 @@ export async function runOpenCodeWorkflow( } if (event.type === "completed") { if (!content && event.content) content = event.content; - input.onEvent?.({ requestId: input.requestId, type: "completed", content: content.trim() }); + input.onEvent?.({ + requestId: input.requestId, + type: "completed", + content: content.trim(), + ...(runtimeConversation ? { runtimeConversation } : {}), + }); return; } if (event.type === "error") { @@ -59,7 +72,12 @@ export async function runOpenCodeWorkflow( throw new Error(`OpenCode exited with ${exitCode ?? "unknown"}: ${(stderr.trim() || output || "no output").slice(0, 800)}`); } if (!output) throw new Error("OpenCode completed without assistant text."); - return { content: output }; + const sessionId = openCodeRuntimeStateCodec.decodeConversation(runtimeConversation)?.native.sessionId; + return { + content: output, + ...(runtimeConversation ? { runtimeConversation } : {}), + ...(sessionId ? { executionReference: { sessionId } } : {}), + }; } export async function runOpenCodeChannelTest( @@ -76,10 +94,12 @@ export async function runOpenCodeChannelTest( executionMode: "oneshot", continuationPolicy: "fresh", runtimeConfig: { model: input.modelId }, + invocationId: input.invocationId, invocation: { surface: "system", role: "channel_test", ownerReference: { channelId: input.channelId } }, runtime: input.runtime as AgentRuntime, channelId: input.channelId, workDir: input.workDir, + reportExecutionReference: input.reportExecutionReference, onEvent: (event) => { if (event.type === "error") input.emit({ type: "error", content: event.error }); }, diff --git a/apps/main-2.0/src/automation/engine/shared/types.ts b/apps/main-2.0/src/automation/engine/shared/types.ts index 84fc399fa..81b7dcd23 100644 --- a/apps/main-2.0/src/automation/engine/shared/types.ts +++ b/apps/main-2.0/src/automation/engine/shared/types.ts @@ -5,6 +5,7 @@ import type { RuntimeUsage } from "../../../shared/runtime/usage"; import type { WorkflowNodeConversation } from "./workflow-v2/conversation"; import type { ConfiguredAgent } from "./agent/types"; import type { WorkflowDraftState, WorkflowStoreState } from "./workflow/draft"; +import type { AgentRecallInvocationSurface } from "../../../shared/runtime-invocation"; export { isWorkflowRunTerminalStatus, type WorkflowArtifactReference, @@ -18,6 +19,7 @@ export { export type { ResourceSourceType } from "./resource"; export type { RuntimeConversation } from "./runtime/conversation"; export type { RuntimeUsage } from "../../../shared/runtime/usage"; +export type { AgentRecallInvocationSurface } from "../../../shared/runtime-invocation"; export type { AgentRevision, AgentType, ConfiguredAgent } from "./agent/types"; export type { AgentMcpBinding, @@ -301,7 +303,7 @@ export interface ProviderBalanceResult { queriedAt: number; } -export type AgentTestEvent = +export type AgentTestEvent = ( | { agentId: string; type: "phase"; content: string; timestamp: number } | { agentId: string; type: "user"; content: string; timestamp: number } | { agentId: string; type: "assistant_delta"; content: string; timestamp: number } @@ -309,7 +311,11 @@ export type AgentTestEvent = | { agentId: string; type: "tool"; content: string; timestamp: number } | { agentId: string; type: "warning"; content: string; timestamp: number } | { agentId: string; type: "stderr"; content: string; timestamp: number } - | { agentId: string; type: "error"; content: string; timestamp: number }; + | { agentId: string; type: "error"; content: string; timestamp: number } +) & { + /** Stable Runtime invocation shared by channel-test status and logs. */ + invocationId?: string; +}; export interface GeneratedConfigFile { channelId: string; @@ -346,15 +352,6 @@ export type ExecutionStyle = "oneshot" | "interactive"; export type RuntimeExecutionMode = ExecutionStyle; export type RuntimeContinuationPolicy = "fresh" | "resume-preferred" | "resume-required"; -/** Product surface that initiated a persisted AgentRecall Runtime invocation. */ -export type AgentRecallInvocationSurface = - | "workflow" - | "evaluation" - | "team_chat" - | "agent" - | "skill" - | "system"; - /** Stable business metadata attached to every Runtime dispatch. */ export interface RuntimeInvocationRequest { /** Runtime caller category used for Session grouping and history labels. */ diff --git a/apps/main-2.0/src/core/postgres/runtime-invocation-repository.ts b/apps/main-2.0/src/core/postgres/runtime-invocation-repository.ts index 59d81e886..8cf0bee02 100644 --- a/apps/main-2.0/src/core/postgres/runtime-invocation-repository.ts +++ b/apps/main-2.0/src/core/postgres/runtime-invocation-repository.ts @@ -79,4 +79,19 @@ export class PostgresRuntimeInvocationRepository implements RuntimeInvocationRec ], ); } + + /** Marks invocations left pending by a previous application process as failed. */ + async recoverPending(finishedAt: number): Promise { + const result = await this.database.query( + ` + update agent_recall.runtime_invocations + set status = 'failed', + finished_at = $1, + error = 'AgentRecall stopped before this Runtime invocation finished.' + where status = 'pending' + `, + [new Date(finishedAt).toISOString()], + ); + return result.rowCount; + } } diff --git a/apps/main-2.0/src/core/postgres/schema.ts b/apps/main-2.0/src/core/postgres/schema.ts index 39e8c29a1..3b5f507d5 100644 --- a/apps/main-2.0/src/core/postgres/schema.ts +++ b/apps/main-2.0/src/core/postgres/schema.ts @@ -1939,6 +1939,7 @@ export const POSTGRES_MIGRATIONS: readonly PostgresMigration[] = [{ execution_attempts.*, dispatches.room_id, dispatches.source_message_id, + dispatches.target_agent_id, dispatches.task_id, room_agents.channel_id, row_number() OVER ( @@ -1964,6 +1965,7 @@ export const POSTGRES_MIGRATIONS: readonly PostgresMigration[] = [{ jsonb_strip_nulls(jsonb_build_object( 'roomId', room_id, 'messageId', source_message_id, + 'agentId', target_agent_id, 'dispatchId', dispatch_id, 'taskId', task_id, 'attemptId', id diff --git a/apps/main-2.0/src/core/postgres/session-repository.ts b/apps/main-2.0/src/core/postgres/session-repository.ts index efcf4707c..377921585 100644 --- a/apps/main-2.0/src/core/postgres/session-repository.ts +++ b/apps/main-2.0/src/core/postgres/session-repository.ts @@ -5,6 +5,8 @@ import type { ProjectQueryOptions, ProjectSummary, ProjectTagEntry, + RuntimeInvocationSessionResolution, + RuntimeInvocationSummary, SessionMessage, SessionMessageEvent, SessionSearchResult, @@ -1912,11 +1914,26 @@ export class PostgresSessionRepository { return result.rows[0] ? hydrateSession(result.rows[0]) : null; } - /** Finds the indexed Session linked to an exact persisted invocation owner reference. */ - async findByRuntimeInvocationOwner( + /** Resolves an exact invocation owner without conflating missing bindings with indexing delay. */ + async resolveRuntimeInvocationSession( ownerReference: Record, - ): Promise { - if (Object.keys(ownerReference).length === 0) return null; + ): Promise { + if (Object.keys(ownerReference).length === 0) return { status: "not_recorded" }; + const invocation = (await this.database.query<{ + id: string; + status: RuntimeInvocationSummary["status"]; + }>( + ` + select id, status + from agent_recall.runtime_invocations + where initiator = 'agentrecall' + and owner_reference @> $1::jsonb + order by started_at desc, id desc + limit 1 + `, + [postgresJsonValue(ownerReference)], + )).rows[0]; + if (!invocation) return { status: "not_recorded" }; const result = await this.database.query( ` select ${SESSION_SELECT_SQL} @@ -1929,14 +1946,32 @@ export class PostgresSessionRepository { on invocations.id = bindings.invocation_id where ${RUNTIME_SESSION_BINDING_MATCH_SQL} and invocations.initiator = 'agentrecall' - and invocations.owner_reference @> $1::jsonb + and invocations.id = $1 ) order by ${SESSION_ACTIVITY_SQL} desc, sessions.session_key limit 1 `, - [postgresJsonValue(ownerReference)], + [invocation.id], ); - return result.rows[0] ? hydrateSession(result.rows[0]) : null; + if (result.rows[0]) { + return { status: "found", session: hydrateSession(result.rows[0]) }; + } + const bindingExists = Boolean((await this.database.query<{ found: boolean }>( + ` + select exists ( + select 1 + from agent_recall.runtime_session_bindings + where invocation_id = $1 + ) as found + `, + [invocation.id], + )).rows[0]?.found); + if (bindingExists) return { status: "not_indexed", invocationId: invocation.id }; + return { + status: "no_session_reference", + invocationId: invocation.id, + invocationStatus: invocation.status, + }; } async setAiSummary(sessionKey: string, summary: string, model: string): Promise { diff --git a/apps/main-2.0/src/core/postgres/session-search-repository.ts b/apps/main-2.0/src/core/postgres/session-search-repository.ts index 0ddaa8216..b71234ce8 100644 --- a/apps/main-2.0/src/core/postgres/session-search-repository.ts +++ b/apps/main-2.0/src/core/postgres/session-search-repository.ts @@ -5,12 +5,14 @@ import type { SessionSearchPage, SessionSearchResult, } from "../types"; +import { AGENT_RECALL_INVOCATION_SURFACES } from "../../shared/runtime-invocation"; import { LIVE_SESSION_INACTIVITY_TIMEOUT_MS } from "../refresh-policy"; import type { PostgresDatabase } from "./database"; import { SESSION_ACTIVITY_SQL, SESSION_SELECT_SQL, AGENTRECALL_CREATED_SESSION_SQL, + RUNTIME_SESSION_BINDING_MATCH_SQL, escapeLike, hydrateSession, isoValue, @@ -36,6 +38,21 @@ const LIVE_SESSION_KEY_SQL = ` end `; +function agentRecallCreatedSurfaceSql(surface: string): string { + return ` + exists ( + select 1 + from agent_recall.runtime_session_bindings bindings + join agent_recall.runtime_invocations invocations + on invocations.id = bindings.invocation_id + where ${RUNTIME_SESSION_BINDING_MATCH_SQL} + and bindings.relation = 'created' + and invocations.initiator = 'agentrecall' + and invocations.surface = '${surface}' + ) + `; +} + export class PostgresSessionSearchRepository { constructor(private readonly database: PostgresDatabase) {} @@ -142,8 +159,16 @@ export class PostgresSessionSearchRepository { filters.push(`(best_turn.id is not null or (${metadataPredicates.join(" and ")}))`); } + const invocationSurfaceCountFilters = [...filters]; + const invocationSurfaceCountValues = [...values]; const originCountFilters = [...filters]; const originCountValues = [...values]; + if (options.invocationSurface && options.invocationSurface !== "all") { + if (!AGENT_RECALL_INVOCATION_SURFACES.includes(options.invocationSurface)) { + throw new Error(`Unsupported Runtime invocation surface: ${options.invocationSurface}`); + } + filters.push(agentRecallCreatedSurfaceSql(options.invocationSurface)); + } if (options.origin === "ordinary") filters.push(`not (${AGENTRECALL_CREATED_SESSION_SQL})`); else if (options.origin === "agentrecall") filters.push(AGENTRECALL_CREATED_SESSION_SQL); const countValues = [...values]; @@ -293,6 +318,7 @@ export class PostgresSessionSearchRepository { `; const filteredSessionsSql = buildFilteredSessionsSql(filters); const originFilteredSessionsSql = buildFilteredSessionsSql(originCountFilters); + const invocationSurfaceFilteredSessionsSql = buildFilteredSessionsSql(invocationSurfaceCountFilters); const result = await this.database.query( ` select @@ -329,6 +355,16 @@ export class PostgresSessionSearchRepository { `, originCountValues, )).rows[0]; + const invocationSurfaceCountRow = (await this.database.query>( + ` + select + ${AGENT_RECALL_INVOCATION_SURFACES.map((surface) => + `count(*) filter (where ${agentRecallCreatedSurfaceSql(surface)}) as ${surface}_count`).join(",\n ")}, + count(*) filter (where ${AGENTRECALL_CREATED_SESSION_SQL}) as all_count + ${invocationSurfaceFilteredSessionsSql} + `, + invocationSurfaceCountValues, + )).rows[0]; return { sessions, totalCount, @@ -338,6 +374,15 @@ export class PostgresSessionSearchRepository { agentRecall: numberValue(originCountRow?.agentrecall_count), all: numberValue(originCountRow?.all_count), }, + invocationSurfaceCounts: { + workflow: numberValue(invocationSurfaceCountRow?.workflow_count), + evaluation: numberValue(invocationSurfaceCountRow?.evaluation_count), + team_chat: numberValue(invocationSurfaceCountRow?.team_chat_count), + agent: numberValue(invocationSurfaceCountRow?.agent_count), + skill: numberValue(invocationSurfaceCountRow?.skill_count), + system: numberValue(invocationSurfaceCountRow?.system_count), + all: numberValue(invocationSurfaceCountRow?.all_count), + }, }; } diff --git a/apps/main-2.0/src/core/postgres/session-search.test.ts b/apps/main-2.0/src/core/postgres/session-search.test.ts index 9313677d0..665af0929 100644 --- a/apps/main-2.0/src/core/postgres/session-search.test.ts +++ b/apps/main-2.0/src/core/postgres/session-search.test.ts @@ -289,6 +289,15 @@ describe("PostgreSQL Turn search", () => { const ordinary = await searchRepository.searchSessionPage({ origin: "ordinary", excludeSubagents: true }); expect(ordinary.sessions.map((item) => item.sessionKey).sort()).toEqual(["codex:roles", "codex:two"]); expect(ordinary.originCounts).toEqual({ ordinary: 2, agentRecall: 1, all: 3 }); + expect(ordinary.invocationSurfaceCounts).toEqual({ + workflow: 0, + evaluation: 1, + team_chat: 0, + agent: 0, + skill: 0, + system: 0, + all: 1, + }); expect(ordinary.sessions.find((item) => item.sessionKey === "codex:two")).toMatchObject({ createdByAgentRecall: false, runtimeInvocations: [expect.objectContaining({ @@ -311,10 +320,61 @@ describe("PostgreSQL Turn search", () => { ownerReference: { runId: "run-1", caseResultId: "case-1" }, })], }); - await expect(repository.findByRuntimeInvocationOwner({ runId: "run-1" })) - .resolves.toMatchObject({ sessionKey: "codex:one" }); - await expect(repository.findByRuntimeInvocationOwner({ runId: "missing" })) - .resolves.toBeNull(); + const evaluationSessions = await searchRepository.searchSessionPage({ + origin: "agentrecall", + invocationSurface: "evaluation", + excludeSubagents: true, + }); + expect(evaluationSessions.sessions.map((item) => item.sessionKey)).toEqual(["codex:one"]); + expect(evaluationSessions.originCounts).toEqual({ ordinary: 2, agentRecall: 1, all: 3 }); + const workflowSessions = await searchRepository.searchSessionPage({ + origin: "agentrecall", + invocationSurface: "workflow", + excludeSubagents: true, + }); + expect(workflowSessions.sessions).toEqual([]); + await expect(repository.resolveRuntimeInvocationSession({ runId: "run-1" })) + .resolves.toMatchObject({ status: "found", session: { sessionKey: "codex:one" } }); + await expect(repository.resolveRuntimeInvocationSession({ runId: "missing" })) + .resolves.toEqual({ status: "not_recorded" }); + + await invocations.begin({ + id: "inv-no-reference", + initiator: "agentrecall", + invocation: { surface: "workflow", ownerReference: { runId: "run-no-reference" } }, + runtimeId: "dsh", + environmentId: "local", + startedAt: Date.parse("2026-07-22T08:00:00.000Z"), + }); + await invocations.finish( + "inv-no-reference", + "failed", + Date.parse("2026-07-22T08:00:01.000Z"), + ); + await expect(repository.resolveRuntimeInvocationSession({ runId: "run-no-reference" })) + .resolves.toEqual({ + status: "no_session_reference", + invocationId: "inv-no-reference", + invocationStatus: "failed", + }); + + await invocations.begin({ + id: "inv-awaiting-index", + initiator: "agentrecall", + invocation: { surface: "workflow", ownerReference: { runId: "run-awaiting-index" } }, + runtimeId: "codex", + environmentId: "local", + startedAt: Date.parse("2026-07-23T08:00:00.000Z"), + }); + await invocations.bind("inv-awaiting-index", { + runtimeId: "codex", + environmentId: "local", + sessionId: "not-indexed-yet", + relation: "created", + boundAt: Date.parse("2026-07-23T08:00:01.000Z"), + }); + await expect(repository.resolveRuntimeInvocationSession({ runId: "run-awaiting-index" })) + .resolves.toEqual({ status: "not_indexed", invocationId: "inv-awaiting-index" }); }); it("filters both Claude and Codex StepCode variants as one source", async () => { diff --git a/apps/main-2.0/src/core/postgres/support-repositories.test.ts b/apps/main-2.0/src/core/postgres/support-repositories.test.ts index 9f73a9cc8..aff22c988 100644 --- a/apps/main-2.0/src/core/postgres/support-repositories.test.ts +++ b/apps/main-2.0/src/core/postgres/support-repositories.test.ts @@ -4,6 +4,7 @@ import type { IndexedSession } from "../types"; import { PostgresDatabase } from "./database"; import { PostgresEnvironmentRepository } from "./environment-repository"; import { PostgresMetadataRepository } from "./metadata-repository"; +import { PostgresRuntimeInvocationRepository } from "./runtime-invocation-repository"; import { POSTGRES_MIGRATIONS } from "./schema"; import { PostgresSessionRepository } from "./session-repository"; import { PostgresSkillRepository } from "./skill-repository"; @@ -41,6 +42,45 @@ describe("PostgreSQL support repositories", () => { await database.close(); }); + it("fails Runtime invocations left pending by a previous process", async () => { + const repository = new PostgresRuntimeInvocationRepository(database); + await repository.begin({ + id: "stale-invocation", + initiator: "agentrecall", + invocation: { surface: "agent", role: "task", ownerReference: { taskId: "task-1" } }, + runtimeId: "opencode", + environmentId: "local", + startedAt: 1_000, + }); + await repository.begin({ + id: "finished-invocation", + initiator: "agentrecall", + invocation: { surface: "agent", role: "task", ownerReference: { taskId: "task-2" } }, + runtimeId: "opencode", + environmentId: "local", + startedAt: 2_000, + }); + await repository.finish("finished-invocation", "completed", 3_000); + + await expect(repository.recoverPending(4_000)).resolves.toBe(1); + const result = await database.query<{ + id: string; + status: string; + finished_at: Date | string | null; + error: string | null; + }>( + "select id, status, finished_at, error from agent_recall.runtime_invocations order by id", + ); + expect(result.rows).toEqual([ + expect.objectContaining({ id: "finished-invocation", status: "completed", error: null }), + expect.objectContaining({ + id: "stale-invocation", + status: "failed", + error: "AgentRecall stopped before this Runtime invocation finished.", + }), + ]); + }); + it("manages environments without allowing the local environment to be deleted", async () => { const repository = new PostgresEnvironmentRepository(database); const first = await repository.upsertEnvironment({ diff --git a/apps/main-2.0/src/core/session-store.ts b/apps/main-2.0/src/core/session-store.ts index e56e718cd..c2717c73e 100644 --- a/apps/main-2.0/src/core/session-store.ts +++ b/apps/main-2.0/src/core/session-store.ts @@ -59,6 +59,7 @@ import type { ProjectQueryOptions, ProjectSummary, ProjectTagEntry, + RuntimeInvocationSessionResolution, SearchOptions, SessionEnvironment, SessionMessage, @@ -608,12 +609,12 @@ export class SessionStore { return this.sessions.findByRawId(rawId); } - /** Resolves a Session through its exact Runtime invocation owner reference. */ - async findByRuntimeInvocationOwner( + /** Resolves a Runtime invocation owner and preserves binding diagnostics. */ + async resolveRuntimeInvocationSession( ownerReference: Record, - ): Promise { + ): Promise { await this.ready; - return this.sessions.findByRuntimeInvocationOwner(ownerReference); + return this.sessions.resolveRuntimeInvocationSession(ownerReference); } async setAiSummary(sessionKey: string, summary: string, model: string): Promise { diff --git a/apps/main-2.0/src/core/types.ts b/apps/main-2.0/src/core/types.ts index 6661fc44c..8635faf11 100644 --- a/apps/main-2.0/src/core/types.ts +++ b/apps/main-2.0/src/core/types.ts @@ -1,3 +1,7 @@ +import type { SessionInvocationSurfaceFilter } from "../shared/runtime-invocation"; + +export type { SessionInvocationSurfaceFilter } from "../shared/runtime-invocation"; + export type SessionSource = | "claude-cli" | "claude-app" @@ -367,7 +371,6 @@ export interface LoadedSession { export type SessionSourceFilter = SessionSource | "claude" | "codex" | "stepcode" | "all"; /** Selects ordinary Sessions, AgentRecall-created Sessions, or both. */ export type SessionOriginFilter = "ordinary" | "agentrecall" | "all"; - export interface SearchOptions { query?: string; tag?: string; @@ -385,6 +388,7 @@ export interface SearchOptions { excludeSubagents?: boolean; prioritizeFavorites?: boolean; origin?: SessionOriginFilter; + invocationSurface?: SessionInvocationSurfaceFilter; } export interface ProjectQueryOptions { @@ -479,6 +483,17 @@ export interface RuntimeInvocationSummary { runtimeTurnId: string | null; } +/** Outcome of resolving a business record to its Runtime Session. */ +export type RuntimeInvocationSessionResolution = + | { status: "found"; session: SessionSearchResult } + | { status: "not_indexed"; invocationId: string } + | { + status: "no_session_reference"; + invocationId: string; + invocationStatus: RuntimeInvocationSummary["status"]; + } + | { status: "not_recorded" }; + export interface SessionMatchHit { messageIndex: number; role: SessionMessage["role"]; @@ -508,6 +523,8 @@ export interface SessionSearchPage { agentRecall: number; all: number; }; + /** AgentRecall-created Session counts under active non-origin and non-surface filters. */ + invocationSurfaceCounts: Record; } export interface SessionStatsSummary extends TokenUsage { diff --git a/apps/main-2.0/src/main/ipc/session-catalog.test.ts b/apps/main-2.0/src/main/ipc/session-catalog.test.ts index 9ba7ebe0e..801f037d6 100644 --- a/apps/main-2.0/src/main/ipc/session-catalog.test.ts +++ b/apps/main-2.0/src/main/ipc/session-catalog.test.ts @@ -6,21 +6,24 @@ import { registerSessionCatalogIpc } from "./session-catalog"; describe("Session catalog IPC Runtime owner boundary", () => { test("accepts a bounded string map and rejects malformed owner references", async () => { const handlers = new Map unknown>(); - const findByRuntimeInvocationOwner = vi.fn(async () => ({ sessionKey: "codex:one" })); + const resolveRuntimeInvocationSession = vi.fn(async () => ({ + status: "found" as const, + session: { sessionKey: "codex:one" }, + })); registerSessionCatalogIpc({ handle: (channel, listener) => { handlers.set(channel, listener as (...args: unknown[]) => unknown); return undefined as never; }, }, { - findByRuntimeInvocationOwner, + resolveRuntimeInvocationSession, } as unknown as SessionCatalogService); - const handler = handlers.get("session:find-by-runtime-owner"); + const handler = handlers.get("session:resolve-runtime-owner"); expect(handler).toBeTypeOf("function"); await expect(handler?.({}, { workflowId: "workflow-1", runId: "run-1" })) - .resolves.toEqual({ sessionKey: "codex:one" }); - expect(findByRuntimeInvocationOwner).toHaveBeenCalledWith({ + .resolves.toEqual({ status: "found", session: { sessionKey: "codex:one" } }); + expect(resolveRuntimeInvocationSession).toHaveBeenCalledWith({ workflowId: "workflow-1", runId: "run-1", }); diff --git a/apps/main-2.0/src/main/ipc/session-catalog.ts b/apps/main-2.0/src/main/ipc/session-catalog.ts index 45a2cc02b..199adc5dc 100644 --- a/apps/main-2.0/src/main/ipc/session-catalog.ts +++ b/apps/main-2.0/src/main/ipc/session-catalog.ts @@ -25,8 +25,8 @@ export function registerSessionCatalogIpc( ipc.handle("search:session-page", (_event, options: SearchOptions) => service.searchPage(options)); ipc.handle("session:get", (_event, sessionKey: string) => service.get(sessionKey)); ipc.handle("session:find-by-raw-id", (_event, rawId: string) => service.findByRawId(rawId)); - ipc.handle("session:find-by-runtime-owner", (_event, ownerReference: unknown) => - service.findByRuntimeInvocationOwner(runtimeInvocationOwnerReference(ownerReference))); + ipc.handle("session:resolve-runtime-owner", (_event, ownerReference: unknown) => + service.resolveRuntimeInvocationSession(runtimeInvocationOwnerReference(ownerReference))); ipc.handle("session:turns", (_event, sessionKey: string) => service.listTurns(sessionKey)); ipc.handle("session:turn", (_event, sessionKey: string, turnId: string) => service.getTurn(sessionKey, turnId)); diff --git a/apps/main-2.0/src/main/services/automation-service.ts b/apps/main-2.0/src/main/services/automation-service.ts index 379513243..701d2feb3 100644 --- a/apps/main-2.0/src/main/services/automation-service.ts +++ b/apps/main-2.0/src/main/services/automation-service.ts @@ -275,6 +275,7 @@ export class NativeAutomationService { readonly teamChat: TeamChatService; readonly portableWorkflows: WorkflowPortableService; private readonly hubInstance: AgentHub; + private readonly runtimeInvocations: PostgresRuntimeInvocationRepository; private readonly appStore: PostgresAppStore; private readonly configuredAgentExecutor: ConfiguredAgentExecutionService; private readonly registryInstance: McpRegistryStore; @@ -308,13 +309,14 @@ export class NativeAutomationService { dependencies: AutomationServiceDependencies = {}, ) { this.paths = resolveAutomationPaths(options.userDataPath); + this.runtimeInvocations = new PostgresRuntimeInvocationRepository(options.database); this.hubInstance = dependencies.hub ?? new AgentHub( {}, undefined, undefined, undefined, undefined, - new PostgresRuntimeInvocationRepository(options.database), + this.runtimeInvocations, ); this.appStore = new PostgresAppStore(options.database, this.paths.fileStoragePath); this.registryInstance = dependencies.registry ?? new McpRegistryStore(options.database); @@ -609,6 +611,7 @@ export class NativeAutomationService { private async initializeInternal(): Promise { await this.prepare(); + await this.runtimeInvocations.recoverPending(Date.now()); this.router = await this.startRouterService({ channels: () => this.hubInstance.snapshot().channels }); this.setRouterBaseUrl(this.router.baseUrl); this.bridge = await this.startBridgeService(this.hubInstance, { diff --git a/apps/main-2.0/src/main/services/session-catalog-service.ts b/apps/main-2.0/src/main/services/session-catalog-service.ts index 8aaff8a0f..f434a150d 100644 --- a/apps/main-2.0/src/main/services/session-catalog-service.ts +++ b/apps/main-2.0/src/main/services/session-catalog-service.ts @@ -17,6 +17,7 @@ import type { ProjectQueryOptions, ProjectSummary, ProjectTagEntry, + RuntimeInvocationSessionResolution, SearchOptions, SessionEnvironment, SessionMessage, @@ -80,11 +81,11 @@ export class SessionCatalogService { return this.dependencies.store.findByRawId(rawId); } - /** Returns the indexed Session owned by an exact Runtime invocation reference. */ - async findByRuntimeInvocationOwner( + /** Resolves an invocation owner to a Session or an explicit unavailable reason. */ + async resolveRuntimeInvocationSession( ownerReference: Record, - ): Promise { - return this.dependencies.store.findByRuntimeInvocationOwner(ownerReference); + ): Promise { + return this.dependencies.store.resolveRuntimeInvocationSession(ownerReference); } async get(sessionKey: string): Promise { diff --git a/apps/main-2.0/src/main/team-chat/team-chat-service.test.ts b/apps/main-2.0/src/main/team-chat/team-chat-service.test.ts index 453b340aa..83d69acf0 100644 --- a/apps/main-2.0/src/main/team-chat/team-chat-service.test.ts +++ b/apps/main-2.0/src/main/team-chat/team-chat-service.test.ts @@ -983,6 +983,7 @@ describe("TeamChatService studio employees", () => { expect(calls[0]?.ownerReference).toMatchObject({ roomId: fixture.room.id, messageId: expect.any(String), + agentId: one!.agentId, dispatchId: expect.any(String), attemptId: expect.any(String), }); diff --git a/apps/main-2.0/src/main/team-chat/team-chat-service.ts b/apps/main-2.0/src/main/team-chat/team-chat-service.ts index ead376fa7..4d15dcda5 100644 --- a/apps/main-2.0/src/main/team-chat/team-chat-service.ts +++ b/apps/main-2.0/src/main/team-chat/team-chat-service.ts @@ -1071,6 +1071,7 @@ export class TeamChatService { ownerReference: { roomId: input.room.id, messageId: input.sourceMessage.id, + agentId: target.agentId, dispatchId, attemptId, ...(dispatch.taskId ? { taskId: dispatch.taskId } : {}), diff --git a/apps/main-2.0/src/preload/index.ts b/apps/main-2.0/src/preload/index.ts index ebed4f3c4..6840814a2 100644 --- a/apps/main-2.0/src/preload/index.ts +++ b/apps/main-2.0/src/preload/index.ts @@ -21,6 +21,7 @@ import type { ProjectSummary, ProjectQueryOptions, ProjectTagEntry, + RuntimeInvocationSessionResolution, SearchOptions, SessionEnvironment, SessionMessage, @@ -62,10 +63,10 @@ const api = { searchSessionPage: (options: SearchOptions): Promise => ipcRenderer.invoke("search:session-page", options), getSession: (sessionKey: string): Promise => ipcRenderer.invoke("session:get", sessionKey), findSessionByRawId: (rawId: string): Promise => ipcRenderer.invoke("session:find-by-raw-id", rawId), - /** Opens the Session associated with an exact AgentRecall invocation owner. */ - findSessionByRuntimeInvocationOwner: ( + /** Resolves the Session associated with an exact AgentRecall invocation owner. */ + resolveRuntimeInvocationSession: ( ownerReference: Record, - ): Promise => ipcRenderer.invoke("session:find-by-runtime-owner", ownerReference), + ): Promise => ipcRenderer.invoke("session:resolve-runtime-owner", ownerReference), getSessionContextComponents: (sessionKey: string): Promise => ipcRenderer.invoke("session:context-components", sessionKey), getMessages: (sessionKey: string, offset?: number, limit?: number): Promise => diff --git a/apps/main-2.0/src/renderer/src/App.tsx b/apps/main-2.0/src/renderer/src/App.tsx index cab9b7d43..e0e0a63bb 100644 --- a/apps/main-2.0/src/renderer/src/App.tsx +++ b/apps/main-2.0/src/renderer/src/App.tsx @@ -220,6 +220,8 @@ export function App(): ReactElement { const [workbenchSkills, setWorkbenchSkills] = useState(null); const [preferredTeamChatRoomId, setPreferredTeamChatRoomId] = useState(); const [preferredTeamChatMessageId, setPreferredTeamChatMessageId] = useState(); + const [preferredEvaluationRunId, setPreferredEvaluationRunId] = useState(); + const [preferredRuntimeChannelId, setPreferredRuntimeChannelId] = useState(); useEffect(() => { if (activePage !== "workbench") return; let active = true; @@ -325,6 +327,9 @@ export function App(): ReactElement { origin, setOrigin, originCounts, + invocationSurface, + setInvocationSurface, + invocationSurfaceCounts, environmentId, setEnvironmentId, tag, @@ -1844,6 +1849,7 @@ export function App(): ReactElement { onShowMcp={() => void navigateToPage("mcp")} onShowChat={(roomId) => { setPreferredTeamChatRoomId(roomId); + setPreferredTeamChatMessageId(undefined); void navigateToPage("team-chat"); }} onShowMemories={() => void navigateToPage("memories")} @@ -1869,6 +1875,8 @@ export function App(): ReactElement { source, origin, originCounts, + invocationSurface, + invocationSurfaceCounts, sourceFilters: visibleSourceFilters, visibility, searchRef, @@ -1920,6 +1928,7 @@ export function App(): ReactElement { deleteTag: setDeleteTagName, setSource, setOrigin, + setInvocationSurface, setTag, setVisibility, search: setQuery, @@ -2022,6 +2031,10 @@ export function App(): ReactElement { language={language} preferredRoomId={preferredTeamChatRoomId} preferredMessageId={preferredTeamChatMessageId} + onPreferredConsumed={() => { + setPreferredTeamChatRoomId(undefined); + setPreferredTeamChatMessageId(undefined); + }} onOpenSession={(sessionKey) => { void window.sessionSearch.getSession(sessionKey).then((session) => { if (session) void openDetail(session); @@ -2036,6 +2049,8 @@ export function App(): ReactElement { enabled={Boolean(appSettings?.evalEnabled)} preselectedSkill={evalPreselectedSkill} onPreselectedConsumed={() => setEvalPreselectedSkill(null)} + initialRunId={preferredEvaluationRunId} + onInitialRunConsumed={() => setPreferredEvaluationRunId(undefined)} onOpenSettings={() => { setSettingsInitialSection("eval"); setSettingsOpen(true); @@ -2051,7 +2066,12 @@ export function App(): ReactElement { ) : null} {activePage === "runtimes" ? ( - + setPreferredRuntimeChannelId(undefined)} + onNavigationGuardChange={setPageNavigationGuard} + /> ) : null} {activePage === "mcp" ? : null} @@ -2179,18 +2199,23 @@ export function App(): ReactElement { return; } if (invocation.surface === "team_chat") { - setPreferredTeamChatRoomId(invocation.ownerReference.roomId); - setPreferredTeamChatMessageId(invocation.ownerReference.messageId); + const roomId = invocation.ownerReference.roomId; + setPreferredTeamChatRoomId(roomId); + setPreferredTeamChatMessageId(roomId ? invocation.ownerReference.messageId : undefined); void navigateToPage("team-chat"); return; } - const page: AppPage = invocation.surface === "evaluation" - ? "evaluation" - : invocation.surface === "skill" - ? "skills" - : invocation.surface === "system" - ? "runtimes" - : "workbench"; + if (invocation.surface === "evaluation") { + setPreferredEvaluationRunId(invocation.ownerReference.runId); + void navigateToPage("evaluation"); + return; + } + if (invocation.surface === "system") { + setPreferredRuntimeChannelId(invocation.ownerReference.channelId); + void navigateToPage("runtimes"); + return; + } + const page: AppPage = invocation.surface === "skill" ? "skills" : "workbench"; void navigateToPage(page); }, reveal: (session) => void runAction( diff --git a/apps/main-2.0/src/renderer/src/features/automation/runtime-feature-page.tsx b/apps/main-2.0/src/renderer/src/features/automation/runtime-feature-page.tsx index 4458d3600..953ef7fd6 100644 --- a/apps/main-2.0/src/renderer/src/features/automation/runtime-feature-page.tsx +++ b/apps/main-2.0/src/renderer/src/features/automation/runtime-feature-page.tsx @@ -35,12 +35,16 @@ export function reconcileEditableAgentsAfterChannelSave( export function RuntimeFeaturePage({ language, + initialChannelId, + onInitialChannelConsumed, onNavigationGuardChange, }: { language: LanguageMode; + initialChannelId?: string; + onInitialChannelConsumed?: () => void; onNavigationGuardChange?: (guard: (() => Promise) | null) => void; }): ReactElement { - const { api, snapshot, setSnapshot, loading, error, refresh } = useAutomationDetails(); + const { api, snapshot, setSnapshot, detailsLoaded, loading, error, refresh } = useAutomationDetails(); const [view, setView] = useState<"channels" | "agents">("channels"); const [providerKeys, setProviderKeys] = useState>(readProviderKeys); const [editableAgents, setEditableAgents] = useState(snapshot.configuredAgents); @@ -63,6 +67,15 @@ export function RuntimeFeaturePage({ }, []); const manager = useRuntimeConfigManager({ chatApi: api, snapshot, setSnapshot, runtimeViewActive: true, onChannelsSaved }); + useEffect(() => { + if (!initialChannelId || !detailsLoaded) return; + if (manager.configChannels.some((channel) => channel.id === initialChannelId)) { + setView("channels"); + manager.selectConfigChannel(initialChannelId); + } + onInitialChannelConsumed?.(); + }, [detailsLoaded, initialChannelId, manager.configChannels, manager.selectConfigChannel, onInitialChannelConsumed]); + const requestUnsavedDecision = useCallback((message: string): Promise => ( new Promise((resolve) => { unsavedDecisionRef.current?.("cancel"); diff --git a/apps/main-2.0/src/renderer/src/features/automation/workflow-feature-page.test.tsx b/apps/main-2.0/src/renderer/src/features/automation/workflow-feature-page.test.tsx index 3262f7248..4e1762018 100644 --- a/apps/main-2.0/src/renderer/src/features/automation/workflow-feature-page.test.tsx +++ b/apps/main-2.0/src/renderer/src/features/automation/workflow-feature-page.test.tsx @@ -14,7 +14,7 @@ const api = vi.hoisted(() => ({ })); const sessionSearch = vi.hoisted(() => ({ - findSessionByRuntimeInvocationOwner: vi.fn(), + resolveRuntimeInvocationSession: vi.fn(), })); vi.mock("../../../../automation/engine/renderer/src/app/services/agent-recall-service", () => ({ @@ -236,7 +236,10 @@ describe("WorkflowFeaturePage live output", () => { it("opens the Session recorded for the selected Workflow run", async () => { const snapshot = completedWorkflow(); const onOpenSession = vi.fn(); - sessionSearch.findSessionByRuntimeInvocationOwner.mockResolvedValue({ sessionKey: "session-1" }); + sessionSearch.resolveRuntimeInvocationSession.mockResolvedValue({ + status: "found", + session: { sessionKey: "session-1" }, + }); api.getWorkflowCore.mockResolvedValue({ definitions: [snapshot.definition], runs: [snapshot.run] }); await act(async () => { @@ -266,7 +269,7 @@ describe("WorkflowFeaturePage live output", () => { await Promise.resolve(); }); - expect(sessionSearch.findSessionByRuntimeInvocationOwner).toHaveBeenCalledWith({ + expect(sessionSearch.resolveRuntimeInvocationSession).toHaveBeenCalledWith({ workflowId: "workflow-1", runId: "run-1", nodeId: "inspect-code", diff --git a/apps/main-2.0/src/renderer/src/features/automation/workflow-feature-page.tsx b/apps/main-2.0/src/renderer/src/features/automation/workflow-feature-page.tsx index 011f10d9f..0753e4eab 100644 --- a/apps/main-2.0/src/renderer/src/features/automation/workflow-feature-page.tsx +++ b/apps/main-2.0/src/renderer/src/features/automation/workflow-feature-page.tsx @@ -16,6 +16,7 @@ import { validateWorkflowDefinition } from "../../../../automation/engine/shared import { agentRecallAutomationService } from "../../../../automation/engine/renderer/src/app/services/agent-recall-service"; import type { LanguageMode } from "../../language"; import { localize } from "../../language"; +import { runtimeSessionUnavailableMessage } from "../sessions/runtime-session-resolution"; import { useAutomationStoreSnapshot } from "./automation-provider"; import { addWorkflowNode, createWorkflowCopy, createWorkflowDefinition, type WorkflowNodeKind } from "./workflow-editor-model"; import { WorkflowGraphCanvas } from "./workflow-graph-canvas"; @@ -589,16 +590,16 @@ export function WorkflowFeaturePage({ if (!draft || !selectedRun) return; setError(undefined); try { - const session = await window.sessionSearch.findSessionByRuntimeInvocationOwner({ + const resolution = await window.sessionSearch.resolveRuntimeInvocationSession({ workflowId: draft.id, runId: selectedRun.id, ...(selectedNodeId ? { nodeId: selectedNodeId } : {}), }); - if (!session) { - setError(localize(language, "The Session for this run has not been indexed yet.", "该运行对应的 Session 尚未完成索引。")); + if (resolution.status !== "found") { + setError(runtimeSessionUnavailableMessage(resolution, { en: "this run", zh: "该运行" }, language)); return; } - onOpenSession?.(session.sessionKey); + onOpenSession?.(resolution.session.sessionKey); } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); } diff --git a/apps/main-2.0/src/renderer/src/features/eval/eval-page.tsx b/apps/main-2.0/src/renderer/src/features/eval/eval-page.tsx index 0e027a6cb..30e66762b 100644 --- a/apps/main-2.0/src/renderer/src/features/eval/eval-page.tsx +++ b/apps/main-2.0/src/renderer/src/features/eval/eval-page.tsx @@ -40,6 +40,8 @@ export function EvalPage({ onNavigationGuardChange, preselectedSkill, onPreselectedConsumed, + initialRunId, + onInitialRunConsumed, }: { language: LanguageMode; enabled: boolean; @@ -48,6 +50,8 @@ export function EvalPage({ onNavigationGuardChange?: (guard: (() => Promise) | null) => void; preselectedSkill?: string | null; onPreselectedConsumed?: () => void; + initialRunId?: string; + onInitialRunConsumed?: () => void; }): ReactElement { const l = (en: string, zh: string) => localize(language, en, zh); const [tab, setTab] = useState("skills"); @@ -160,6 +164,10 @@ export function EvalPage({ } }, [preselectedSkill, onPreselectedConsumed]); + useEffect(() => { + if (initialRunId) setTab("runs"); + }, [initialRunId]); + return (
@@ -201,7 +209,12 @@ export function EvalPage({ onOpenRuns={() => switchTab("runs")} /> ) : tab === "runs" ? ( - + ) : !enabled ? (
diff --git a/apps/main-2.0/src/renderer/src/features/eval/eval-runs-page.test.tsx b/apps/main-2.0/src/renderer/src/features/eval/eval-runs-page.test.tsx index 87af4b5b2..e302fc0fa 100644 --- a/apps/main-2.0/src/renderer/src/features/eval/eval-runs-page.test.tsx +++ b/apps/main-2.0/src/renderer/src/features/eval/eval-runs-page.test.tsx @@ -141,6 +141,28 @@ async function render(): Promise { } describe("EvalRunsPage", () => { + it("opens an explicitly requested run even when it is older than the listed page", async () => { + harness.listRuns.mockResolvedValue({ items: [summary()], total: 51, offset: 0, limit: 50 }); + harness.getRun.mockImplementation(async (runId: string) => ({ + ...graphRun(), + id: runId, + startedAt: 99, + })); + const onInitialRunConsumed = vi.fn(); + + await act(async () => { + root.render(createElement(EvalRunsPage, { + language: "zh", + onOpenSession: () => undefined, + initialRunId: "run-older", + onInitialRunConsumed, + })); + }); + + await vi.waitFor(() => expect(harness.getRun).toHaveBeenCalledWith("run-older")); + expect(onInitialRunConsumed).toHaveBeenCalledOnce(); + }); + it("groups each task's runs under an independently collapsible heading", async () => { harness.listExperiments.mockResolvedValue([ experiment(), diff --git a/apps/main-2.0/src/renderer/src/features/eval/eval-runs-page.tsx b/apps/main-2.0/src/renderer/src/features/eval/eval-runs-page.tsx index cf58e7d1f..c3f7addff 100644 --- a/apps/main-2.0/src/renderer/src/features/eval/eval-runs-page.tsx +++ b/apps/main-2.0/src/renderer/src/features/eval/eval-runs-page.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { ReactElement } from "react"; import { AlertTriangle, @@ -44,9 +44,13 @@ import { export function EvalRunsPage({ language, onOpenSession, + initialRunId, + onInitialRunConsumed, }: { language: LanguageMode; onOpenSession: (sessionKey: string) => void; + initialRunId?: string; + onInitialRunConsumed?: () => void; }): ReactElement { const l = (en: string, zh: string) => localize(language, en, zh); const [runs, setRuns] = useState(null); @@ -58,6 +62,14 @@ export function EvalRunsPage({ const [run, setRun] = useState(null); const [loadingRun, setLoadingRun] = useState(false); const [error, setError] = useState(null); + const requestedRunIdRef = useRef(undefined); + + useEffect(() => { + if (!initialRunId) return; + requestedRunIdRef.current = initialRunId; + setSelectedRunId(initialRunId); + onInitialRunConsumed?.(); + }, [initialRunId, onInitialRunConsumed]); const reload = useCallback(async () => { setError(null); @@ -86,7 +98,7 @@ export function EvalRunsPage({ )), )); setSelectedRunId((current) => ( - current && nextRuns.some((item) => item.id === current) + current && (nextRuns.some((item) => item.id === current) || current === requestedRunIdRef.current) ? current : nextRuns[0]?.id ?? null )); @@ -141,7 +153,10 @@ export function EvalRunsPage({ void (async () => { try { const next = await window.sessionSearch.automation.getEvaluationRun(selectedRunId); - if (!cancelled) setRun(next ?? null); + if (!cancelled) { + setRun(next ?? null); + if (selectedRunId === requestedRunIdRef.current) requestedRunIdRef.current = undefined; + } } catch (cause) { if (!cancelled) setError(cause instanceof Error ? cause.message : String(cause)); } finally { diff --git a/apps/main-2.0/src/renderer/src/features/session-detail/detail-panel.tsx b/apps/main-2.0/src/renderer/src/features/session-detail/detail-panel.tsx index ad2c55d22..b35672b97 100644 --- a/apps/main-2.0/src/renderer/src/features/session-detail/detail-panel.tsx +++ b/apps/main-2.0/src/renderer/src/features/session-detail/detail-panel.tsx @@ -172,6 +172,24 @@ function invocationStatusLabel(status: RuntimeInvocationSummary["status"], langu return localize(language, label[0], label[1]); } +function invocationOwnerActionLabel( + invocation: RuntimeInvocationSummary, + language: LanguageMode, +): string { + const exactOwner = + (invocation.surface === "workflow" && Boolean(invocation.ownerReference.workflowId)) + || (invocation.surface === "team_chat" && Boolean(invocation.ownerReference.roomId)) + || (invocation.surface === "evaluation" && Boolean(invocation.ownerReference.runId)) + || (invocation.surface === "system" && Boolean(invocation.ownerReference.channelId)); + if (exactOwner) return localize(language, "Back to source", "返回调用来源"); + if (invocation.surface === "skill") return localize(language, "Open Skills", "打开 Skills"); + if (invocation.surface === "workflow") return localize(language, "Open Workflows", "打开工作流"); + if (invocation.surface === "team_chat") return localize(language, "Open Team Chat", "打开团队聊天"); + if (invocation.surface === "evaluation") return localize(language, "Open Evaluations", "打开评测"); + if (invocation.surface === "system") return localize(language, "Open Runtimes", "打开 Runtime"); + return localize(language, "Open Workbench", "打开工作台"); +} + export function DetailPanel({ session, turns, @@ -600,7 +618,7 @@ export function DetailPanel({ )} {onOpenInvocationOwner && Object.keys(invocation.ownerReference).length > 0 ? ( ) : null}
diff --git a/apps/main-2.0/src/renderer/src/features/sessions/runtime-session-resolution.test.ts b/apps/main-2.0/src/renderer/src/features/sessions/runtime-session-resolution.test.ts new file mode 100644 index 000000000..b4c0d805b --- /dev/null +++ b/apps/main-2.0/src/renderer/src/features/sessions/runtime-session-resolution.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { runtimeSessionUnavailableMessage } from "./runtime-session-resolution"; + +describe("runtimeSessionUnavailableMessage", () => { + it("distinguishes indexing delay from a Runtime that returned no Session reference", () => { + expect(runtimeSessionUnavailableMessage( + { status: "not_indexed", invocationId: "inv-1" }, + { en: "this run", zh: "该运行" }, + "zh", + )).toBe("该运行对应的 Session 尚未完成索引。"); + expect(runtimeSessionUnavailableMessage( + { status: "no_session_reference", invocationId: "inv-2", invocationStatus: "failed" }, + { en: "this run", zh: "该运行" }, + "zh", + )).toBe("该运行的 Runtime 未返回 Session 引用。"); + }); + + it("reports pending and missing invocations explicitly", () => { + expect(runtimeSessionUnavailableMessage( + { status: "no_session_reference", invocationId: "inv-3", invocationStatus: "pending" }, + { en: "this message", zh: "该消息" }, + "en", + )).toContain("is still running"); + expect(runtimeSessionUnavailableMessage( + { status: "not_recorded" }, + { en: "this message", zh: "该消息" }, + "en", + )).toContain("No Runtime invocation"); + }); +}); diff --git a/apps/main-2.0/src/renderer/src/features/sessions/runtime-session-resolution.ts b/apps/main-2.0/src/renderer/src/features/sessions/runtime-session-resolution.ts new file mode 100644 index 000000000..818f3b075 --- /dev/null +++ b/apps/main-2.0/src/renderer/src/features/sessions/runtime-session-resolution.ts @@ -0,0 +1,27 @@ +import type { RuntimeInvocationSessionResolution } from "../../../../core/types"; +import type { LanguageMode } from "../../language"; + +export function runtimeSessionUnavailableMessage( + resolution: Exclude, + subject: { en: string; zh: string }, + language: LanguageMode, +): string { + if (resolution.status === "not_indexed") { + return language === "zh" + ? `${subject.zh}对应的 Session 尚未完成索引。` + : `The Session for ${subject.en} has not been indexed yet.`; + } + if (resolution.status === "no_session_reference") { + if (resolution.invocationStatus === "pending") { + return language === "zh" + ? `${subject.zh}的 Runtime 调用仍在运行,尚未返回 Session 引用。` + : `The Runtime call for ${subject.en} is still running and has not reported a Session reference.`; + } + return language === "zh" + ? `${subject.zh}的 Runtime 未返回 Session 引用。` + : `The Runtime for ${subject.en} did not return a Session reference.`; + } + return language === "zh" + ? `${subject.zh}没有可追溯的 Runtime 调用记录。` + : `No Runtime invocation was recorded for ${subject.en}.`; +} diff --git a/apps/main-2.0/src/renderer/src/features/sessions/sessions-page.test.tsx b/apps/main-2.0/src/renderer/src/features/sessions/sessions-page.test.tsx index 889edd112..debf651b6 100644 --- a/apps/main-2.0/src/renderer/src/features/sessions/sessions-page.test.tsx +++ b/apps/main-2.0/src/renderer/src/features/sessions/sessions-page.test.tsx @@ -30,6 +30,7 @@ describe("SessionsPage search tools", () => { options: { query: "migration", source: "codex", + origin: "agentrecall", tag: "important", visibility: "favorites", dateFrom: Date.parse("2026-07-01T00:00:00.000Z"), @@ -40,13 +41,14 @@ describe("SessionsPage search tools", () => { useCount: 0, }; const listSavedSearches = vi.fn(async () => [savedSearch]); + const createSavedSearch = vi.fn(async () => savedSearch); const touchSavedSearch = vi.fn(async () => undefined); Object.defineProperty(window, "sessionSearch", { configurable: true, value: { platform: "win32", listSavedSearches, - createSavedSearch: vi.fn(async () => savedSearch), + createSavedSearch, deleteSavedSearch: vi.fn(async () => true), touchSavedSearch, }, @@ -57,17 +59,21 @@ describe("SessionsPage search tools", () => { await act(async () => root.render()); expect(container.querySelector(".toolbar-primary .searchbox")).not.toBeNull(); expect(container.querySelector(".toolbar-secondary .toolbar-filters")).not.toBeNull(); - const invocationGroup = [...container.querySelectorAll(".session-origin-filter button")] - .find((button) => button.textContent?.includes("AgentRecall calls")); + const invocationGroup = container.querySelector(".agentrecall-session-group-header"); expect(invocationGroup?.textContent).toContain("(2)"); await act(async () => invocationGroup?.click()); expect(actions.setOrigin).toHaveBeenCalledWith("agentrecall"); - const collapsedGroup = container.querySelector(".session-origin-group .result-group-head"); - expect(collapsedGroup?.getAttribute("aria-expanded")).toBe("false"); - expect(container.querySelector(".session-origin-group .grouped-results")).toBeNull(); - await act(async () => collapsedGroup?.click()); - expect(collapsedGroup?.getAttribute("aria-expanded")).toBe("true"); - expect(container.querySelector(".session-origin-group .grouped-results")).not.toBeNull(); + expect(actions.setInvocationSurface).toHaveBeenCalledWith("all"); + await act(async () => root.render( + , + )); + expect(container.querySelector(".agentrecall-session-surfaces")).not.toBeNull(); + const workflowSurface = [...container.querySelectorAll(".agentrecall-session-surfaces button")] + .find((button) => button.textContent?.includes("Workflow")); + await act(async () => workflowSurface?.click()); + expect(actions.setInvocationSurface).toHaveBeenCalledWith("workflow"); + expect(container.querySelector(".grouped-results")?.textContent).toContain("ordinary"); + expect(container.querySelector(".grouped-results")?.textContent).toContain("agentrecall"); const advancedButton = buttonByLabel(container, "Advanced search"); await act(async () => advancedButton.click()); @@ -86,10 +92,25 @@ describe("SessionsPage search tools", () => { , )); expect(container.querySelector(".query-builder select")?.value).toBe("codex"); + const saveButton = [...container.querySelectorAll(".query-builder-actions button")] + .find((button) => button.textContent?.trim() === "Save"); + await act(async () => saveButton?.click()); + const saveName = container.querySelector('.query-builder-save input'); + if (!saveName) throw new Error("Expected saved-search name input"); + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(saveName, "Runtime calls"); + await act(async () => saveName.dispatchEvent(new Event("input", { bubbles: true }))); + const saveSearchButton = [...container.querySelectorAll(".query-builder-save button")] + .find((button) => button.textContent?.includes("Save search")); + await act(async () => saveSearchButton?.click()); + expect(createSavedSearch).toHaveBeenCalledWith("Runtime calls", expect.objectContaining({ + origin: "all", + source: "codex", + })); const applyButton = [...container.querySelectorAll(".query-builder button")] .find((button) => button.textContent?.includes("Apply")); await act(async () => applyButton?.click()); expect(actions.setSource).toHaveBeenCalledWith("codex"); + expect(actions.setOrigin).toHaveBeenCalledWith("agentrecall"); const groupButton = buttonByLabel(container, "Group results"); await act(async () => groupButton.click()); @@ -101,7 +122,7 @@ describe("SessionsPage search tools", () => { expect(actions.setSortBy).toHaveBeenCalledWith("activity"); await act(async () => buttonByLabel(container, "Saved searches").click()); - await vi.waitFor(() => expect(listSavedSearches).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(listSavedSearches).toHaveBeenCalledTimes(2)); const savedSearchButton = container.querySelector(".saved-search-apply"); expect(savedSearchButton?.textContent).toContain("Codex favorites"); await act(async () => savedSearchButton?.click()); @@ -124,6 +145,16 @@ function createModel(): SessionsPageModel { sessionTotalCount: 0, origin: "all", originCounts: { ordinary: 3, agentRecall: 2, all: 5 }, + invocationSurface: "all", + invocationSurfaceCounts: { + workflow: 1, + evaluation: 0, + team_chat: 1, + agent: 0, + skill: 0, + system: 0, + all: 2, + }, sidebarSections: { environments: false, remaining: false, sources: false, views: false }, environmentId: "all", tags: ["important"], @@ -210,6 +241,7 @@ function createActions(): SessionsPageActions { deleteTag: vi.fn(), setSource: vi.fn(), setOrigin: vi.fn(), + setInvocationSurface: vi.fn(), setTag: vi.fn(), setVisibility: vi.fn(), search: vi.fn(), diff --git a/apps/main-2.0/src/renderer/src/features/sessions/sessions-page.tsx b/apps/main-2.0/src/renderer/src/features/sessions/sessions-page.tsx index 53079c43d..8181022ab 100644 --- a/apps/main-2.0/src/renderer/src/features/sessions/sessions-page.tsx +++ b/apps/main-2.0/src/renderer/src/features/sessions/sessions-page.tsx @@ -41,6 +41,7 @@ import type { SessionSortBy, } from "../../../../core/types"; import type { SavedSearch } from "../../../../core/store/saved-searches"; +import { AGENT_RECALL_INVOCATION_SURFACES } from "../../../../shared/runtime-invocation"; import { DATE_RANGE_OPTIONS, dateRangeLabel, @@ -104,6 +105,8 @@ export interface SessionsPageModel { source: SearchOptions["source"]; origin: NonNullable; originCounts: { ordinary: number; agentRecall: number; all: number }; + invocationSurface: NonNullable; + invocationSurfaceCounts: Record, number>; sourceFilters: Array<{ label: string; value: SearchOptions["source"] }>; visibility: "default" | "favorites" | "hidden"; searchRef: RefObject; @@ -138,6 +141,7 @@ export interface SessionsPageActions { deleteTag(tagName: string): void; setSource(source: SearchOptions["source"]): void; setOrigin(origin: NonNullable): void; + setInvocationSurface(surface: NonNullable): void; setTag(tag: string | undefined): void; setVisibility(visibility: SessionsPageModel["visibility"]): void; search(query: string): void; @@ -176,10 +180,7 @@ export function SessionsPage({ const [savedSearchesOpen, setSavedSearchesOpen] = useState(false); const [savedSearches, setSavedSearches] = useState([]); const [groupMode, setGroupMode] = useState("flat"); - const [agentRecallGroupOpen, setAgentRecallGroupOpen] = useState(false); const l = (en: string, zh: string): string => model.language === "zh" ? zh : en; - const ordinarySessions = model.sessions.filter((session) => !session.createdByAgentRecall); - const agentRecallSessions = model.sessions.filter((session) => session.createdByAgentRecall); const queryBuilderState = useMemo(() => ({ source: model.source === "all" ? undefined : model.source, tag: model.tag, @@ -210,7 +211,12 @@ export function SessionsPage({ function saveCurrentSearch(name: string, state: QueryBuilderState): void { void window.sessionSearch - .createSavedSearch(name, { query: model.query, ...toSearchOptionsPatch(state) }) + .createSavedSearch(name, { + query: model.query, + origin: model.origin, + invocationSurface: model.invocationSurface, + ...toSearchOptionsPatch(state), + }) .then(loadSavedSearches) .catch(() => undefined); } @@ -218,6 +224,8 @@ export function SessionsPage({ function applySavedSearch(saved: SavedSearch): void { if (saved.options.query !== undefined) actions.search(saved.options.query); actions.setSource(saved.options.source ?? "all"); + actions.setOrigin(saved.options.origin ?? "ordinary"); + actions.setInvocationSurface(saved.options.invocationSurface ?? "all"); actions.setTag(saved.options.tag); actions.setVisibility(saved.options.visibility ?? "default"); if (Number.isFinite(saved.options.dateFrom) && Number.isFinite(saved.options.dateTo)) { @@ -504,21 +512,20 @@ export function SessionsPage({ - @@ -553,31 +560,46 @@ export function SessionsPage({ : null}
+
+ + {model.origin === "agentrecall" ? ( +
+ + {AGENT_RECALL_INVOCATION_SURFACES.map((surface) => ( + + ))} +
+ ) : null} +
+
- {model.origin === "all" ? ( - <> - {renderSessionResults(ordinarySessions)} - {model.originCounts.agentRecall > 0 ? ( -
- - {agentRecallGroupOpen ? ( - renderSessionResults(agentRecallSessions) - ) : null} -
- ) : null} - - ) : ( - renderSessionResults(model.sessions) - )} + {renderSessionResults(model.sessions)} {model.sessions.length === 0 ?
{l("No sessions found.", "没有找到会话。")}
: null} @@ -615,6 +637,21 @@ export function SessionsPage({ ); } +function invocationSurfaceFilterLabel( + surface: Exclude, "all">, + language: LanguageMode, +): string { + const labels = { + workflow: ["Workflow", "Workflow"], + evaluation: ["Eval", "评估"], + team_chat: ["Team Chat", "团队聊天"], + agent: ["Agent", "Agent"], + skill: ["Skill", "Skill"], + system: ["System", "系统任务"], + } as const; + return labels[surface][language === "zh" ? 1 : 0]; +} + function paginationItems(currentPage: number, totalPages: number): number[] { const startPage = Math.max(1, currentPage - 2); const endPage = Math.min(totalPages, currentPage + 2); diff --git a/apps/main-2.0/src/renderer/src/features/sessions/use-session-catalog.test.tsx b/apps/main-2.0/src/renderer/src/features/sessions/use-session-catalog.test.tsx index e1a78b76f..20628c11d 100644 --- a/apps/main-2.0/src/renderer/src/features/sessions/use-session-catalog.test.tsx +++ b/apps/main-2.0/src/renderer/src/features/sessions/use-session-catalog.test.tsx @@ -55,7 +55,7 @@ describe("useSessionCatalog pagination", () => { } await act(async () => root.render(createElement(Harness))); - await vi.waitFor(() => expect(searchSessionPage).toHaveBeenCalledWith(expect.objectContaining({ offset: 0, origin: "all" }))); + await vi.waitFor(() => expect(searchSessionPage).toHaveBeenCalledWith(expect.objectContaining({ offset: 0, origin: "ordinary" }))); await act(async () => catalog.goToPage(3)); await vi.waitFor(() => expect(searchSessionPage).toHaveBeenCalledWith(expect.objectContaining({ offset: 60 }))); @@ -113,6 +113,14 @@ describe("useSessionCatalog pagination", () => { expect.objectContaining({ sortBy: "smart", offset: 0 }), )); expect(catalog.currentPage).toBe(1); + + await act(async () => { + catalog.setOrigin("agentrecall"); + catalog.setInvocationSurface("workflow"); + }); + await vi.waitFor(() => expect(searchSessionPage).toHaveBeenLastCalledWith( + expect.objectContaining({ origin: "agentrecall", invocationSurface: "workflow", offset: 0 }), + )); }); }); diff --git a/apps/main-2.0/src/renderer/src/features/sessions/use-session-catalog.ts b/apps/main-2.0/src/renderer/src/features/sessions/use-session-catalog.ts index a62fb2f73..f4a266c2f 100644 --- a/apps/main-2.0/src/renderer/src/features/sessions/use-session-catalog.ts +++ b/apps/main-2.0/src/renderer/src/features/sessions/use-session-catalog.ts @@ -25,6 +25,15 @@ import { resolveSearchScope } from "../search/search-scope"; export type SessionVisibility = "default" | "favorites" | "hidden"; const SESSION_PAGE_SIZE = 30; +const EMPTY_INVOCATION_SURFACE_COUNTS = { + workflow: 0, + evaluation: 0, + team_chat: 0, + agent: 0, + skill: 0, + system: 0, + all: 0, +}; export function useSessionCatalog({ active, @@ -41,7 +50,8 @@ export function useSessionCatalog({ }) { const [query, setQuery] = useState(""); const [source, setSource] = useState("all"); - const [origin, setOrigin] = useState>("all"); + const [origin, setOrigin] = useState>("ordinary"); + const [invocationSurface, setInvocationSurface] = useState>("all"); const [environmentId, setEnvironmentId] = useState("all"); const [tag, setTag] = useState(); const [projectPath, setProjectPath] = useState(); @@ -59,6 +69,7 @@ export function useSessionCatalog({ }); const [sessionTotalCount, setSessionTotalCount] = useState(0); const [originCounts, setOriginCounts] = useState({ ordinary: 0, agentRecall: 0, all: 0 }); + const [invocationSurfaceCounts, setInvocationSurfaceCounts] = useState(EMPTY_INVOCATION_SURFACE_COUNTS); const [results, setResults] = useState([]); const [resultsScopeKey, setResultsScopeKey] = useState(null); const [selectedKey, setSelectedKey] = useState(null); @@ -77,6 +88,7 @@ export function useSessionCatalog({ query, source, origin, + invocationSurface, environmentId, tag ?? "", projectPath ?? "", @@ -92,6 +104,7 @@ export function useSessionCatalog({ query, source, origin, + invocationSurface, environmentId, tag, projectPath, @@ -126,6 +139,7 @@ export function useSessionCatalog({ query, source, origin, + invocationSurface, tag, projectPath: searchScope.projectPath, environmentId: searchScope.environmentId, @@ -139,7 +153,13 @@ export function useSessionCatalog({ liveSessionKeys: liveDetectionFailed ? [] : liveSearchKeys, }; const page = searchScope.projectEnvironmentConflict - ? { sessions: [], totalCount: 0, hasMore: false, originCounts: { ordinary: 0, agentRecall: 0, all: 0 } } + ? { + sessions: [], + totalCount: 0, + hasMore: false, + originCounts: { ordinary: 0, agentRecall: 0, all: 0 }, + invocationSurfaceCounts: EMPTY_INVOCATION_SURFACE_COUNTS, + } : await window.sessionSearch.searchSessionPage(options); if (requestId !== loadSeqRef.current) return; const lastPage = Math.max(1, Math.ceil(page.totalCount / SESSION_PAGE_SIZE)); @@ -153,6 +173,7 @@ export function useSessionCatalog({ setResultsScopeKey(requestScopeKey); setSessionTotalCount(page.totalCount); setOriginCounts(page.originCounts ?? { ordinary: page.totalCount, agentRecall: 0, all: page.totalCount }); + setInvocationSurfaceCounts(page.invocationSurfaceCounts ?? EMPTY_INVOCATION_SURFACE_COUNTS); setSelectedKey((current) => current && !page.sessions.some((session) => session.sessionKey === current) @@ -164,6 +185,7 @@ export function useSessionCatalog({ query, source, origin, + invocationSurface, environmentId, tag, projectPath, @@ -191,6 +213,7 @@ export function useSessionCatalog({ query, source, origin, + invocationSurface, tag, projectPath: searchScope.projectPath, environmentId: searchScope.environmentId, @@ -204,7 +227,7 @@ export function useSessionCatalog({ }); if (page.hasMore) throw new Error("More than 100,000 sessions match. Narrow the filters first."); return page.sessions; - }, [environmentId, projectPath, projectEnvironmentId, customDateRange, dateRange, query, source, origin, tag, visibility, sortBy, liveStatus, liveDetectionFailed, liveSearchKeys]); + }, [environmentId, projectPath, projectEnvironmentId, customDateRange, dateRange, query, source, origin, invocationSurface, tag, visibility, sortBy, liveStatus, liveDetectionFailed, liveSearchKeys]); const clearProjectFilter = useCallback((): void => { setProjectPath(undefined); @@ -335,6 +358,9 @@ export function useSessionCatalog({ origin, setOrigin, originCounts, + invocationSurface, + setInvocationSurface, + invocationSurfaceCounts, environmentId, setEnvironmentId, tag, diff --git a/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.test.tsx b/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.test.tsx index f66c0bf0a..03ca7981e 100644 --- a/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.test.tsx +++ b/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.test.tsx @@ -46,7 +46,7 @@ describe("TeamChatPage rooms", () => { let root: Root; let fixture: ReturnType; let teamChat: ReturnType; - let findSessionByRuntimeInvocationOwner = vi.fn(); + let resolveRuntimeInvocationSession = vi.fn(); beforeEach(() => { Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true); @@ -55,10 +55,10 @@ describe("TeamChatPage rooms", () => { root = createRoot(container); fixture = createTeamChatFixture(); teamChat = fixture; - findSessionByRuntimeInvocationOwner = vi.fn(async () => null); + resolveRuntimeInvocationSession = vi.fn(async () => ({ status: "not_recorded" as const })); Object.defineProperty(window, "sessionSearch", { configurable: true, - value: { teamChat, findSessionByRuntimeInvocationOwner }, + value: { teamChat, resolveRuntimeInvocationSession }, }); vi.spyOn(window, "confirm").mockReturnValue(true); }); @@ -143,7 +143,10 @@ describe("TeamChatPage rooms", () => { it("opens the latest Session recorded for the active room", async () => { fixture.setRooms([roomFixture("room-alpha", "Alpha")]); - findSessionByRuntimeInvocationOwner.mockResolvedValue({ sessionKey: "session-1" }); + resolveRuntimeInvocationSession.mockResolvedValue({ + status: "found", + session: { sessionKey: "session-1" }, + }); const onOpenSession = vi.fn(); await act(async () => root.render( @@ -162,7 +165,7 @@ describe("TeamChatPage rooms", () => { await Promise.resolve(); }); - expect(findSessionByRuntimeInvocationOwner).toHaveBeenCalledWith({ roomId: "room-alpha" }); + expect(resolveRuntimeInvocationSession).toHaveBeenCalledWith({ roomId: "room-alpha" }); expect(onOpenSession).toHaveBeenCalledWith("session-1"); }); @@ -175,7 +178,10 @@ describe("TeamChatPage rooms", () => { senderName: "Builder", sourceMessageId: "human-message", }]); - findSessionByRuntimeInvocationOwner.mockResolvedValue({ sessionKey: "session-message" }); + resolveRuntimeInvocationSession.mockResolvedValue({ + status: "found", + session: { sessionKey: "session-message" }, + }); const onOpenSession = vi.fn(); await act(async () => root.render( @@ -192,9 +198,10 @@ describe("TeamChatPage rooms", () => { await Promise.resolve(); }); - expect(findSessionByRuntimeInvocationOwner).toHaveBeenCalledWith({ + expect(resolveRuntimeInvocationSession).toHaveBeenCalledWith({ roomId: "room-alpha", messageId: "human-message", + agentId: "member-1", }); expect(onOpenSession).toHaveBeenCalledWith("session-message"); }); @@ -403,6 +410,43 @@ describe("TeamChatPage rooms", () => { }); }); + it("loads older pages until the preferred source message can be focused", async () => { + fixture.setRooms([roomFixture("room-alpha", "Alpha")]); + const onPreferredConsumed = vi.fn(); + teamChat.listMessages.mockImplementation(async (request: ListTeamChatMessagesRequest) => { + if (request.before === "alpha-before") { + return { + messages: [messageFixture("target-message", "room-alpha", 1, "Original request")], + }; + } + return { + messages: [messageFixture("recent-message", "room-alpha", 2, "Recent reply")], + nextBefore: "alpha-before", + }; + }); + + await act(async () => root.render( + , + )); + + await vi.waitFor(() => expect(teamChat.listMessages).toHaveBeenCalledWith({ + roomId: "room-alpha", + before: "alpha-before", + limit: 100, + })); + await vi.waitFor(() => { + const target = container.querySelector('[data-message-id="target-message"]'); + expect(target).not.toBeNull(); + expect(document.activeElement).toBe(target); + expect(onPreferredConsumed).toHaveBeenCalledOnce(); + }); + }); + it("ignores an earlier-message response after switching rooms", async () => { fixture.setRooms([ roomFixture("room-alpha", "Alpha"), diff --git a/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.tsx b/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.tsx index 97bf5398a..79ad47381 100644 --- a/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.tsx +++ b/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.tsx @@ -42,6 +42,7 @@ import { modelDisplayLabel } from "../../../../automation/engine/shared/models"; import { localize, type LanguageMode } from "../../language"; import { Markdown } from "../../markdown"; import { useAutomationDetails } from "../automation/automation-provider"; +import { runtimeSessionUnavailableMessage } from "../sessions/runtime-session-resolution"; interface StreamDraft { dispatchId: string; @@ -162,11 +163,13 @@ export function TeamChatPage({ language, preferredRoomId, preferredMessageId, + onPreferredConsumed, onOpenSession, }: { language: LanguageMode; preferredRoomId?: string; preferredMessageId?: string; + onPreferredConsumed?: () => void; onOpenSession?: (sessionKey: string) => void; }): ReactElement { const l = useCallback((en: string, zh: string) => localize(language, en, zh), [language]); @@ -223,17 +226,43 @@ export function TeamChatPage({ const focusedMessageIdRef = useRef(undefined); const skipNextAutoScrollRef = useRef(false); + useEffect(() => { + focusedMessageIdRef.current = undefined; + }, [preferredMessageId, preferredRoomId]); + + useEffect(() => { + if ( + !preferredRoomId + || preferredMessageId + || selectedRoomId !== preferredRoomId + || activeRoom?.id !== preferredRoomId + || loadingMessages + ) return; + onPreferredConsumed?.(); + }, [ + activeRoom?.id, + loadingMessages, + onPreferredConsumed, + preferredMessageId, + preferredRoomId, + selectedRoomId, + ]); + const openLatestRoomSession = async (): Promise => { if (!activeRoom) return; try { - const session = await window.sessionSearch.findSessionByRuntimeInvocationOwner({ + const resolution = await window.sessionSearch.resolveRuntimeInvocationSession({ roomId: activeRoom.id, }); - if (!session) { - setContextFeedback(l("This room's latest Session has not been indexed yet.", "该工作室最近的 Session 尚未完成索引。")); + if (resolution.status !== "found") { + setContextFeedback(runtimeSessionUnavailableMessage( + resolution, + { en: "this room's latest reply", zh: "该工作室最近一次回复" }, + language, + )); return; } - onOpenSession?.(session.sessionKey); + onOpenSession?.(resolution.session.sessionKey); } catch (error) { setContextFeedback(errorMessage(error)); } @@ -242,15 +271,20 @@ export function TeamChatPage({ const openMessageSession = async (message: TeamChatMessage): Promise => { const messageId = message.sourceMessageId ?? message.id; try { - const session = await window.sessionSearch.findSessionByRuntimeInvocationOwner({ + const resolution = await window.sessionSearch.resolveRuntimeInvocationSession({ roomId: message.roomId, messageId, + ...(message.senderAgentId ? { agentId: message.senderAgentId } : {}), }); - if (!session) { - setContextFeedback(l("This message's Session has not been indexed yet.", "该消息对应的 Session 尚未完成索引。")); + if (resolution.status !== "found") { + setContextFeedback(runtimeSessionUnavailableMessage( + resolution, + { en: "this message", zh: "该消息" }, + language, + )); return; } - onOpenSession?.(session.sessionKey); + onOpenSession?.(resolution.session.sessionKey); } catch (error) { setContextFeedback(errorMessage(error)); } @@ -543,16 +577,6 @@ export function TeamChatPage({ transcriptEndRef.current?.scrollIntoView({ block: "end" }); }, [messages.length, streams]); - useEffect(() => { - if (!preferredMessageId || focusedMessageIdRef.current === preferredMessageId) return; - const target = [...document.querySelectorAll("[data-message-id]")] - .find((element) => element.dataset.messageId === preferredMessageId); - if (!target) return; - focusedMessageIdRef.current = preferredMessageId; - target.scrollIntoView?.({ block: "center" }); - target.focus?.(); - }, [messages, preferredMessageId]); - const loadEarlierMessages = useCallback(async (): Promise => { const roomId = selectedRoomIdRef.current; if (!roomId || activeRoom?.id !== roomId || !nextBefore || loadingEarlierRef.current) return; @@ -591,6 +615,49 @@ export function TeamChatPage({ } }, [activeRoom?.id, api, isCurrentRoomScope, nextBefore, setContextFeedback]); + useEffect(() => { + if (!preferredMessageId || focusedMessageIdRef.current === preferredMessageId) return; + const target = [...document.querySelectorAll("[data-message-id]")] + .find((element) => element.dataset.messageId === preferredMessageId); + if (target) { + focusedMessageIdRef.current = preferredMessageId; + target.scrollIntoView?.({ block: "center" }); + target.focus?.(); + onPreferredConsumed?.(); + return; + } + if ( + selectedRoomId === preferredRoomId + && nextBefore + && !loadingMessages + && !loadingEarlier + ) { + void loadEarlierMessages(); + return; + } + if ( + selectedRoomId === preferredRoomId + && activeRoom?.id === preferredRoomId + && !loadingMessages + && !loadingEarlier + && !nextBefore + ) { + focusedMessageIdRef.current = preferredMessageId; + onPreferredConsumed?.(); + } + }, [ + activeRoom?.id, + loadEarlierMessages, + loadingEarlier, + loadingMessages, + messages, + nextBefore, + onPreferredConsumed, + preferredMessageId, + preferredRoomId, + selectedRoomId, + ]); + const sendMessage = useCallback(async (): Promise => { const content = composer.trim(); const roomId = selectedRoomIdRef.current; diff --git a/apps/main-2.0/src/renderer/src/styles/sessions.css b/apps/main-2.0/src/renderer/src/styles/sessions.css index 80ea827cb..a9a6712e2 100644 --- a/apps/main-2.0/src/renderer/src/styles/sessions.css +++ b/apps/main-2.0/src/renderer/src/styles/sessions.css @@ -842,6 +842,69 @@ .bulk-result-actions button:hover { color: var(--text); } .bulk-result-actions .bulk-delete-button { color: var(--danger); } +.agentrecall-session-group { + margin: 0 0 8px; + border: 1px solid var(--border-subtle); + border-radius: 9px; + background: var(--panel-bg); +} + +.agentrecall-session-group.is-open { + border-color: color-mix(in srgb, var(--accent) 38%, var(--border-subtle)); +} + +.agentrecall-session-group-header { + display: flex; + width: 100%; + align-items: center; + gap: 7px; + min-height: 34px; + padding: 0 10px; + color: var(--text-muted); + font-size: 12px; + font-weight: 600; + text-align: left; +} + +.agentrecall-session-group-header:hover, +.agentrecall-session-group.is-open .agentrecall-session-group-header { + color: var(--text); +} + +.agentrecall-session-group-header strong { + min-width: 22px; + margin-left: auto; + padding: 1px 7px; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent-bright); + font-size: 11px; + text-align: center; +} + +.agentrecall-session-surfaces { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 0 10px 10px 31px; +} + +.agentrecall-session-surfaces button { + min-height: 25px; + padding: 0 8px; + border: 1px solid var(--border-subtle); + border-radius: 7px; + color: var(--text-muted); + font-size: 11px; +} + +.agentrecall-session-surfaces button:hover, +.agentrecall-session-surfaces button.active { + border-color: color-mix(in srgb, var(--accent) 45%, var(--border-subtle)); + background: var(--accent-soft); + color: var(--accent-bright); +} + .selected-path { max-width: 52%; overflow: hidden; diff --git a/apps/main-2.0/src/shared/runtime-invocation.ts b/apps/main-2.0/src/shared/runtime-invocation.ts new file mode 100644 index 000000000..163ac3133 --- /dev/null +++ b/apps/main-2.0/src/shared/runtime-invocation.ts @@ -0,0 +1,11 @@ +export const AGENT_RECALL_INVOCATION_SURFACES = [ + "workflow", + "evaluation", + "team_chat", + "agent", + "skill", + "system", +] as const; + +export type AgentRecallInvocationSurface = typeof AGENT_RECALL_INVOCATION_SURFACES[number]; +export type SessionInvocationSurfaceFilter = AgentRecallInvocationSurface | "all"; diff --git a/docs/v2/guide.md b/docs/v2/guide.md index 39a13039b..d968563ef 100644 --- a/docs/v2/guide.md +++ b/docs/v2/guide.md @@ -139,6 +139,14 @@ Session 页面用于索引、搜索、查看和整理不同编码 Agent 的历 WorkBuddy 首版是只读本地来源,可搜索、查看和导出会话中的消息、工具轨迹、标题及用量信息;暂不支持实时跟踪、Resume、迁移、同步或从 AgentRecall 打开 WorkBuddy。 +### AgentRecall 发起的 Runtime 会话 + +Workflow、Eval、Team Chat、Agent、Skill 探索和配置测试所调用的 Runtime 会被记录为 AgentRecall 调用。由这些调用新建的 Session 默认收在 **AgentRecall 调用**分组中,不会挤占普通会话列表;分组标题会显示当前搜索条件下的匹配数量。 + +展开分组后,可以查看全部 AgentRecall Session,或继续按 Workflow、Eval、Team Chat、Agent、Skill 和系统任务筛选。切换到 **全部**会同时显示普通 Session 与 AgentRecall 创建的 Session。收藏、标签、隐藏和批量操作仍按原有规则工作,自动归组不会修改这些状态。 + +Session 详情会显示关联调用的用途、状态、时间和可用的业务返回入口。Workflow 运行记录、Eval 结果和 Team Chat 消息也可以直接打开对应 Session。如果 Runtime 尚未返回 Session 引用、Session 仍在等待索引,或业务记录没有可追溯的调用,页面会分别显示原因。 + ### 搜索和筛选 在 Session 页面按 `Cmd+F`(macOS)或 `Ctrl+F`(Windows)聚焦搜索框,输入关键词后按 Enter。 @@ -151,6 +159,7 @@ WorkBuddy 首版是只读本地来源,可搜索、查看和导出会话中的 - 顶部的全部、进行中和已结束状态。 - 今天、7 天、30 天或全部时间。 - 最近搜索记录。 +- 普通会话、AgentRecall 调用、全部会话及 AgentRecall 调用类型。 - **AI 找会话**,用自然语言描述要寻找的内容。 选中的环境、项目、标签或单日范围会显示在搜索框附近,点击对应条件即可清除。 From e3b6cfce5460e406261e8a49cfd24e5e71622d38 Mon Sep 17 00:00:00 2001 From: Akuma <2374973868@qq.com> Date: Fri, 4 Sep 2026 13:25:36 +0800 Subject: [PATCH 05/11] fix(v2): complete runtime session attribution --- .../fix-issue-535-runtime-sessions.md | 4 +- .../engine/main/agents/dsh/dsh-runner.test.ts | 30 --- .../engine/main/agents/dsh/dsh-runner.ts | 87 +------- .../agents/dsh/dsh-session-discovery.test.ts | 108 ---------- .../main/agents/dsh/dsh-session-discovery.ts | 186 ------------------ .../main/agents/hermes/hermes-runner.ts | 38 ++-- .../runtime/native-session-reporting.test.ts | 57 ++++++ .../runtime/runtime-invocation-recorder.ts | 31 ++- .../runtime/runtime-router-invocation.test.ts | 114 ++++++++++- .../main/agents/runtime/runtime-router.ts | 70 +++++-- .../engine/main/evaluation-runner.ts | 4 +- .../hub/agent-hub-invocation-owner.test.ts | 98 +++++++++ .../automation/engine/main/hub/agent-hub.ts | 13 +- .../main/hub/chat/agent-hub-interactive.ts | 5 +- .../hub/runtime/executor/codex/codex-test.ts | 5 +- .../executor/codex/codex-workflow.test.ts | 66 +++++++ .../runtime/executor/codex/codex-workflow.ts | 2 + .../executor/dsh/dsh-capabilities.test.ts | 6 +- .../runtime-onboarding-contract.test.ts | 3 +- .../main/hub/runtime/run/agent-hub-runner.ts | 1 + ...configured-agent-execution-service.test.ts | 1 + .../configured-agent-execution-service.ts | 2 + .../src/automation/engine/shared/types.ts | 2 + .../src/core/evaluation/nodes/contracts.ts | 5 +- .../core/evaluation/nodes/prepare-nodes.ts | 4 +- apps/main-2.0/src/core/evaluation/run.test.ts | 8 +- .../postgres/runtime-invocation-repository.ts | 3 +- .../main-2.0/src/core/postgres/schema.test.ts | 26 ++- apps/main-2.0/src/core/postgres/schema.ts | 14 +- .../src/core/postgres/session-records.ts | 78 +++++--- .../core/postgres/session-repository.test.ts | 22 +++ .../src/core/postgres/session-repository.ts | 30 ++- .../src/core/postgres/session-search.test.ts | 51 ++++- .../core/postgres/session-stats-repository.ts | 37 +++- apps/main-2.0/src/core/session-store.ts | 5 +- apps/main-2.0/src/core/types.ts | 17 +- apps/main-2.0/src/main/index.ts | 17 +- .../src/main/ipc/session-catalog.test.ts | 18 +- apps/main-2.0/src/main/ipc/session-catalog.ts | 45 ++++- .../src/main/services/evaluation-service.ts | 6 +- .../main/services/session-catalog-service.ts | 5 +- apps/main-2.0/src/preload/index.ts | 5 +- .../renderer/src/App.session-open.test.tsx | 92 ++++++++- apps/main-2.0/src/renderer/src/App.tsx | 68 +++++-- .../automation/runtime-feature-page.tsx | 13 ++ .../automation/workflow-feature-page.test.tsx | 9 +- .../automation/workflow-feature-page.tsx | 9 +- .../renderer/src/features/eval/eval-page.tsx | 6 + .../src/features/eval/eval-runs-page.test.tsx | 76 +++++++ .../src/features/eval/eval-runs-page.tsx | 93 ++++++++- .../session-detail/detail-panel.test.tsx | 1 - .../features/session-detail/detail-panel.tsx | 3 +- .../src/features/skills/skills-page.tsx | 10 + .../team-chat/team-chat-page.test.tsx | 16 +- .../src/features/team-chat/team-chat-page.tsx | 14 +- .../workbench/use-workbench-overview.ts | 16 +- .../src/features/workbench/workbench-page.tsx | 22 +++ .../main-2.0/src/renderer/src/styles/eval.css | 12 +- docs/v2/guide.md | 4 +- 59 files changed, 1227 insertions(+), 566 deletions(-) delete mode 100644 apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-session-discovery.test.ts delete mode 100644 apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-session-discovery.ts create mode 100644 apps/main-2.0/src/automation/engine/main/hub/agent-hub-invocation-owner.test.ts create mode 100644 apps/main-2.0/src/automation/engine/main/hub/runtime/executor/codex/codex-workflow.test.ts diff --git a/.release-notes/fix-issue-535-runtime-sessions.md b/.release-notes/fix-issue-535-runtime-sessions.md index d5b37e8c5..80d229f6e 100644 --- a/.release-notes/fix-issue-535-runtime-sessions.md +++ b/.release-notes/fix-issue-535-runtime-sessions.md @@ -4,5 +4,5 @@ ## Bug 修复 -- AgentRecall 发起的 Codex、Claude Code、Hermes、OpenCode、OpenClaw 与 DeepSeek Harness 会话现在归入默认收起的独立分组,可按 Workflow、Eval、Team Chat、Agent、Skill 和系统任务筛选,不再挤占普通会话列表。 -- Session 详情和业务记录现在提供双向入口,并明确区分 Runtime 未返回 Session 引用、Session 尚未完成索引和没有调用记录。 +- AgentRecall 发起且由 Runtime 返回可靠 Session 引用的会话现在归入默认收起的独立分组,可按 Workflow、Eval、Team Chat、Agent、Skill 和系统任务筛选,不再挤占普通会话列表;用量和项目计数也采用相同口径。 +- Session 详情和 Workflow、Eval、Team Chat 业务记录现在提供精确的双向入口;Runtime 未返回 Session 引用时会保留调用记录并明确说明,不再根据目录变化、标题、路径或时间猜测归属。 diff --git a/apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-runner.test.ts b/apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-runner.test.ts index b15a5b1ca..ac0ba122c 100644 --- a/apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-runner.test.ts +++ b/apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-runner.test.ts @@ -65,11 +65,6 @@ function createRunner( ...overrides, }, { platform: "linux", - createSessionDiscovery: () => ({ - prepare: () => undefined, - observe: () => undefined, - finish: async () => undefined, - }), ...dependencies, }), events, @@ -106,31 +101,6 @@ describe("DshRunner", () => { expect(exits).toEqual([0]); }); - test("reports the Session discovered during the owned headless run", async () => { - const proc = createProcess(); - const createSessionDiscovery = vi.fn((_sessionsRoot: string, onSessionId: (sessionId: string) => void) => ({ - prepare: () => undefined, - observe: () => onSessionId("session-dsh-created"), - finish: async () => undefined, - })); - const { runner, events } = createRunner({}, { createSessionDiscovery }); - const started = runner.start(); - - proc.stdout.write("Done"); - proc.emit("close", 0, null); - await started; - - expect(createSessionDiscovery).toHaveBeenCalledOnce(); - expect(events[0]).toMatchObject({ - type: "runtime_conversation", - runtimeConversation: { - runtimeId: "dsh", - payload: { native: { sessionId: "session-dsh-created" } }, - }, - }); - expect(events[1]).toEqual({ type: "completed", content: "Done" }); - }); - test("reports a successful process that produced no assistant text", async () => { const proc = createProcess(); const { runner, events, exits } = createRunner(); diff --git a/apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-runner.ts b/apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-runner.ts index 5dce5ebb4..167bd1ffe 100644 --- a/apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-runner.ts +++ b/apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-runner.ts @@ -3,11 +3,6 @@ import { StringDecoder } from "node:string_decoder"; import type { AgentEvent } from "../../../shared/types"; import { spawnCli } from "../../platform/cli-launcher"; import { resolveWindowsDshInvocation } from "./dsh-windows-launcher"; -import { - DshSessionDiscovery, - dshSessionsRoot, - type DshSessionDiscoveryHandle, -} from "./dsh-session-discovery"; const MAX_STDERR_CHARS = 8_000; const MAX_POSIX_PROMPT_BYTES = 120_000; @@ -29,10 +24,6 @@ interface DshRunnerDependencies { spawnProcess: typeof spawn; killProcess: typeof process.kill; resolveWindowsInvocation: typeof resolveWindowsDshInvocation; - createSessionDiscovery: ( - sessionsRoot: string, - onSessionId: (sessionId: string) => void, - ) => DshSessionDiscoveryHandle; } interface ActiveDshRun { @@ -48,26 +39,18 @@ interface ActiveDshRun { stopTimer?: ReturnType; terminalTimer?: ReturnType; stopPromise?: Promise; - sessionDiscovery: DshSessionDiscoveryHandle; } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function abortError(): Error { - const error = new Error("DSH runner start was cancelled."); - error.name = "AbortError"; - return error; -} - function boundedAppend(current: string, text: string): string { return `${current}${text}`.slice(-MAX_STDERR_CHARS); } export class DshRunner { private active: ActiveDshRun | undefined; - private pendingStartAbort: AbortController | undefined; private readonly dependencies: DshRunnerDependencies; constructor( @@ -79,16 +62,12 @@ export class DshRunner { spawnProcess: spawn, killProcess: process.kill, resolveWindowsInvocation: resolveWindowsDshInvocation, - createSessionDiscovery: (sessionsRoot, onSessionId) => - new DshSessionDiscovery(sessionsRoot, onSessionId), ...dependencies, }; } async start(): Promise { - if (this.active || this.pendingStartAbort) { - throw new Error("DSH runner is already running."); - } + if (this.active) throw new Error("DSH runner is already running."); let exitReported = false; const reportExit = (code: number | null): void => { @@ -99,20 +78,6 @@ export class DshRunner { let proc: ChildProcess; let activeCreated = false; - const startAbort = new AbortController(); - this.pendingStartAbort = startAbort; - const environment = this.options.env ?? process.env; - const sessionDiscovery = this.dependencies.createSessionDiscovery( - dshSessionsRoot(environment), - (sessionId) => this.options.onEvent({ - type: "runtime_conversation", - runtimeConversation: { - runtimeId: "dsh", - codecVersion: "v1", - payload: { native: { sessionId } }, - }, - }), - ); try { if ( this.dependencies.platform !== "win32" @@ -122,9 +87,6 @@ export class DshRunner { "The DSH prompt is too large for a single command-line argument. Shorten the request or attached instructions.", ); } - const preparation = sessionDiscovery.prepare(startAbort.signal); - if (preparation) await preparation; - if (startAbort.signal.aborted) throw abortError(); let executable = this.options.executable; let args = ["--profile", "headless", this.options.prompt]; let stdin: string | undefined; @@ -133,7 +95,7 @@ export class DshRunner { const invocation = this.dependencies.resolveWindowsInvocation({ executable, args, - environment, + environment: this.options.env ?? process.env, workingDirectory: this.options.cwd, }); executable = invocation.executable; @@ -145,7 +107,7 @@ export class DshRunner { executable, args, cwd: this.options.cwd, - env: environment, + env: this.options.env ?? process.env, stdio: supportsIpcInterrupt ? [stdin === undefined ? "ignore" : "pipe", "pipe", "pipe", "ipc"] : [stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"], @@ -166,12 +128,9 @@ export class DshRunner { rejectCompletion, finished: false, stopping: false, - sessionDiscovery, }; this.active = active; - this.pendingStartAbort = undefined; activeCreated = true; - sessionDiscovery.observe(); const stdoutDecoder = new StringDecoder("utf8"); const stderrDecoder = new StringDecoder("utf8"); @@ -187,10 +146,10 @@ export class DshRunner { this.options.onStderr?.(text); }); - const finish = async ( + const finish = ( code: number | null, processError?: unknown, - ): Promise => { + ): void => { if (active.finished) return; active.finished = true; stdout += stdoutDecoder.end(); @@ -199,11 +158,6 @@ export class DshRunner { if (this.active === active) this.active = undefined; let callbackError: unknown; - try { - await active.sessionDiscovery.finish(); - } catch (error) { - callbackError = error; - } try { if (active.terminalError) { this.options.onEvent({ @@ -236,7 +190,7 @@ export class DshRunner { } } } catch (error) { - callbackError ??= error; + callbackError = error; } try { reportExit( @@ -261,12 +215,12 @@ export class DshRunner { this.forceTerminateProcessTree(active); active.terminalTimer = setTimeout(() => { active.terminalTimer = undefined; - void finish(null, new Error(message)); + finish(null, new Error(message)); }, TERMINATION_GRACE_MS + TERMINATION_CLOSE_MS); }; - proc.once("close", (code) => void finish(code)); - proc.once("error", (error) => void finish(null, error)); + proc.once("close", (code) => finish(code)); + proc.once("error", (error) => finish(null, error)); const missingPipes = [ !proc.stdout ? "stdout" : undefined, @@ -296,25 +250,8 @@ export class DshRunner { await completion; return; } catch (error) { - let discoveryCleanupError: unknown; - try { - await sessionDiscovery.finish(); - } catch (cleanupError) { - discoveryCleanupError = cleanupError; - } - if (this.pendingStartAbort === startAbort) this.pendingStartAbort = undefined; - if (startAbort.signal.aborted) { - reportExit(null); - return; - } if (activeCreated) throw error; - const runtimeError = new Error( - `DSH process error: ${errorMessage(error)}${ - discoveryCleanupError === undefined - ? "" - : `; Session discovery cleanup failed: ${errorMessage(discoveryCleanupError)}` - }`, - ); + const runtimeError = new Error(`DSH process error: ${errorMessage(error)}`); try { this.options.onEvent({ type: "error", error: runtimeError.message }); } finally { @@ -325,10 +262,6 @@ export class DshRunner { } async stop(): Promise { - if (this.pendingStartAbort) { - this.pendingStartAbort.abort(); - return; - } const active = this.active; if (!active) return; if (!active.stopPromise) { diff --git a/apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-session-discovery.test.ts b/apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-session-discovery.test.ts deleted file mode 100644 index 1e2353fe7..000000000 --- a/apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-session-discovery.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { DshSessionDiscovery } from "./dsh-session-discovery"; - -describe("DshSessionDiscovery", () => { - afterEach(() => vi.useRealTimers()); - - it("reports the only Session created after the owned DSH process starts", async () => { - vi.useFakeTimers(); - let ids = new Set(["session-existing"]); - const onSessionId = vi.fn(); - const discovery = new DshSessionDiscovery("/dsh/sessions", onSessionId, { - readSessionIds: vi.fn(async () => new Set(ids)), - setInterval, - clearInterval, - }); - - await discovery.prepare(new AbortController().signal); - discovery.observe(); - ids = new Set([...ids, "session-created"]); - await vi.advanceTimersByTimeAsync(100); - await discovery.finish(); - - expect(onSessionId).toHaveBeenCalledOnce(); - expect(onSessionId).toHaveBeenCalledWith("session-created"); - }); - - it("does not guess when more than one Session appears in the discovery window", async () => { - vi.useFakeTimers(); - let ids = new Set(); - const onSessionId = vi.fn(); - const discovery = new DshSessionDiscovery("/dsh/ambiguous", onSessionId, { - readSessionIds: vi.fn(async () => new Set(ids)), - setInterval, - clearInterval, - }); - - await discovery.prepare(new AbortController().signal); - discovery.observe(); - ids = new Set(["session-one", "session-two"]); - await vi.advanceTimersByTimeAsync(100); - await discovery.finish(); - - expect(onSessionId).not.toHaveBeenCalled(); - }); - - it("does not attribute Sessions when the owned process never entered observation", async () => { - let ids = new Set(); - const onSessionId = vi.fn(); - const discovery = new DshSessionDiscovery("/dsh/not-started", onSessionId, { - readSessionIds: vi.fn(async () => new Set(ids)), - setInterval, - clearInterval, - }); - - await discovery.prepare(new AbortController().signal); - ids = new Set(["session-external"]); - await discovery.finish(); - - expect(onSessionId).not.toHaveBeenCalled(); - }); - - it("surfaces polling failures during deterministic cleanup", async () => { - vi.useFakeTimers(); - const readSessionIds = vi.fn() - .mockResolvedValueOnce(new Set()) - .mockRejectedValueOnce(new Error("read denied")); - const discovery = new DshSessionDiscovery("/dsh/unreadable", vi.fn(), { - readSessionIds, - setInterval, - clearInterval, - }); - - await discovery.prepare(new AbortController().signal); - discovery.observe(); - await vi.advanceTimersByTimeAsync(100); - - await expect(discovery.finish()).rejects.toThrow("read denied"); - }); - - it("keeps later discovery queued when an intermediate waiter is cancelled", async () => { - const dependencies = { - readSessionIds: vi.fn(async () => new Set()), - setInterval, - clearInterval, - }; - const first = new DshSessionDiscovery("/dsh/serialized", vi.fn(), dependencies); - const cancelled = new DshSessionDiscovery("/dsh/serialized", vi.fn(), dependencies); - const last = new DshSessionDiscovery("/dsh/serialized", vi.fn(), dependencies); - const cancelledController = new AbortController(); - - await first.prepare(new AbortController().signal); - const cancelledPreparation = cancelled.prepare(cancelledController.signal); - cancelledController.abort(); - await expect(cancelledPreparation).rejects.toMatchObject({ name: "AbortError" }); - - let lastPrepared = false; - const lastPreparation = last.prepare(new AbortController().signal).then(() => { - lastPrepared = true; - }); - await Promise.resolve(); - expect(lastPrepared).toBe(false); - - await first.finish(); - await lastPreparation; - expect(lastPrepared).toBe(true); - await last.finish(); - }); -}); diff --git a/apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-session-discovery.ts b/apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-session-discovery.ts deleted file mode 100644 index 58a71da06..000000000 --- a/apps/main-2.0/src/automation/engine/main/agents/dsh/dsh-session-discovery.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { readdir } from "node:fs/promises"; -import { homedir } from "node:os"; -import { join } from "node:path"; - -const DEFAULT_DSH_HOME_NAME = ".dsh"; -const DISCOVERY_INTERVAL_MS = 100; -const discoveryTails = new Map>(); - -export interface DshSessionDiscoveryHandle { - prepare(signal: AbortSignal): Promise | void; - observe(): void; - finish(): Promise; -} - -interface DshSessionDiscoveryDependencies { - readSessionIds: (sessionsRoot: string) => Promise>; - setInterval: typeof setInterval; - clearInterval: typeof clearInterval; -} - -function abortError(): Error { - const error = new Error("DSH Session discovery was cancelled."); - error.name = "AbortError"; - return error; -} - -async function waitForTurn(previous: Promise, signal: AbortSignal): Promise { - if (signal.aborted) throw abortError(); - await new Promise((resolve, reject) => { - const handleAbort = (): void => { - reject(abortError()); - }; - signal.addEventListener("abort", handleAbort, { once: true }); - void previous.then( - () => { - signal.removeEventListener("abort", handleAbort); - resolve(); - }, - (error: unknown) => { - signal.removeEventListener("abort", handleAbort); - reject(error); - }, - ); - }); -} - -async function acquireDiscoveryTurn(key: string, signal: AbortSignal): Promise<() => void> { - const previous = discoveryTails.get(key) ?? Promise.resolve(); - let releaseSlot!: () => void; - const slot = new Promise((resolve) => { - releaseSlot = resolve; - }); - const tail = previous.then(() => slot); - discoveryTails.set(key, tail); - try { - await waitForTurn(previous, signal); - } catch (error) { - releaseSlot(); - void tail.then(() => { - if (discoveryTails.get(key) === tail) discoveryTails.delete(key); - }); - throw error; - } - let released = false; - return () => { - if (released) return; - released = true; - releaseSlot(); - if (discoveryTails.get(key) === tail) discoveryTails.delete(key); - }; -} - -async function readDshSessionIds(sessionsRoot: string): Promise> { - const ids = new Set(); - let projects; - try { - projects = await readdir(sessionsRoot, { withFileTypes: true }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return ids; - throw error; - } - await Promise.all(projects.filter((entry) => entry.isDirectory()).map(async (project) => { - let sessions; - try { - sessions = await readdir(join(sessionsRoot, project.name), { withFileTypes: true }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return; - throw error; - } - for (const session of sessions) { - if (session.isDirectory() && session.name) ids.add(session.name); - } - })); - return ids; -} - -export function dshSessionsRoot(environment: NodeJS.ProcessEnv): string { - const dshHome = environment.DSH_HOME?.trim() || join(homedir(), DEFAULT_DSH_HOME_NAME); - return join(dshHome, "sessions"); -} - -/** - * Attributes the one fresh Session created by an official DSH headless run. - * AgentRecall serializes only this short discovery window. More than one new - * Session is intentionally left unbound because ownership would be ambiguous. - */ -export class DshSessionDiscovery implements DshSessionDiscoveryHandle { - private baseline: Set | undefined; - private releaseTurn: (() => void) | undefined; - private timer: ReturnType | undefined; - private checking: Promise = Promise.resolve(); - private discoveryError: unknown; - private observing = false; - private reported = false; - - constructor( - private readonly sessionsRoot: string, - private readonly onSessionId: (sessionId: string) => void, - private readonly dependencies: DshSessionDiscoveryDependencies = { - readSessionIds: readDshSessionIds, - setInterval, - clearInterval, - }, - ) {} - - async prepare(signal: AbortSignal): Promise { - this.releaseTurn = await acquireDiscoveryTurn(this.sessionsRoot, signal); - try { - this.baseline = await this.dependencies.readSessionIds(this.sessionsRoot); - } catch (error) { - this.release(); - throw error; - } - } - - observe(): void { - if (!this.baseline || this.timer) return; - this.observing = true; - this.timer = this.dependencies.setInterval(() => { - this.enqueueCheck(); - }, DISCOVERY_INTERVAL_MS); - this.enqueueCheck(); - } - - async finish(): Promise { - this.clearTimer(); - if (this.observing) this.enqueueCheck(); - try { - await this.checking; - } finally { - this.release(); - } - if (this.discoveryError !== undefined) throw this.discoveryError; - } - - private enqueueCheck(): void { - if (!this.baseline || this.reported || this.discoveryError) return; - this.checking = this.checking - .then(async () => { - if (!this.baseline || this.reported || this.discoveryError) return; - const current = await this.dependencies.readSessionIds(this.sessionsRoot); - const created = [...current].filter((sessionId) => !this.baseline?.has(sessionId)); - if (created.length !== 1) return; - this.reported = true; - this.onSessionId(created[0]!); - this.clearTimer(); - this.release(); - }) - .catch((error: unknown) => { - this.discoveryError ??= error; - this.clearTimer(); - this.release(); - }); - } - - private clearTimer(): void { - if (!this.timer) return; - this.dependencies.clearInterval(this.timer); - this.timer = undefined; - } - - private release(): void { - this.releaseTurn?.(); - this.releaseTurn = undefined; - } -} diff --git a/apps/main-2.0/src/automation/engine/main/agents/hermes/hermes-runner.ts b/apps/main-2.0/src/automation/engine/main/agents/hermes/hermes-runner.ts index 22082676c..cb31b6283 100644 --- a/apps/main-2.0/src/automation/engine/main/agents/hermes/hermes-runner.ts +++ b/apps/main-2.0/src/automation/engine/main/agents/hermes/hermes-runner.ts @@ -4,6 +4,8 @@ import { runtimeModelId } from "../../../shared/models"; import { spawnCli } from "../../platform/cli-launcher"; import { hermesRuntimeStateCodec } from "./hermes-runtime-state-codec"; +const MAX_STDERR_CHARS = 8_000; + export interface HermesRunOptions { executable: string; cwd: string; @@ -45,6 +47,26 @@ export class HermesRunner { let stdout = ""; let stderr = ""; + let reportedSessionId: string | undefined; + const reportSessionReference = (includeIncompleteFinalLine = false): void => { + const lastLineBreak = stderr.lastIndexOf("\n"); + const parseableStderr = includeIncompleteFinalLine + ? stderr + : lastLineBreak >= 0 ? stderr.slice(0, lastLineBreak + 1) : ""; + const sessionId = hermesSessionIdFromStderr(parseableStderr); + if (!sessionId || sessionId === reportedSessionId) return; + reportedSessionId = sessionId; + this.options.onEvent({ + type: "runtime_conversation", + runtimeConversation: hermesRuntimeStateCodec.encodeConversation({ + native: { sessionId }, + appContext: { + cwd: this.options.cwd, + ...(this.options.modelId ? { modelId: this.options.modelId } : {}), + }, + }), + }); + }; proc.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString(); }); @@ -52,6 +74,8 @@ export class HermesRunner { const text = chunk.toString(); stderr += text; this.options.onStderr?.(text); + reportSessionReference(); + stderr = stderr.slice(-MAX_STDERR_CHARS); }); return await new Promise((resolve, reject) => { @@ -66,20 +90,8 @@ export class HermesRunner { proc.once("exit", (code) => { finish(() => { const content = stdout.trim(); + reportSessionReference(true); if (!this.stopping && code === 0) { - const sessionId = hermesSessionIdFromStderr(stderr); - if (sessionId) { - this.options.onEvent({ - type: "runtime_conversation", - runtimeConversation: hermesRuntimeStateCodec.encodeConversation({ - native: { sessionId }, - appContext: { - cwd: this.options.cwd, - ...(this.options.modelId ? { modelId: this.options.modelId } : {}), - }, - }), - }); - } if (content) this.options.onEvent({ type: "completed", content }); else this.options.onEvent({ type: "error", error: "Hermes completed without assistant text." }); } else if (!this.stopping) { diff --git a/apps/main-2.0/src/automation/engine/main/agents/runtime/native-session-reporting.test.ts b/apps/main-2.0/src/automation/engine/main/agents/runtime/native-session-reporting.test.ts index cc6943077..9f3010588 100644 --- a/apps/main-2.0/src/automation/engine/main/agents/runtime/native-session-reporting.test.ts +++ b/apps/main-2.0/src/automation/engine/main/agents/runtime/native-session-reporting.test.ts @@ -116,4 +116,61 @@ describe("native Runtime Session reporting", () => { }); expect(events[1]).toEqual({ type: "completed", content: "Done" }); }); + + it("reports Hermes' session id before a non-zero exit", async () => { + const process = createProcess(); + const events: AgentEvent[] = []; + const runner = new HermesRunner({ + executable: "hermes", + cwd: "/repo", + prompt: "Review", + onEvent: (event) => events.push(event), + onExit: vi.fn(), + }); + + const started = runner.start(); + process.stderr.write("session_id: session-hermes-failed\n"); + process.stderr.write("provider failed\n"); + process.emit("exit", 1); + await started; + + expect(events[0]).toMatchObject({ + type: "runtime_conversation", + runtimeConversation: { + runtimeId: "hermes", + payload: { native: { sessionId: "session-hermes-failed" } }, + }, + }); + expect(events[1]).toMatchObject({ type: "error" }); + }); + + it("does not bind a partial Hermes session id split across stderr chunks", async () => { + const process = createProcess(); + const events: AgentEvent[] = []; + const runner = new HermesRunner({ + executable: "hermes", + cwd: "/repo", + prompt: "Review", + onEvent: (event) => events.push(event), + onExit: vi.fn(), + }); + + const started = runner.start(); + process.stderr.write("session_id: session-part"); + expect(events).toEqual([]); + process.stderr.write("-complete\n"); + process.stdout.write("Done\n"); + process.emit("exit", 0); + await started; + + expect(events.filter((event) => event.type === "runtime_conversation")).toEqual([ + expect.objectContaining({ + runtimeConversation: { + runtimeId: "hermes", + codecVersion: "v1", + payload: expect.objectContaining({ native: { sessionId: "session-part-complete" } }), + }, + }), + ]); + }); }); diff --git a/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-invocation-recorder.ts b/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-invocation-recorder.ts index 287a5bd05..2fa32bfa3 100644 --- a/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-invocation-recorder.ts +++ b/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-invocation-recorder.ts @@ -2,6 +2,10 @@ import type { AgentId, RuntimeInvocationRequest, } from "../../../shared/types"; +import { sanitizeWorkflowTransactionValue } from "../../../shared/workflow-v2/transaction"; + +const MAX_RUNTIME_INVOCATION_ERROR_CHARACTERS = 4_000; +const TRUNCATED_RUNTIME_INVOCATION_ERROR_SUFFIX = "\n..."; /** Durable lifecycle state for one AgentRecall Runtime dispatch. */ export type RuntimeInvocationStatus = @@ -65,9 +69,34 @@ export interface RuntimeInvocationRecorder { ): Promise; } -/** No-op recorder used by isolated RuntimeHub instances without a database owner. */ +/** Redacts credential-shaped data and bounds errors before they enter durable history. */ +export function runtimeInvocationErrorMessage(error: unknown): string { + const raw = error instanceof Error ? error.message : String(error); + const sanitized = sanitizeWorkflowTransactionValue(raw); + const value = typeof sanitized === "string" ? sanitized : String(sanitized); + return value.length <= MAX_RUNTIME_INVOCATION_ERROR_CHARACTERS + ? value + : `${value.slice( + 0, + MAX_RUNTIME_INVOCATION_ERROR_CHARACTERS + - TRUNCATED_RUNTIME_INVOCATION_ERROR_SUFFIX.length, + )}${TRUNCATED_RUNTIME_INVOCATION_ERROR_SUFFIX}`; +} + +/** No-op recorder used only by isolated tests without a database owner. */ export const NOOP_RUNTIME_INVOCATION_RECORDER: RuntimeInvocationRecorder = { begin: async () => undefined, bind: async () => undefined, finish: async () => undefined, }; + +const missingRecorder = async (): Promise => { + throw new Error("A durable Runtime invocation recorder is required before dispatch."); +}; + +/** Fails before dispatch when production wiring omitted the durable ledger. */ +export const MISSING_RUNTIME_INVOCATION_RECORDER: RuntimeInvocationRecorder = { + begin: missingRecorder, + bind: missingRecorder, + finish: missingRecorder, +}; diff --git a/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-router-invocation.test.ts b/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-router-invocation.test.ts index 9ea2ed1c5..e1d215ab8 100644 --- a/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-router-invocation.test.ts +++ b/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-router-invocation.test.ts @@ -9,6 +9,7 @@ import type { RuntimeInvocationStatus, RuntimeSessionBinding, } from "./runtime-invocation-recorder"; +import { runtimeInvocationErrorMessage } from "./runtime-invocation-recorder"; import { RuntimeRouter } from "./runtime-router"; const runtime: AgentRuntime = { @@ -92,6 +93,79 @@ function driver(askWorkflow: NonNullable): Runtime } describe("RuntimeRouter invocation lifecycle", () => { + test("fails before Runtime dispatch when the durable recorder is missing", async () => { + const askWorkflow = vi.fn(async () => ({ content: "must not run" })); + const router = new RuntimeRouter(new RuntimeDriverRegistry([driver(askWorkflow)])); + + await expect(router.askWorkflow(request())).rejects.toThrow(/durable Runtime invocation recorder/i); + expect(askWorkflow).not.toHaveBeenCalled(); + }); + + test("redacts and bounds persisted Runtime errors", () => { + const message = runtimeInvocationErrorMessage( + `Authorization: Bearer private-token\npassword=visible\n${"x".repeat(5_000)}`, + ); + + expect(message).not.toContain("private-token"); + expect(message).not.toContain("password=visible"); + expect(message).toContain("Authorization: [REDACTED]"); + expect(message.length).toBeLessThanOrEqual(4_000); + }); + + test("rejects oversized identifiers reported by an untrusted Runtime", async () => { + const runtimeDriver = driver(async (input) => { + input.reportExecutionReference?.({ sessionId: "s".repeat(1_001) }); + return { content: "must not succeed" }; + }); + const events: string[] = []; + const durableRecorder = recorder(events); + const router = new RuntimeRouter( + new RuntimeDriverRegistry([runtimeDriver]), + durableRecorder, + () => 1_000, + () => "invocation-oversized-reference", + ); + + await expect(router.askWorkflow(request())).rejects.toThrow(/Session identifier.*limit/i); + expect(durableRecorder.bind).not.toHaveBeenCalled(); + expect(durableRecorder.finish).toHaveBeenCalledWith( + "invocation-oversized-reference", + "failed", + 1_000, + expect.stringMatching(/Session identifier.*limit/i), + ); + }); + + test("rejects a Session envelope owned by a different Runtime", async () => { + const runtimeDriver = driver(async () => ({ + content: "must not succeed", + runtimeConversation: { + runtimeId: "claude", + codecVersion: "test", + payload: { native: { sessionId: "claude-session" } }, + }, + })); + const events: string[] = []; + const durableRecorder = recorder(events); + const router = new RuntimeRouter( + new RuntimeDriverRegistry([runtimeDriver]), + durableRecorder, + () => 1_000, + () => "invocation-wrong-runtime", + ); + + await expect(router.askWorkflow(request())).rejects.toThrow( + /codex Runtime reported a Session owned by claude/i, + ); + expect(durableRecorder.bind).not.toHaveBeenCalled(); + expect(durableRecorder.finish).toHaveBeenCalledWith( + "invocation-wrong-runtime", + "failed", + 1_000, + expect.stringMatching(/owned by claude/i), + ); + }); + test("persists pending before dispatch and binds a created Session even when Runtime fails", async () => { const events: string[] = []; const runtimeDriver = driver(async (input) => { @@ -115,6 +189,38 @@ describe("RuntimeRouter invocation lifecycle", () => { ]); }); + test("waits for an observed Session binding before finalizing a failed invocation", async () => { + const binding = deferred(); + const finish = vi.fn(async () => undefined); + const durableRecorder: RuntimeInvocationRecorder = { + begin: vi.fn(async () => undefined), + bind: vi.fn(async () => binding.promise), + finish, + }; + const runtimeDriver = driver(async (input) => { + input.reportExecutionReference?.({ sessionId: "thread-before-failure" }); + throw new Error("turn failed"); + }); + const router = new RuntimeRouter( + new RuntimeDriverRegistry([runtimeDriver]), + durableRecorder, + () => 1_500, + () => "invocation-binding-order", + ); + + const execution = router.askWorkflow(request()); + await vi.waitFor(() => expect(durableRecorder.bind).toHaveBeenCalled()); + expect(finish).not.toHaveBeenCalled(); + binding.resolve(); + await expect(execution).rejects.toThrow("turn failed"); + expect(finish).toHaveBeenCalledWith( + "invocation-binding-order", + "failed", + 1_500, + "turn failed", + ); + }); + test("records another invocation as continued when it resumes the same Session", async () => { const events: string[] = []; const runtimeDriver = driver(async (input) => { @@ -156,12 +262,18 @@ describe("RuntimeRouter invocation lifecycle", () => { () => "invocation-propagated", ); - await router.askWorkflow({ ...request(), environmentId: "ssh-dev", onEvent }); + const response = await router.askWorkflow({ + ...request(), + invocationId: "invocation-propagated", + environmentId: "ssh-dev", + onEvent, + }); expect(onEvent).toHaveBeenCalledWith(expect.objectContaining({ type: "delta", invocationId: "invocation-propagated", })); + expect(response.executionReference).toEqual({ invocationId: "invocation-propagated" }); }); test("binds the native Session created by a channel test", async () => { diff --git a/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-router.ts b/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-router.ts index 0994f9b4f..53f44bdec 100644 --- a/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-router.ts +++ b/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-router.ts @@ -25,12 +25,15 @@ import type { import type { RuntimeCapabilities } from "./runtime-capabilities"; import type { RuntimeStateCodec } from "./runtime-state-codec"; import { - NOOP_RUNTIME_INVOCATION_RECORDER, + MISSING_RUNTIME_INVOCATION_RECORDER, + runtimeInvocationErrorMessage, type RuntimeInvocationRecorder, type RuntimeInvocationStatus, type RuntimeSessionRelation, } from "./runtime-invocation-recorder"; +const MAX_RUNTIME_REFERENCE_CHARACTERS = 1_000; + type RuntimeRequestLike = { runtimeId: AgentId; executionMode: RuntimeExecutionMode; @@ -43,7 +46,7 @@ type RuntimeRequestLike = { export class RuntimeRouter { constructor( private readonly registry: RuntimeDriverRegistry, - private readonly invocationRecorder: RuntimeInvocationRecorder = NOOP_RUNTIME_INVOCATION_RECORDER, + private readonly invocationRecorder: RuntimeInvocationRecorder = MISSING_RUNTIME_INVOCATION_RECORDER, private readonly now: () => number = () => Date.now(), private readonly createInvocationId: () => string = () => randomUUID(), ) {} @@ -123,7 +126,11 @@ export class RuntimeRouter { await executor.start(); await callbackQueue; } catch (error) { - await lifecycle.finish(this.statusForError(error), error); + try { + await callbackQueue; + } finally { + await lifecycle.finish(this.statusForError(error), error); + } throw error; } }, @@ -179,7 +186,11 @@ export class RuntimeRouter { await session.ensureAttached(); await callbackQueue; } catch (error) { - await active.finish(this.statusForError(error), error); + try { + await callbackQueue; + } finally { + await active.finish(this.statusForError(error), error); + } throw error; } }, @@ -190,7 +201,11 @@ export class RuntimeRouter { await callbackQueue; await active.finish("completed"); } catch (error) { - await active.finish(this.statusForError(error), error); + try { + await callbackQueue; + } finally { + await active.finish(this.statusForError(error), error); + } throw error; } }, @@ -206,6 +221,7 @@ export class RuntimeRouter { detach: async (reason) => { try { await session.detach(reason); + await callbackQueue; } finally { if (lifecycle && !lifecycle.isFinished()) { await lifecycle.finish(reason === "error" ? "failed" : "cancelled"); @@ -235,9 +251,10 @@ export class RuntimeRouter { ...normalizedInput, invocationId: lifecycle.id, reportExecutionReference: (reference) => { + const invocationReference = { ...reference, invocationId: lifecycle.id }; enqueue(async () => { - await lifecycle.bindReference(reference); - reportExecutionReference?.(reference); + await lifecycle.bindReference(invocationReference); + reportExecutionReference?.(invocationReference); }); }, onEvent: (event) => { @@ -253,9 +270,21 @@ export class RuntimeRouter { if (response.runtimeConversation) await lifecycle.bindConversation(response.runtimeConversation); if (response.executionReference) await lifecycle.bindReference(response.executionReference); await lifecycle.finish("completed"); - return response; + return normalizedInput.invocationId + ? { + ...response, + executionReference: { + ...response.executionReference, + invocationId: lifecycle.id, + }, + } + : response; } catch (error) { - await lifecycle.finish(this.statusForError(error, normalizedInput.signal), error); + try { + await callbackQueue; + } finally { + await lifecycle.finish(this.statusForError(error, normalizedInput.signal), error); + } throw error; } } @@ -291,7 +320,11 @@ export class RuntimeRouter { await lifecycle.finish("completed"); return result; } catch (error) { - await lifecycle.finish(this.statusForError(error), error); + try { + await callbackQueue; + } finally { + await lifecycle.finish(this.statusForError(error), error); + } throw error; } } @@ -503,12 +536,23 @@ class RuntimeInvocationLifecycle { } async bindConversation(conversation: RuntimeConversation): Promise { + if (conversation.runtimeId !== this.options.runtimeId) { + throw new Error( + `${this.options.runtimeId} Runtime reported a Session owned by ${conversation.runtimeId}.`, + ); + } const sessionId = this.options.sessionIdFromConversation(conversation); if (sessionId) await this.bindReference({ sessionId }); } async bindReference(reference: RuntimeExecutionReference): Promise { if (!reference.sessionId) return; + if (reference.sessionId.length > MAX_RUNTIME_REFERENCE_CHARACTERS) { + throw new Error("Runtime reported a Session identifier that exceeds the supported limit."); + } + if (reference.turnId && reference.turnId.length > MAX_RUNTIME_REFERENCE_CHARACTERS) { + throw new Error("Runtime reported a Turn identifier that exceeds the supported limit."); + } this.hasBinding = true; this.enqueueBinding( { @@ -551,11 +595,7 @@ class RuntimeInvocationLifecycle { this.hasBinding = true; this.enqueueBinding({ sessionId: this.options.continuedSessionId }, "continued"); } - const message = error === undefined - ? undefined - : error instanceof Error - ? error.message - : String(error); + const message = error === undefined ? undefined : runtimeInvocationErrorMessage(error); const pendingWrites = this.writeQueue; this.writeQueue = pendingWrites.catch(() => undefined).then(() => this.options.recorder.finish( this.options.invocationId, diff --git a/apps/main-2.0/src/automation/engine/main/evaluation-runner.ts b/apps/main-2.0/src/automation/engine/main/evaluation-runner.ts index 0bfca60a8..864ca37c6 100644 --- a/apps/main-2.0/src/automation/engine/main/evaluation-runner.ts +++ b/apps/main-2.0/src/automation/engine/main/evaluation-runner.ts @@ -72,8 +72,8 @@ export interface RunEvaluationInput { ) => Promise<{ output: string; durationMs: number }>; /** Reads the SKILL.md bytes and hash of the skill this experiment injects. */ readSkill?: (skillName: string) => Promise<{ content: string; hash: string } | null>; - /** Resolves a runtime session id to the indexed AgentRecall session. */ - resolveSession?: (rawId: string) => Promise<{ sessionKey: string } | null>; + /** Resolves an exact Runtime invocation to the indexed AgentRecall session. */ + resolveSession?: (reference: EvaluationExecutionReference) => Promise<{ sessionKey: string } | null>; readTrajectory?: (sessionKey: string) => Promise; readSessionArtifact?: ( sessionKey: string, diff --git a/apps/main-2.0/src/automation/engine/main/hub/agent-hub-invocation-owner.test.ts b/apps/main-2.0/src/automation/engine/main/hub/agent-hub-invocation-owner.test.ts new file mode 100644 index 000000000..e162bd1b0 --- /dev/null +++ b/apps/main-2.0/src/automation/engine/main/hub/agent-hub-invocation-owner.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { AgentRuntime } from "../../shared/types"; +import { buildInteractiveChatContext } from "./chat/agent-hub-interactive"; +import type { AgentExecutionContext, AgentExecutorFactory } from "./runtime/executor/agent-executor"; +import { runAgentExecution } from "./runtime/run/agent-hub-runner"; +import { ChatState, TaskState } from "./state/agent-hub-state"; + +const runtime: AgentRuntime = { + id: "codex", + label: "Codex", + command: "codex", + version: "test", + available: true, +}; + +describe("Agent Runtime invocation ownership", () => { + it("keeps the configured Agent id on interactive chat invocations", () => { + const chat = new ChatState("agent-1", "model-1"); + chat.id = "chat-1"; + + const context = buildInteractiveChatContext({ + chat, + resolved: { + runtimeAgentId: "codex", + modelId: "model-1", + runtime, + channel: { id: "codex-default" }, + }, + workDir: "/workspace", + developerInstructions: "", + selectExecutionMode: () => "interactive", + defaultContinuationPolicy: () => "fresh", + cloneConversationForPolicy: () => undefined, + emit: () => undefined, + syncState: () => undefined, + }); + + expect(context.invocation).toEqual({ + surface: "agent", + role: "chat", + ownerReference: { chatId: "chat-1", agentId: "agent-1" }, + }); + }); + + it("keeps the configured Agent id on one-shot task invocations", async () => { + const task = new TaskState("Run it", "agent-1", "model-1", "/workspace"); + task.id = "task-1"; + const create = vi.fn((_context: AgentExecutionContext) => ({ + start: async () => undefined, + stop: async () => undefined, + })); + const executorFactory: AgentExecutorFactory = { create }; + + await runAgentExecution({ + run: task, + prompt: "Run it", + resolved: { + agent: { + id: "agent-1", + name: "Agent One", + description: "", + runtimeAgentId: "codex", + modelId: "model-1", + channelId: "codex-default", + tags: [], + createdAt: 1, + updatedAt: 1, + }, + runtimeAgentId: "codex", + channel: { id: "codex-default" }, + modelId: "model-1", + runtime, + }, + workDir: "/workspace", + chatDeveloperInstructions: "", + taskDeveloperInstructions: "", + executorFactory, + selectExecutionMode: () => "oneshot", + defaultContinuationPolicy: () => "fresh", + cloneConversationForPolicy: () => undefined, + handleAgentEvent: () => undefined, + markRunExited: () => undefined, + markRunFailed: () => undefined, + registerStop: () => undefined, + clearStop: () => undefined, + emit: () => undefined, + }); + + expect(create).toHaveBeenCalledWith(expect.objectContaining({ + invocation: { + surface: "agent", + role: "task", + ownerReference: { taskId: "task-1", agentId: "agent-1" }, + }, + })); + }); +}); diff --git a/apps/main-2.0/src/automation/engine/main/hub/agent-hub.ts b/apps/main-2.0/src/automation/engine/main/hub/agent-hub.ts index 81a4ae298..bf5378ff5 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/agent-hub.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/agent-hub.ts @@ -101,7 +101,11 @@ import { InteractiveSessionManager } from "../agents/runtime/interactive-session import type { CodexRpcClient } from "../agents/codex/codex-rpc"; import type { RuntimeCapabilities } from "../agents/runtime/runtime-capabilities"; import type { InteractiveSessionContext, InteractiveSessionSnapshot, RuntimeDriverRegistry, RuntimeSurface } from "../agents/runtime/runtime-driver"; -import type { RuntimeInvocationRecorder } from "../agents/runtime/runtime-invocation-recorder"; +import { + MISSING_RUNTIME_INVOCATION_RECORDER, + NOOP_RUNTIME_INVOCATION_RECORDER, + type RuntimeInvocationRecorder, +} from "../agents/runtime/runtime-invocation-recorder"; import { RuntimeRouter } from "../agents/runtime/runtime-router"; import { createRuntimeDriverRegistry, RuntimeAgentExecutorFactory, type AgentExecutorFactory } from "./runtime/executor/agent-executor"; import { queryProviderBalance, type ProviderBalanceQueryOptions } from "../channels/provider-balance"; @@ -439,7 +443,12 @@ export class AgentHub { mcpServersForAgent: (configuredAgentId, allowedMcpTools) => this.boundMcpServersForAgent(configuredAgentId, allowedMcpTools), requestApproval: this.runtimeApprovals.request, }); - this.runtimeRouter = new RuntimeRouter(this.runtimeDrivers, runtimeInvocationRecorder); + // Unit-level AgentHub fixtures intentionally have no PostgreSQL owner. The + // application always supplies its repository; any other omission fails + // before a Runtime process starts instead of silently dropping the ledger. + const recorder = runtimeInvocationRecorder + ?? (process.env.VITEST ? NOOP_RUNTIME_INVOCATION_RECORDER : MISSING_RUNTIME_INVOCATION_RECORDER); + this.runtimeRouter = new RuntimeRouter(this.runtimeDrivers, recorder); this.workflowStore = new WorkflowStore({ normalizeDraft: (draft) => this.cloneWorkflowDraft(draft), now: () => Date.now(), diff --git a/apps/main-2.0/src/automation/engine/main/hub/chat/agent-hub-interactive.ts b/apps/main-2.0/src/automation/engine/main/hub/chat/agent-hub-interactive.ts index 21a85e65c..91105436d 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/chat/agent-hub-interactive.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/chat/agent-hub-interactive.ts @@ -90,7 +90,10 @@ export function buildInteractiveChatContext(input: { invocation: { surface: "agent", role: "chat", - ownerReference: { chatId: input.chat.id }, + ownerReference: { + chatId: input.chat.id, + agentId: input.chat.configuredAgentId, + }, }, ...(runtimeConversation ? { runtimeConversation } : {}), runtime: input.resolved.runtime as AgentRuntime, diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/codex/codex-test.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/codex/codex-test.ts index 9ad95e335..ac05e0191 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/codex/codex-test.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/codex/codex-test.ts @@ -134,7 +134,10 @@ export async function runCodexChannelTest( timeoutMs: RUNTIME_CHANNEL_TEST_TIMEOUT_MS, onStdoutLine: (line) => { const sessionId = extractCodexSessionId(line); - if (sessionId) sessionIds.add(sessionId); + if (sessionId && !sessionIds.has(sessionId)) { + sessionIds.add(sessionId); + input.reportExecutionReference?.({ sessionId }); + } const eventOutput = handleCodexTestLine(line, input.emit); if (eventOutput) output += eventOutput; }, diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/codex/codex-workflow.test.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/codex/codex-workflow.test.ts new file mode 100644 index 000000000..18dc2ff74 --- /dev/null +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/codex/codex-workflow.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test, vi } from "vitest"; + +import type { RuntimeWorkflowRequestContext } from "../../../../agents/runtime/runtime-driver"; +import type { RuntimeWorkflowExecutionOptions } from "../workflow/agent-executor-workflow-shared"; + +vi.mock("../../../../agents/codex/codex-rpc", () => ({ + CodexRpcClient: class { + async start(): Promise {} + async request(method: string): Promise { + if (method === "thread/start") return { thread: { id: "thread-created-before-turn" } }; + if (method === "turn/start") throw new Error("turn/start failed"); + return {}; + } + respond(): void {} + async shutdown(): Promise {} + }, +})); + +import { runCodexWorkflow } from "./codex-workflow"; + +describe("Codex Workflow native Session binding", () => { + test("reports the thread before turn/start can fail", async () => { + const reportExecutionReference = vi.fn(); + const input: RuntimeWorkflowRequestContext = { + requestId: "request-1", + prompt: "Review", + configuredAgentId: "agent-1", + runtimeId: "codex", + executionMode: "oneshot", + continuationPolicy: "fresh", + runtimeConfig: { model: "default" }, + invocation: { + surface: "evaluation", + role: "subject", + ownerReference: { runId: "run-1", caseId: "case-1" }, + }, + runtime: { + id: "codex", + label: "Codex", + command: "codex", + version: "test", + available: true, + }, + channelId: "codex-default", + workDir: "/workspace", + reportExecutionReference, + }; + const options: RuntimeWorkflowExecutionOptions = { + executables: { + api: "", + codex: "codex", + claude: "", + dsh: "", + opencode: "", + openclaw: "", + hermes: "", + }, + channelById: () => undefined, + }; + + await expect(runCodexWorkflow(input, options)).rejects.toThrow("turn/start failed"); + expect(reportExecutionReference).toHaveBeenCalledWith({ + sessionId: "thread-created-before-turn", + }); + }); +}); diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/codex/codex-workflow.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/codex/codex-workflow.ts index 7bd886b6a..736f29ab4 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/codex/codex-workflow.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/codex/codex-workflow.ts @@ -177,6 +177,8 @@ export async function runCodexWorkflow( runtimeConversation = codexRuntimeStateCodec.encodeConversation({ native: { threadId }, }); + executionReference = { sessionId: threadId }; + input.reportExecutionReference?.(executionReference); } const turnResult = await client.request("turn/start", { threadId, diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/dsh/dsh-capabilities.test.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/dsh/dsh-capabilities.test.ts index 21868dab5..59f89e746 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/dsh/dsh-capabilities.test.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/dsh/dsh-capabilities.test.ts @@ -4,6 +4,7 @@ import { type InteractiveSessionContext, } from "../../../../agents/runtime/runtime-driver"; import { RuntimeRouter } from "../../../../agents/runtime/runtime-router"; +import { NOOP_RUNTIME_INVOCATION_RECORDER } from "../../../../agents/runtime/runtime-invocation-recorder"; import type { AgentExecutionContext } from "../agent-executor-types"; import type { RuntimeAgentExecutorFactoryOptions } from "../agent-executor-types"; import { createDshDriver } from "./create-dsh-driver"; @@ -70,7 +71,10 @@ describe("DSH runtime driver", () => { executables: { dsh: "dsh" } as RuntimeAgentExecutorFactoryOptions["executables"], channelById: () => undefined, }; - const router = new RuntimeRouter(new RuntimeDriverRegistry([createDshDriver(options)])); + const router = new RuntimeRouter( + new RuntimeDriverRegistry([createDshDriver(options)]), + NOOP_RUNTIME_INVOCATION_RECORDER, + ); const context: AgentExecutionContext = { runId: "task-1", runKind: "task", diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/runtime-onboarding-contract.test.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/runtime-onboarding-contract.test.ts index 85c30d4fb..c56202f47 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/runtime-onboarding-contract.test.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/executor/runtime-onboarding-contract.test.ts @@ -8,6 +8,7 @@ import type { } from "../../../agents/runtime/runtime-driver"; import { RuntimeDriverRegistry } from "../../../agents/runtime/runtime-driver"; import { RuntimeRouter } from "../../../agents/runtime/runtime-router"; +import { NOOP_RUNTIME_INVOCATION_RECORDER } from "../../../agents/runtime/runtime-invocation-recorder"; import { support } from "./agent-executor-capabilities"; import { createOneShotRuntimeDriver } from "./agent-executor-driver-factories"; @@ -120,7 +121,7 @@ describe("runtime onboarding contract", () => { deleteSessionArtifacts: undefined, }); const registry = new RuntimeDriverRegistry([driver]); - const router = new RuntimeRouter(registry); + const router = new RuntimeRouter(registry, NOOP_RUNTIME_INVOCATION_RECORDER); const runtimeConversation = { runtimeId: "api", codecVersion: "v1", diff --git a/apps/main-2.0/src/automation/engine/main/hub/runtime/run/agent-hub-runner.ts b/apps/main-2.0/src/automation/engine/main/hub/runtime/run/agent-hub-runner.ts index db19ce885..6a8f315ba 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/runtime/run/agent-hub-runner.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/runtime/run/agent-hub-runner.ts @@ -93,6 +93,7 @@ export async function runAgentExecution(input: { role: input.run.kind, ownerReference: { ...(input.run.kind === "chat" ? { chatId: input.run.id } : { taskId: input.run.id }), + agentId: input.resolved.agent.id, ...(input.run.kind === "task" && input.run.planningWorkflowId ? { workflowId: input.run.planningWorkflowId } : {}), diff --git a/apps/main-2.0/src/automation/engine/main/platform/configured-agent-execution-service.test.ts b/apps/main-2.0/src/automation/engine/main/platform/configured-agent-execution-service.test.ts index 9f8be0137..f50939dc4 100644 --- a/apps/main-2.0/src/automation/engine/main/platform/configured-agent-execution-service.test.ts +++ b/apps/main-2.0/src/automation/engine/main/platform/configured-agent-execution-service.test.ts @@ -43,6 +43,7 @@ describe("ConfiguredAgentExecutionService", () => { }); expect(execute).toHaveBeenCalledWith(expect.objectContaining({ + invocationId: expect.any(String), planningWorkflowId: "workflow", workflowRunId: "run", workflowNodeId: "review", diff --git a/apps/main-2.0/src/automation/engine/main/platform/configured-agent-execution-service.ts b/apps/main-2.0/src/automation/engine/main/platform/configured-agent-execution-service.ts index e36ba802d..3ebcb0fec 100644 --- a/apps/main-2.0/src/automation/engine/main/platform/configured-agent-execution-service.ts +++ b/apps/main-2.0/src/automation/engine/main/platform/configured-agent-execution-service.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import type { AgentRecallMcpContext, AgentChannel, @@ -121,6 +122,7 @@ export class ConfiguredAgentExecutionService { ? structuredClone(input.runtimeConversation) : undefined; const request: WorkflowAgentRequest = { + invocationId: randomUUID(), configuredAgentId: input.configuredAgentId, prompt: input.prompt, runtimeId: target.runtimeId, diff --git a/apps/main-2.0/src/automation/engine/shared/types.ts b/apps/main-2.0/src/automation/engine/shared/types.ts index 81b7dcd23..f39ce3f35 100644 --- a/apps/main-2.0/src/automation/engine/shared/types.ts +++ b/apps/main-2.0/src/automation/engine/shared/types.ts @@ -552,6 +552,8 @@ export interface WorkflowAgentResponse { } export interface RuntimeExecutionReference { + /** AgentRecall ledger row that owns this native reference. */ + invocationId?: string; sessionId?: string; turnId?: string; } diff --git a/apps/main-2.0/src/core/evaluation/nodes/contracts.ts b/apps/main-2.0/src/core/evaluation/nodes/contracts.ts index 55635d43c..72f1f1ef6 100644 --- a/apps/main-2.0/src/core/evaluation/nodes/contracts.ts +++ b/apps/main-2.0/src/core/evaluation/nodes/contracts.ts @@ -92,6 +92,7 @@ export interface EvaluationArtifactValue { /** Runtime-native ids a fresh run reports, used to find its session. */ export interface EvaluationExecutionReference { + invocationId?: string; sessionId?: string; turnId?: string; } @@ -190,8 +191,8 @@ export interface EvaluationNodeDependencies { }, signal?: AbortSignal, ) => Promise<{ output: string; durationMs: number }>; - /** Resolves a runtime-native session id to an indexed AgentRecall session. */ - resolveSession?: (rawId: string) => Promise<{ sessionKey: string } | null>; + /** Resolves an exact Runtime invocation to an indexed AgentRecall session. */ + resolveSession?: (reference: EvaluationExecutionReference) => Promise<{ sessionKey: string } | null>; /** Reads an indexed session's trajectory. */ readTrajectory?: (sessionKey: string) => Promise; /** Reads a session's final answer, for evaluating a session that already exists. */ diff --git a/apps/main-2.0/src/core/evaluation/nodes/prepare-nodes.ts b/apps/main-2.0/src/core/evaluation/nodes/prepare-nodes.ts index a8c8f5cc3..8bad96767 100644 --- a/apps/main-2.0/src/core/evaluation/nodes/prepare-nodes.ts +++ b/apps/main-2.0/src/core/evaluation/nodes/prepare-nodes.ts @@ -206,7 +206,7 @@ export function createSessionLinkNode( inputs: { execution_ref: EXECUTION_REF_PORT }, outputs: { trajectory: TRAJECTORY_PORT }, async run(context) { - const rawId = context.in.execution_ref.sessionId?.trim(); + const rawId = context.in.execution_ref.sessionId; if (!rawId) return evaluationExcused.infra("runtime_reported_no_session"); if (!dependencies.resolveSession || !dependencies.readTrajectory) { return evaluationExcused.infra("session_lookup_unavailable", { facts: { rawId } }); @@ -219,7 +219,7 @@ export function createSessionLinkNode( facts: { rawId, attempt }, }); } - const session = await dependencies.resolveSession(rawId); + const session = await dependencies.resolveSession(context.in.execution_ref); if (session) { const trajectory = await dependencies.readTrajectory(session.sessionKey); if (!trajectory) { diff --git a/apps/main-2.0/src/core/evaluation/run.test.ts b/apps/main-2.0/src/core/evaluation/run.test.ts index 39b28b9dd..3b508b7a5 100644 --- a/apps/main-2.0/src/core/evaluation/run.test.ts +++ b/apps/main-2.0/src/core/evaluation/run.test.ts @@ -189,11 +189,11 @@ describe("evaluation run", () => { durationMs: 5, executionReference: { sessionId: "thread-9" }, }), - resolveSession: async (rawId) => { + resolveSession: async (reference) => { attempts += 1; return attempts < 3 ? null - : { sessionKey: `claude:${rawId}`, source: "claude", rawId }; + : { sessionKey: `claude:${reference.sessionId}` }; }, readTrajectory: async () => trajectory(), wait: async (ms) => { @@ -223,7 +223,7 @@ describe("evaluation run", () => { durationMs: 5, executionReference: { sessionId: "thread-9" }, }), - resolveSession: async (rawId) => ({ sessionKey: `claude:${rawId}` }), + resolveSession: async (reference) => ({ sessionKey: `claude:${reference.sessionId}` }), readTrajectory: async () => trajectory(), readArtifactFiles: async () => [{ path: "src/a.ts", status: "added" }], }), @@ -249,7 +249,7 @@ describe("evaluation run", () => { durationMs: 5, executionReference: { sessionId: "thread-9" }, }), - resolveSession: async (rawId) => ({ sessionKey: `claude:${rawId}` }), + resolveSession: async (reference) => ({ sessionKey: `claude:${reference.sessionId}` }), readTrajectory: async () => trajectory(), readArtifactFiles: async () => { throw new Error("trace unavailable"); diff --git a/apps/main-2.0/src/core/postgres/runtime-invocation-repository.ts b/apps/main-2.0/src/core/postgres/runtime-invocation-repository.ts index 8cf0bee02..9ccf057ba 100644 --- a/apps/main-2.0/src/core/postgres/runtime-invocation-repository.ts +++ b/apps/main-2.0/src/core/postgres/runtime-invocation-repository.ts @@ -4,6 +4,7 @@ import type { RuntimeInvocationStatus, RuntimeSessionBinding, } from "../../automation/engine/main/agents/runtime/runtime-invocation-recorder"; +import { runtimeInvocationErrorMessage } from "../../automation/engine/main/agents/runtime/runtime-invocation-recorder"; import type { PostgresDatabase } from "./database"; import { postgresJsonValue, postgresText } from "./session-records"; @@ -75,7 +76,7 @@ export class PostgresRuntimeInvocationRepository implements RuntimeInvocationRec postgresText(invocationId), status, new Date(finishedAt).toISOString(), - error ? postgresText(error) : null, + error ? postgresText(runtimeInvocationErrorMessage(error)) : null, ], ); } diff --git a/apps/main-2.0/src/core/postgres/schema.test.ts b/apps/main-2.0/src/core/postgres/schema.test.ts index d3ac865ef..4f6bfec1a 100644 --- a/apps/main-2.0/src/core/postgres/schema.test.ts +++ b/apps/main-2.0/src/core/postgres/schema.test.ts @@ -1054,15 +1054,27 @@ describe("AgentRecall PostgreSQL schema", () => { insert into agent_recall.evaluation_experiments ( id, name, dataset_id, agent_id, repetitions, created_at, updated_at ) values ('experiment-history', 'history', 'dataset-history', 'agent', 1, now(), now()); + insert into agent_recall.evaluation_experiments ( + id, name, dataset_id, agent_id, repetitions, source, created_at, updated_at + ) values ('experiment-existing-session', 'existing', 'dataset-history', 'agent', 1, 'session', now(), now()); insert into agent_recall.evaluation_runs ( id, experiment_id, status, started_at, finished_at ) values ('run-history', 'experiment-history', 'completed', now(), now()); + insert into agent_recall.evaluation_runs ( + id, experiment_id, status, started_at, finished_at + ) values ('run-existing-session', 'experiment-existing-session', 'completed', now(), now()); insert into agent_recall.evaluation_case_results ( id, run_id, dataset_item_id, repetition, input, output, duration_ms, session_key ) values ( 'case-history', 'run-history', 'item', 1, 'input', 'output', 1, 'codex:historical-runtime' ); + insert into agent_recall.evaluation_case_results ( + id, run_id, dataset_item_id, repetition, input, output, duration_ms, session_key + ) values ( + 'case-existing-session', 'run-existing-session', 'item', 1, 'input', 'output', 1, + 'codex:historical-runtime' + ); `); const upgradedDatabase = new PostgresDatabase(pool, { @@ -1072,11 +1084,12 @@ describe("AgentRecall PostgreSQL schema", () => { await upgradedDatabase.initialize(); const linked = await upgradedDatabase.query<{ surface: string; + owner_reference: Record; runtime_id: string; runtime_session_id: string; relation: string; }>(` - select invocations.surface, bindings.runtime_id, + select invocations.surface, invocations.owner_reference, bindings.runtime_id, bindings.runtime_session_id, bindings.relation from agent_recall.runtime_invocations invocations join agent_recall.runtime_session_bindings bindings @@ -1085,10 +1098,21 @@ describe("AgentRecall PostgreSQL schema", () => { `); expect(linked.rows).toEqual([{ surface: "evaluation", + owner_reference: { + experimentId: "experiment-history", + runId: "run-history", + caseId: "case-history", + }, runtime_id: "codex", runtime_session_id: "historical-runtime", relation: "created", }]); + const existingSessionBackfill = await upgradedDatabase.query<{ count: string }>(` + select count(*)::text as count + from agent_recall.runtime_invocations + where id = 'legacy-evaluation:case-existing-session' + `); + expect(existingSessionBackfill.rows[0]?.count).toBe("0"); await upgradedDatabase.query(` insert into agent_recall.runtime_invocations ( diff --git a/apps/main-2.0/src/core/postgres/schema.ts b/apps/main-2.0/src/core/postgres/schema.ts index 3b5f507d5..b5645520a 100644 --- a/apps/main-2.0/src/core/postgres/schema.ts +++ b/apps/main-2.0/src/core/postgres/schema.ts @@ -1872,8 +1872,10 @@ export const POSTGRES_MIGRATIONS: readonly PostgresMigration[] = [{ case_results.error FROM agent_recall.evaluation_case_results case_results JOIN agent_recall.evaluation_runs runs ON runs.id = case_results.run_id + JOIN agent_recall.evaluation_experiments experiments ON experiments.id = runs.experiment_id JOIN agent_recall.sessions sessions ON sessions.session_key = case_results.session_key WHERE case_results.session_key IS NOT NULL + AND coalesce(experiments.source, 'run_agent') = 'run_agent' ) INSERT INTO agent_recall.runtime_invocations ( id, initiator, surface, role, owner_reference, runtime_id, @@ -1887,14 +1889,14 @@ export const POSTGRES_MIGRATIONS: readonly PostgresMigration[] = [{ jsonb_build_object( 'experimentId', experiment_id, 'runId', run_id, - 'caseResultId', case_result_id + 'caseId', case_result_id ), runtime_id, environment_id, CASE WHEN error IS NULL THEN 'completed' ELSE 'failed' END, started_at, coalesce(finished_at, started_at), - error + left(error, 4000) FROM linked_evaluations WHERE runtime_id IS NOT NULL ON CONFLICT (id) DO NOTHING; @@ -1916,8 +1918,10 @@ export const POSTGRES_MIGRATIONS: readonly PostgresMigration[] = [{ coalesce(runs.finished_at, runs.started_at) AS bound_at FROM agent_recall.evaluation_case_results case_results JOIN agent_recall.evaluation_runs runs ON runs.id = case_results.run_id + JOIN agent_recall.evaluation_experiments experiments ON experiments.id = runs.experiment_id JOIN agent_recall.sessions sessions ON sessions.session_key = case_results.session_key WHERE case_results.session_key IS NOT NULL + AND coalesce(experiments.source, 'run_agent') = 'run_agent' ) INSERT INTO agent_recall.runtime_session_bindings ( invocation_id, runtime_id, environment_id, runtime_session_id, relation, bound_at @@ -1943,7 +1947,7 @@ export const POSTGRES_MIGRATIONS: readonly PostgresMigration[] = [{ dispatches.task_id, room_agents.channel_id, row_number() OVER ( - PARTITION BY execution_attempts.runtime_id, execution_attempts.runtime_session_ref + PARTITION BY execution_attempts.runtime_id, coalesce(room_agents.channel_id, ''), execution_attempts.runtime_session_ref ORDER BY execution_attempts.started_at, execution_attempts.id ) AS session_sequence FROM agent_recall.chat_dispatch_attempts execution_attempts @@ -1980,7 +1984,7 @@ export const POSTGRES_MIGRATIONS: readonly PostgresMigration[] = [{ END, started_at, coalesce(finished_at, started_at), - error + left(error, 4000) FROM attempts ON CONFLICT (id) DO NOTHING; @@ -1989,7 +1993,7 @@ export const POSTGRES_MIGRATIONS: readonly PostgresMigration[] = [{ execution_attempts.*, room_agents.channel_id, row_number() OVER ( - PARTITION BY execution_attempts.runtime_id, execution_attempts.runtime_session_ref + PARTITION BY execution_attempts.runtime_id, coalesce(room_agents.channel_id, ''), execution_attempts.runtime_session_ref ORDER BY execution_attempts.started_at, execution_attempts.id ) AS session_sequence FROM agent_recall.chat_dispatch_attempts execution_attempts diff --git a/apps/main-2.0/src/core/postgres/session-records.ts b/apps/main-2.0/src/core/postgres/session-records.ts index da97c193a..99a9263d9 100644 --- a/apps/main-2.0/src/core/postgres/session-records.ts +++ b/apps/main-2.0/src/core/postgres/session-records.ts @@ -1,8 +1,9 @@ import { cleanTitle } from "../format-adapters"; import type { EnvironmentKind, - SessionSearchResult, RuntimeInvocationSummary, + SessionOriginFilter, + SessionSearchResult, SessionSource, SessionTurnMatch, SessionTurnStatus, @@ -188,7 +189,17 @@ export const AGENTRECALL_CREATED_SESSION_SQL = ` ) `; -export const SESSION_SELECT_SQL = ` +export function sessionOriginPredicate(origin: SessionOriginFilter | undefined): string | undefined { + if (origin === "agentrecall") return AGENTRECALL_CREATED_SESSION_SQL; + if (origin === "ordinary") return `not (${AGENTRECALL_CREATED_SESSION_SQL})`; + return undefined; +} + +function sessionSelectSql(runtimeInvocationLimit?: number): string { + const runtimeInvocationLimitSql = runtimeInvocationLimit === undefined + ? "" + : `limit ${runtimeInvocationLimit}`; + return ` sessions.*, coalesce( ( @@ -222,38 +233,50 @@ export const SESSION_SELECT_SQL = ` where session_tags.session_key = sessions.session_key ), array[]::text[] - ) as tag_names - ,${AGENTRECALL_CREATED_SESSION_SQL} as created_by_agent_recall - ,coalesce( + ) as tag_names, + ${AGENTRECALL_CREATED_SESSION_SQL} as created_by_agent_recall, + coalesce( ( select jsonb_agg( - jsonb_build_object( - 'invocationId', invocations.id, - 'surface', invocations.surface, - 'role', invocations.role, - 'ownerReference', invocations.owner_reference, - 'runtimeId', bindings.runtime_id, - 'channelId', nullif(bindings.channel_id, ''), - 'environmentId', bindings.environment_id, - 'status', invocations.status, - 'startedAt', extract(epoch from invocations.started_at) * 1000, - 'finishedAt', case when invocations.finished_at is null then null - else extract(epoch from invocations.finished_at) * 1000 end, - 'error', invocations.error, - 'relation', bindings.relation, - 'runtimeSessionId', bindings.runtime_session_id, - 'runtimeTurnId', bindings.runtime_turn_id - ) order by invocations.started_at desc, invocations.id desc + history.payload order by history.started_at desc, history.invocation_id desc ) - from agent_recall.runtime_session_bindings bindings - join agent_recall.runtime_invocations invocations - on invocations.id = bindings.invocation_id - where ${RUNTIME_SESSION_BINDING_MATCH_SQL} - and invocations.initiator = 'agentrecall' + from ( + select + invocations.started_at, + invocations.id as invocation_id, + jsonb_build_object( + 'invocationId', invocations.id, + 'surface', invocations.surface, + 'role', invocations.role, + 'ownerReference', invocations.owner_reference, + 'runtimeId', bindings.runtime_id, + 'channelId', nullif(bindings.channel_id, ''), + 'environmentId', bindings.environment_id, + 'status', invocations.status, + 'startedAt', extract(epoch from invocations.started_at) * 1000, + 'finishedAt', case when invocations.finished_at is null then null + else extract(epoch from invocations.finished_at) * 1000 end, + 'relation', bindings.relation, + 'runtimeSessionId', bindings.runtime_session_id, + 'runtimeTurnId', bindings.runtime_turn_id + ) as payload + from agent_recall.runtime_session_bindings bindings + join agent_recall.runtime_invocations invocations + on invocations.id = bindings.invocation_id + where ${RUNTIME_SESSION_BINDING_MATCH_SQL} + and invocations.initiator = 'agentrecall' + order by invocations.started_at desc, invocations.id desc + ${runtimeInvocationLimitSql} + ) history ), '[]'::jsonb ) as runtime_invocations `; +} + +/** List projection keeps navigation payload bounded; detail loading restores full history. */ +export const SESSION_SELECT_SQL = sessionSelectSql(20); +export const SESSION_DETAIL_SELECT_SQL = sessionSelectSql(); export function numberValue(value: unknown): number { if (typeof value === "number") return Number.isFinite(value) ? value : 0; @@ -575,7 +598,6 @@ function runtimeInvocationSummaries(value: unknown): RuntimeInvocationSummary[] finishedAt: record.finishedAt === null || record.finishedAt === undefined ? null : numberValue(record.finishedAt), - error: typeof record.error === "string" ? record.error : null, relation: record.relation, runtimeSessionId: record.runtimeSessionId, runtimeTurnId: typeof record.runtimeTurnId === "string" ? record.runtimeTurnId : null, diff --git a/apps/main-2.0/src/core/postgres/session-repository.test.ts b/apps/main-2.0/src/core/postgres/session-repository.test.ts index fd90e519d..a38425873 100644 --- a/apps/main-2.0/src/core/postgres/session-repository.test.ts +++ b/apps/main-2.0/src/core/postgres/session-repository.test.ts @@ -9,6 +9,7 @@ import type { } from "../types"; import { PostgresDatabase } from "./database"; import { PostgresMetadataRepository } from "./metadata-repository"; +import { PostgresRuntimeInvocationRepository } from "./runtime-invocation-repository"; import { PostgresSessionRepository } from "./session-repository"; import { PostgresSessionStatsRepository } from "./session-stats-repository"; import { PostgresSessionTurnRepository } from "./session-turn-repository"; @@ -798,6 +799,23 @@ describe("PostgresSessionRepository", () => { { index: 1, timestamp: Date.parse("2026-07-20T08:00:01.000Z") }, ], ); + const invocations = new PostgresRuntimeInvocationRepository(database); + await invocations.begin({ + id: "inv-stats", + initiator: "agentrecall", + invocation: { surface: "evaluation", ownerReference: { runId: "run-stats" } }, + runtimeId: "codex", + environmentId: "local", + startedAt: Date.parse("2026-07-20T08:00:00.000Z"), + }); + await invocations.bind("inv-stats", { + runtimeId: "codex", + environmentId: "local", + sessionId: "session-a", + relation: "created", + boundAt: Date.parse("2026-07-20T08:00:01.000Z"), + }); + await invocations.finish("inv-stats", "completed", Date.parse("2026-07-20T08:00:02.000Z")); const stats = await statsRepository.getStats( { period: "allTime" }, @@ -819,6 +837,10 @@ describe("PostgresSessionRepository", () => { ]); expect(stats.dailyTokenUsage).toHaveLength(7); expect(stats.dailyTokenUsage.reduce((sum, day) => sum + day.totalTokens, 0)).toBe(175); + await expect(statsRepository.getStats({ period: "allTime", origin: "ordinary" })) + .resolves.toMatchObject({ total: { sessionCount: 1, messageCount: 2 } }); + await expect(statsRepository.getStats({ period: "allTime", origin: "agentrecall" })) + .resolves.toMatchObject({ total: { sessionCount: 1, messageCount: messages.length } }); }); it("compares the previous period and returns a trimmed Token trend", async () => { diff --git a/apps/main-2.0/src/core/postgres/session-repository.ts b/apps/main-2.0/src/core/postgres/session-repository.ts index 377921585..76327ff76 100644 --- a/apps/main-2.0/src/core/postgres/session-repository.ts +++ b/apps/main-2.0/src/core/postgres/session-repository.ts @@ -6,6 +6,7 @@ import type { ProjectSummary, ProjectTagEntry, RuntimeInvocationSessionResolution, + RuntimeInvocationLookup, RuntimeInvocationSummary, SessionMessage, SessionMessageEvent, @@ -32,7 +33,9 @@ import { import type { PostgresDatabase, PostgresQueryable } from "./database"; import { SESSION_ACTIVITY_SQL, + SESSION_DETAIL_SELECT_SQL, SESSION_SELECT_SQL, + sessionOriginPredicate, RUNTIME_SESSION_BINDING_MATCH_SQL, hydrateSession, numberValue, @@ -1453,6 +1456,8 @@ export class PostgresSessionRepository { const values: unknown[] = []; const conditions = ["trim(sessions.project_path) <> ''"]; if (options.excludeSubagents) conditions.push("sessions.is_subagent = false"); + const originPredicate = sessionOriginPredicate(options.origin); + if (originPredicate) conditions.push(originPredicate); if (options.environmentId && options.environmentId !== "all") { values.push(options.environmentId); conditions.push(`sessions.environment_id = $${values.length}`); @@ -1889,7 +1894,7 @@ export class PostgresSessionRepository { async getSession(sessionKey: string): Promise { const result = await this.database.query( ` - select ${SESSION_SELECT_SQL} + select ${SESSION_DETAIL_SELECT_SQL} from agent_recall.sessions sessions join agent_recall.environments environments on environments.id = sessions.environment_id where sessions.session_key = $1 @@ -1916,9 +1921,23 @@ export class PostgresSessionRepository { /** Resolves an exact invocation owner without conflating missing bindings with indexing delay. */ async resolveRuntimeInvocationSession( - ownerReference: Record, + lookup: RuntimeInvocationLookup, ): Promise { - if (Object.keys(ownerReference).length === 0) return { status: "not_recorded" }; + if (!lookup.invocationId && Object.keys(lookup.ownerReference ?? {}).length === 0) { + return { status: "not_recorded" }; + } + const conditions = ["initiator = 'agentrecall'"]; + const parameters: unknown[] = []; + const addCondition = (condition: string, value: unknown): void => { + parameters.push(value); + conditions.push(condition.replace("?", `$${parameters.length}`)); + }; + if (lookup.invocationId) addCondition("id = ?", postgresText(lookup.invocationId)); + if (lookup.surface) addCondition("surface = ?", lookup.surface); + if (lookup.role) addCondition("role = ?", postgresText(lookup.role)); + if (lookup.ownerReference && Object.keys(lookup.ownerReference).length > 0) { + addCondition("owner_reference @> ?::jsonb", postgresJsonValue(lookup.ownerReference)); + } const invocation = (await this.database.query<{ id: string; status: RuntimeInvocationSummary["status"]; @@ -1926,12 +1945,11 @@ export class PostgresSessionRepository { ` select id, status from agent_recall.runtime_invocations - where initiator = 'agentrecall' - and owner_reference @> $1::jsonb + where ${conditions.join("\n and ")} order by started_at desc, id desc limit 1 `, - [postgresJsonValue(ownerReference)], + parameters, )).rows[0]; if (!invocation) return { status: "not_recorded" }; const result = await this.database.query( diff --git a/apps/main-2.0/src/core/postgres/session-search.test.ts b/apps/main-2.0/src/core/postgres/session-search.test.ts index 665af0929..9aa0844f1 100644 --- a/apps/main-2.0/src/core/postgres/session-search.test.ts +++ b/apps/main-2.0/src/core/postgres/session-search.test.ts @@ -327,15 +327,24 @@ describe("PostgreSQL Turn search", () => { }); expect(evaluationSessions.sessions.map((item) => item.sessionKey)).toEqual(["codex:one"]); expect(evaluationSessions.originCounts).toEqual({ ordinary: 2, agentRecall: 1, all: 3 }); + await expect(repository.listProjects({ origin: "ordinary", excludeSubagents: true })) + .resolves.toEqual([expect.objectContaining({ sessionCount: 2 })]); + await expect(repository.listProjects({ origin: "agentrecall", excludeSubagents: true })) + .resolves.toEqual([expect.objectContaining({ sessionCount: 1 })]); const workflowSessions = await searchRepository.searchSessionPage({ origin: "agentrecall", invocationSurface: "workflow", excludeSubagents: true, }); expect(workflowSessions.sessions).toEqual([]); - await expect(repository.resolveRuntimeInvocationSession({ runId: "run-1" })) + await expect(repository.resolveRuntimeInvocationSession({ ownerReference: { runId: "run-1" } })) .resolves.toMatchObject({ status: "found", session: { sessionKey: "codex:one" } }); - await expect(repository.resolveRuntimeInvocationSession({ runId: "missing" })) + await expect(repository.resolveRuntimeInvocationSession({ + invocationId: "inv-created", + surface: "evaluation", + role: "subject", + })).resolves.toMatchObject({ status: "found", session: { sessionKey: "codex:one" } }); + await expect(repository.resolveRuntimeInvocationSession({ ownerReference: { runId: "missing" } })) .resolves.toEqual({ status: "not_recorded" }); await invocations.begin({ @@ -351,7 +360,7 @@ describe("PostgreSQL Turn search", () => { "failed", Date.parse("2026-07-22T08:00:01.000Z"), ); - await expect(repository.resolveRuntimeInvocationSession({ runId: "run-no-reference" })) + await expect(repository.resolveRuntimeInvocationSession({ ownerReference: { runId: "run-no-reference" } })) .resolves.toEqual({ status: "no_session_reference", invocationId: "inv-no-reference", @@ -373,10 +382,44 @@ describe("PostgreSQL Turn search", () => { relation: "created", boundAt: Date.parse("2026-07-23T08:00:01.000Z"), }); - await expect(repository.resolveRuntimeInvocationSession({ runId: "run-awaiting-index" })) + await expect(repository.resolveRuntimeInvocationSession({ ownerReference: { runId: "run-awaiting-index" } })) .resolves.toEqual({ status: "not_indexed", invocationId: "inv-awaiting-index" }); }); + it("keeps list history bounded while a Session detail retains every invocation", async () => { + const invocations = new PostgresRuntimeInvocationRepository(database); + for (let index = 0; index < 22; index += 1) { + const id = `inv-history-${String(index).padStart(2, "0")}`; + await invocations.begin({ + id, + initiator: "agentrecall", + invocation: { surface: "workflow", ownerReference: { runId: id } }, + runtimeId: "codex", + environmentId: "local", + startedAt: Date.parse("2026-07-20T08:00:00.000Z") + index, + }); + await invocations.bind(id, { + runtimeId: "codex", + environmentId: "local", + sessionId: "one", + relation: index === 0 ? "created" : "continued", + boundAt: Date.parse("2026-07-20T08:00:00.000Z") + index, + }); + await invocations.finish(id, "completed", Date.parse("2026-07-20T08:01:00.000Z") + index); + } + + const listed = await searchRepository.searchSessionPage({ origin: "agentrecall" }); + expect(listed.sessions.find((item) => item.sessionKey === "codex:one")?.runtimeInvocations) + .toHaveLength(20); + await expect(repository.getSession("codex:one")).resolves.toMatchObject({ + runtimeInvocations: expect.arrayContaining([ + expect.objectContaining({ invocationId: "inv-history-00" }), + expect.objectContaining({ invocationId: "inv-history-21" }), + ]), + }); + expect((await repository.getSession("codex:one"))?.runtimeInvocations).toHaveLength(22); + }); + it("filters both Claude and Codex StepCode variants as one source", async () => { await repository.upsertIndexedSession( session("stepcode-claude:two", "StepCode Claude", "2026-07-24T08:00:00.000Z", { diff --git a/apps/main-2.0/src/core/postgres/session-stats-repository.ts b/apps/main-2.0/src/core/postgres/session-stats-repository.ts index f254535e7..0a4e8586f 100644 --- a/apps/main-2.0/src/core/postgres/session-stats-repository.ts +++ b/apps/main-2.0/src/core/postgres/session-stats-repository.ts @@ -11,6 +11,7 @@ import type { TokenUsage, } from "../types"; import type { PostgresDatabase } from "./database"; +import { sessionOriginPredicate } from "./session-records"; interface StatsRange { period: SessionStatsPeriod; @@ -189,7 +190,15 @@ export class PostgresSessionStatsRepository { includePrevious = true, ): Promise { const range = resolveStatsRange(options, now); - const subagentPredicate = options.excludeSubagents ? "and sessions.is_subagent = false" : ""; + const originPredicate = sessionOriginPredicate(options.origin); + const sessionPredicates = [ + ...(options.excludeSubagents ? ["sessions.is_subagent = false"] : []), + ...(originPredicate ? [originPredicate] : []), + ]; + const sessionWhere = sessionPredicates.length > 0 + ? `where ${sessionPredicates.join(" and ")}` + : ""; + const sessionAnd = sessionPredicates.map((predicate) => `and ${predicate}`).join(" "); const rangeValues = range.since === null ? [] : [ @@ -200,7 +209,7 @@ export class PostgresSessionStatsRepository { ? ` select source, count(*) as session_count from agent_recall.sessions sessions - ${options.excludeSubagents ? "where sessions.is_subagent = false" : ""} + ${sessionWhere} group by source ` : ` @@ -208,12 +217,12 @@ export class PostgresSessionStatsRepository { select sessions.source, sessions.session_key from agent_recall.sessions sessions join agent_recall.session_message_events events on events.session_key = sessions.session_key - where events.occurred_at >= $1 and events.occurred_at <= $2 ${subagentPredicate} + where events.occurred_at >= $1 and events.occurred_at <= $2 ${sessionAnd} union select sessions.source, sessions.session_key from agent_recall.sessions sessions join agent_recall.token_events events on events.session_key = sessions.session_key - where events.occurred_at >= $1 and events.occurred_at <= $2 ${subagentPredicate} + where events.occurred_at >= $1 and events.occurred_at <= $2 ${sessionAnd} ) select source, count(distinct session_key) as session_count from active @@ -223,19 +232,19 @@ export class PostgresSessionStatsRepository { ? ` select source, coalesce(sum(message_count), 0) as message_count from agent_recall.sessions sessions - ${options.excludeSubagents ? "where sessions.is_subagent = false" : ""} + ${sessionWhere} group by source ` : ` select sessions.source, count(*) as message_count from agent_recall.session_message_events events join agent_recall.sessions sessions on sessions.session_key = events.session_key - where events.occurred_at >= $1 and events.occurred_at <= $2 ${subagentPredicate} + where events.occurred_at >= $1 and events.occurred_at <= $2 ${sessionAnd} group by sessions.source `; const tokenWhere = [ ...(range.since === null ? [] : ["events.occurred_at >= $1 and events.occurred_at <= $2"]), - ...(options.excludeSubagents ? ["sessions.is_subagent = false"] : []), + ...sessionPredicates, ]; const tokensSql = ` with ranked as ( @@ -318,7 +327,7 @@ export class PostgresSessionStatsRepository { coalesce(sum(reasoning_output_tokens), 0) as reasoning_output_tokens, coalesce(sum(total_tokens), 0) as total_tokens from agent_recall.sessions sessions - ${options.excludeSubagents ? "where sessions.is_subagent = false" : ""} + ${sessionWhere} group by source `, ); @@ -387,7 +396,7 @@ export class PostgresSessionStatsRepository { from agent_recall.token_events events join agent_recall.sessions sessions on sessions.session_key = events.session_key where events.occurred_at >= $1 and events.occurred_at <= $2 - ${options.excludeSubagents ? "and sessions.is_subagent = false" : ""} + ${sessionAnd} ) select occurred_at, input_tokens, output_tokens, cached_input_tokens, cache_creation_input_tokens, @@ -443,6 +452,14 @@ export class PostgresSessionStatsRepository { const period = options.period ?? "today"; const window = resolveStatsTrendWindow(period, now); if (!window) return { period, granularity: null, buckets: [] }; + const originPredicate = sessionOriginPredicate(options.origin); + const trendSessionPredicates = [ + ...(options.excludeSubagents ? ["sessions.is_subagent = false"] : []), + ...(originPredicate ? [originPredicate] : []), + ]; + const sessionAnd = trendSessionPredicates + .map((predicate) => `and ${predicate}`) + .join(" "); const result = await this.database.query<{ occurred_at: Date | string; @@ -470,7 +487,7 @@ export class PostgresSessionStatsRepository { join agent_recall.sessions sessions on sessions.session_key = events.session_key where events.occurred_at >= $1 and events.occurred_at <= $2 - ${options.excludeSubagents ? "and sessions.is_subagent = false" : ""} + ${sessionAnd} ) select occurred_at, total_tokens from ranked diff --git a/apps/main-2.0/src/core/session-store.ts b/apps/main-2.0/src/core/session-store.ts index c2717c73e..c923b4dbf 100644 --- a/apps/main-2.0/src/core/session-store.ts +++ b/apps/main-2.0/src/core/session-store.ts @@ -59,6 +59,7 @@ import type { ProjectQueryOptions, ProjectSummary, ProjectTagEntry, + RuntimeInvocationLookup, RuntimeInvocationSessionResolution, SearchOptions, SessionEnvironment, @@ -611,10 +612,10 @@ export class SessionStore { /** Resolves a Runtime invocation owner and preserves binding diagnostics. */ async resolveRuntimeInvocationSession( - ownerReference: Record, + lookup: RuntimeInvocationLookup, ): Promise { await this.ready; - return this.sessions.resolveRuntimeInvocationSession(ownerReference); + return this.sessions.resolveRuntimeInvocationSession(lookup); } async setAiSummary(sessionKey: string, summary: string, model: string): Promise { diff --git a/apps/main-2.0/src/core/types.ts b/apps/main-2.0/src/core/types.ts index 8635faf11..4cb819431 100644 --- a/apps/main-2.0/src/core/types.ts +++ b/apps/main-2.0/src/core/types.ts @@ -1,4 +1,7 @@ -import type { SessionInvocationSurfaceFilter } from "../shared/runtime-invocation"; +import type { + AgentRecallInvocationSurface, + SessionInvocationSurfaceFilter, +} from "../shared/runtime-invocation"; export type { SessionInvocationSurfaceFilter } from "../shared/runtime-invocation"; @@ -394,6 +397,7 @@ export interface SearchOptions { export interface ProjectQueryOptions { excludeSubagents?: boolean; environmentId?: string; + origin?: SessionOriginFilter; } export interface TagListOptions { @@ -473,8 +477,6 @@ export interface RuntimeInvocationSummary { startedAt: number; /** Unix epoch timestamp when dispatch reached a terminal state. */ finishedAt: number | null; - /** Persisted failure detail, when available. */ - error: string | null; /** Whether this invocation created or continued the Session. */ relation: "created" | "continued"; /** Native Runtime Session identifier. */ @@ -494,6 +496,14 @@ export type RuntimeInvocationSessionResolution = } | { status: "not_recorded" }; +/** Exact ledger selector used to navigate from a business record to its Runtime Session. */ +export interface RuntimeInvocationLookup { + invocationId?: string; + surface?: AgentRecallInvocationSurface; + role?: string; + ownerReference?: Record; +} + export interface SessionMatchHit { messageIndex: number; role: SessionMessage["role"]; @@ -547,6 +557,7 @@ export type SessionStatsTrendGranularity = "day" | "week" | "month"; export interface SessionStatsOptions { period?: SessionStatsPeriod; excludeSubagents?: boolean; + origin?: SessionOriginFilter; } export interface SessionStatsTrendBucket { diff --git a/apps/main-2.0/src/main/index.ts b/apps/main-2.0/src/main/index.ts index 98fab4218..ecb2f5ba8 100644 --- a/apps/main-2.0/src/main/index.ts +++ b/apps/main-2.0/src/main/index.ts @@ -735,10 +735,18 @@ function createAutomationService(): NativeAutomationService { }, }, readEvaluationSkill: (skillName) => skillService.readSkillInstructions(skillName), - resolveEvaluationSession: async (rawId) => { - const session = await store.findByRawId(rawId); - return session - ? { sessionKey: session.sessionKey, source: session.source, rawId: session.rawId } + resolveEvaluationSession: async (reference) => { + if (!reference.invocationId) return null; + const resolution = await store.resolveRuntimeInvocationSession({ + invocationId: reference.invocationId, + surface: "evaluation", + }); + return resolution.status === "found" + ? { + sessionKey: resolution.session.sessionKey, + source: resolution.session.source, + rawId: resolution.session.rawId, + } : null; }, readEvaluationTrajectory: (sessionKey) => readEvaluationTrajectory(sessionKey), @@ -1044,6 +1052,7 @@ function codexDesktopHome(): string { async function listVisibleProjects(options: ProjectQueryOptions = {}): Promise { const indexed = await store.listProjects(options); + if (options.origin === "agentrecall") return indexed; if (options.environmentId && options.environmentId !== "all" && options.environmentId !== "local") return indexed; return mergeCodexDesktopProjects(indexed, await readCodexDesktopProjects(codexDesktopHome())); } diff --git a/apps/main-2.0/src/main/ipc/session-catalog.test.ts b/apps/main-2.0/src/main/ipc/session-catalog.test.ts index 801f037d6..e9672b4a2 100644 --- a/apps/main-2.0/src/main/ipc/session-catalog.test.ts +++ b/apps/main-2.0/src/main/ipc/session-catalog.test.ts @@ -4,7 +4,7 @@ import type { SessionCatalogService } from "../services/session-catalog-service" import { registerSessionCatalogIpc } from "./session-catalog"; describe("Session catalog IPC Runtime owner boundary", () => { - test("accepts a bounded string map and rejects malformed owner references", async () => { + test("accepts an exact bounded lookup and rejects malformed owner references", async () => { const handlers = new Map unknown>(); const resolveRuntimeInvocationSession = vi.fn(async () => ({ status: "found" as const, @@ -21,14 +21,20 @@ describe("Session catalog IPC Runtime owner boundary", () => { const handler = handlers.get("session:resolve-runtime-owner"); expect(handler).toBeTypeOf("function"); - await expect(handler?.({}, { workflowId: "workflow-1", runId: "run-1" })) + await expect(handler?.({}, { + surface: "workflow", + role: "node", + ownerReference: { workflowId: "workflow-1", runId: "run-1" }, + })) .resolves.toEqual({ status: "found", session: { sessionKey: "codex:one" } }); expect(resolveRuntimeInvocationSession).toHaveBeenCalledWith({ - workflowId: "workflow-1", - runId: "run-1", + surface: "workflow", + role: "node", + ownerReference: { workflowId: "workflow-1", runId: "run-1" }, }); expect(() => handler?.({}, [])).toThrow(/must be an object/i); - expect(() => handler?.({}, { workflowId: 1 })).toThrow(/invalid field/i); - expect(() => handler?.({}, {})).toThrow(/between 1 and 32 fields/i); + expect(() => handler?.({}, { ownerReference: { workflowId: 1 } })).toThrow(/invalid field/i); + expect(() => handler?.({}, { ownerReference: {} })).toThrow(/between 1 and 32 fields/i); + expect(() => handler?.({}, { surface: "unknown", ownerReference: { runId: "1" } })).toThrow(/invalid surface/i); }); }); diff --git a/apps/main-2.0/src/main/ipc/session-catalog.ts b/apps/main-2.0/src/main/ipc/session-catalog.ts index 199adc5dc..e97e0930a 100644 --- a/apps/main-2.0/src/main/ipc/session-catalog.ts +++ b/apps/main-2.0/src/main/ipc/session-catalog.ts @@ -7,10 +7,12 @@ import { } from "../../core/session-bulk-delete"; import type { ProjectQueryOptions, + RuntimeInvocationLookup, SearchOptions, SessionStatsOptions, TagListOptions, } from "../../core/types"; +import { AGENT_RECALL_INVOCATION_SURFACES } from "../../shared/runtime-invocation"; import type { SessionCatalogService } from "../services/session-catalog-service"; /** @@ -25,8 +27,8 @@ export function registerSessionCatalogIpc( ipc.handle("search:session-page", (_event, options: SearchOptions) => service.searchPage(options)); ipc.handle("session:get", (_event, sessionKey: string) => service.get(sessionKey)); ipc.handle("session:find-by-raw-id", (_event, rawId: string) => service.findByRawId(rawId)); - ipc.handle("session:resolve-runtime-owner", (_event, ownerReference: unknown) => - service.resolveRuntimeInvocationSession(runtimeInvocationOwnerReference(ownerReference))); + ipc.handle("session:resolve-runtime-owner", (_event, lookup: unknown) => + service.resolveRuntimeInvocationSession(runtimeInvocationLookup(lookup))); ipc.handle("session:turns", (_event, sessionKey: string) => service.listTurns(sessionKey)); ipc.handle("session:turn", (_event, sessionKey: string, turnId: string) => service.getTurn(sessionKey, turnId)); @@ -73,6 +75,37 @@ export function registerSessionCatalogIpc( ipc.handle("index:status", () => service.getIndexStatus()); } +function runtimeInvocationLookup(value: unknown): RuntimeInvocationLookup { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Runtime invocation lookup must be an object."); + } + const record = value as Record; + const invocationId = optionalLookupString(record.invocationId, "invocationId"); + const role = optionalLookupString(record.role, "role"); + const surface = record.surface; + if ( + surface !== undefined + && ( + typeof surface !== "string" + || !(AGENT_RECALL_INVOCATION_SURFACES as readonly string[]).includes(surface) + ) + ) { + throw new Error("Runtime invocation lookup contains an invalid surface."); + } + const ownerReference = record.ownerReference === undefined + ? undefined + : runtimeInvocationOwnerReference(record.ownerReference); + if (!invocationId && !ownerReference) { + throw new Error("Runtime invocation lookup requires invocationId or ownerReference."); + } + return { + ...(invocationId ? { invocationId } : {}), + ...(surface ? { surface: surface as RuntimeInvocationLookup["surface"] } : {}), + ...(role ? { role } : {}), + ...(ownerReference ? { ownerReference } : {}), + }; +} + function runtimeInvocationOwnerReference(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error("Runtime invocation owner reference must be an object."); @@ -88,3 +121,11 @@ function runtimeInvocationOwnerReference(value: unknown): Record } return Object.fromEntries(entries) as Record; } + +function optionalLookupString(value: unknown, field: string): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== "string" || !value || value.length > 1_000) { + throw new Error(`Runtime invocation lookup contains an invalid ${field}.`); + } + return value; +} diff --git a/apps/main-2.0/src/main/services/evaluation-service.ts b/apps/main-2.0/src/main/services/evaluation-service.ts index 536601953..ba9523c02 100644 --- a/apps/main-2.0/src/main/services/evaluation-service.ts +++ b/apps/main-2.0/src/main/services/evaluation-service.ts @@ -42,7 +42,7 @@ export type EvaluationAgentExecution = ( output: string; durationMs: number; /** Runtime-native ids used to link this run to its session. */ - executionReference?: { sessionId?: string; turnId?: string }; + executionReference?: { invocationId?: string; sessionId?: string; turnId?: string }; }>; // Rubric for auto-provisioned judges. The runner appends the JSON return @@ -83,7 +83,9 @@ export interface EvaluationServiceDependencies { * AgentRecall session. The trajectory half of a run is skipped when this and * `readTrajectory` are not both wired. */ - resolveSession?: (rawId: string) => Promise<{ sessionKey: string } | null>; + resolveSession?: ( + reference: { invocationId?: string; sessionId?: string; turnId?: string }, + ) => Promise<{ sessionKey: string } | null>; readTrajectory?: (sessionKey: string) => Promise; /** Reads a session's answer, for evaluating a session that already happened. */ readSessionArtifact?: ( diff --git a/apps/main-2.0/src/main/services/session-catalog-service.ts b/apps/main-2.0/src/main/services/session-catalog-service.ts index f434a150d..f77992a6d 100644 --- a/apps/main-2.0/src/main/services/session-catalog-service.ts +++ b/apps/main-2.0/src/main/services/session-catalog-service.ts @@ -18,6 +18,7 @@ import type { ProjectSummary, ProjectTagEntry, RuntimeInvocationSessionResolution, + RuntimeInvocationLookup, SearchOptions, SessionEnvironment, SessionMessage, @@ -83,9 +84,9 @@ export class SessionCatalogService { /** Resolves an invocation owner to a Session or an explicit unavailable reason. */ async resolveRuntimeInvocationSession( - ownerReference: Record, + lookup: RuntimeInvocationLookup, ): Promise { - return this.dependencies.store.resolveRuntimeInvocationSession(ownerReference); + return this.dependencies.store.resolveRuntimeInvocationSession(lookup); } async get(sessionKey: string): Promise { diff --git a/apps/main-2.0/src/preload/index.ts b/apps/main-2.0/src/preload/index.ts index 6840814a2..649b8d340 100644 --- a/apps/main-2.0/src/preload/index.ts +++ b/apps/main-2.0/src/preload/index.ts @@ -21,6 +21,7 @@ import type { ProjectSummary, ProjectQueryOptions, ProjectTagEntry, + RuntimeInvocationLookup, RuntimeInvocationSessionResolution, SearchOptions, SessionEnvironment, @@ -65,8 +66,8 @@ const api = { findSessionByRawId: (rawId: string): Promise => ipcRenderer.invoke("session:find-by-raw-id", rawId), /** Resolves the Session associated with an exact AgentRecall invocation owner. */ resolveRuntimeInvocationSession: ( - ownerReference: Record, - ): Promise => ipcRenderer.invoke("session:resolve-runtime-owner", ownerReference), + lookup: RuntimeInvocationLookup, + ): Promise => ipcRenderer.invoke("session:resolve-runtime-owner", lookup), getSessionContextComponents: (sessionKey: string): Promise => ipcRenderer.invoke("session:context-components", sessionKey), getMessages: (sessionKey: string, offset?: number, limit?: number): Promise => diff --git a/apps/main-2.0/src/renderer/src/App.session-open.test.tsx b/apps/main-2.0/src/renderer/src/App.session-open.test.tsx index 6a81644a6..8f5141fc8 100644 --- a/apps/main-2.0/src/renderer/src/App.session-open.test.tsx +++ b/apps/main-2.0/src/renderer/src/App.session-open.test.tsx @@ -8,6 +8,7 @@ import { type SessionBulkDeletePreview, } from "../../core/session-bulk-delete"; import type { + RuntimeInvocationSummary, SessionMigrationProgress, SessionMigrationResult, SessionSearchResult, @@ -25,7 +26,11 @@ const harness = vi.hoisted(() => ({ searchAllMatching: vi.fn(async () => [] as SessionSearchResult[]), openLocal: vi.fn(), setSelectedKey: vi.fn(), + workbenchPage: vi.fn((_props: unknown) => null), sessionsPage: vi.fn((_props: unknown) => null), + sessionDetails: vi.fn((_props: unknown) => null), + skillsPage: vi.fn((_props: unknown) => null), + runtimeFeaturePage: vi.fn((_props: unknown) => null), remoteSessionsDialog: vi.fn((_props: unknown) => null), loadCatalog: vi.fn(async () => undefined), loadWorkbenchSessions: vi.fn(async () => undefined), @@ -40,9 +45,11 @@ const harness = vi.hoisted(() => ({ })); vi.mock("./components/app-navigation", () => ({ AppNavigation: () => null })); -vi.mock("./features/workbench/workbench-page", () => ({ WorkbenchPage: () => null })); +vi.mock("./features/workbench/workbench-page", () => ({ WorkbenchPage: harness.workbenchPage })); vi.mock("./features/sessions/sessions-page", () => ({ SessionsPage: harness.sessionsPage })); -vi.mock("./features/sessions/session-details", () => ({ SessionDetails: () => null })); +vi.mock("./features/sessions/session-details", () => ({ SessionDetails: harness.sessionDetails })); +vi.mock("./features/skills/skills-page", () => ({ SkillsPage: harness.skillsPage })); +vi.mock("./features/automation/runtime-feature-page", () => ({ RuntimeFeaturePage: harness.runtimeFeaturePage })); vi.mock("./features/remote-sessions/remote-sessions-dialog", () => ({ RemoteSessionsDialog: harness.remoteSessionsDialog, })); @@ -401,4 +408,85 @@ describe("external session opening", () => { vi.useRealTimers(); } }); + + it("returns Agent and Skill invocation history to their exact product surfaces", async () => { + harness.detail = { + sessionKey: "codex:runtime-owner", + source: "codex-cli", + displayTitle: "Runtime owner", + } as SessionSearchResult; + await act(async () => root.render(createElement((await import("./App")).App))); + const detailsProps = harness.sessionDetails.mock.calls.at(-1)?.[0] as { + actions: { openInvocationOwner: (invocation: RuntimeInvocationSummary) => void }; + }; + + await act(async () => { + detailsProps.actions.openInvocationOwner({ + invocationId: "agent-invocation", + surface: "agent", + role: "chat", + ownerReference: { chatId: "chat-1", agentId: "agent-1" }, + runtimeId: "codex", + channelId: "codex-default", + environmentId: "local", + status: "completed", + startedAt: 1, + finishedAt: 2, + relation: "created", + runtimeSessionId: "session-1", + runtimeTurnId: null, + }); + await Promise.resolve(); + }); + await vi.waitFor(() => expect(harness.runtimeFeaturePage).toHaveBeenCalled()); + expect(harness.runtimeFeaturePage.mock.calls.at(-1)?.[0]).toMatchObject({ + initialAgentId: "agent-1", + }); + + await act(async () => { + detailsProps.actions.openInvocationOwner({ + invocationId: "skill-invocation", + surface: "skill", + role: "discovery", + ownerReference: { channelId: "codex-default" }, + runtimeId: "codex", + channelId: "codex-default", + environmentId: "local", + status: "completed", + startedAt: 3, + finishedAt: 4, + relation: "created", + runtimeSessionId: "session-2", + runtimeTurnId: null, + }); + await Promise.resolve(); + }); + await vi.waitFor(() => expect(harness.skillsPage).toHaveBeenCalled()); + expect(harness.skillsPage.mock.calls.at(-1)?.[0]).toMatchObject({ + initialDiscoveryOpen: true, + }); + + const skillPageCalls = harness.skillsPage.mock.calls.length; + harness.workbenchPage.mockClear(); + await act(async () => { + detailsProps.actions.openInvocationOwner({ + invocationId: "future-invocation", + surface: "future_surface", + role: null, + ownerReference: { futureId: "future-1" }, + runtimeId: "codex", + channelId: "codex-default", + environmentId: "local", + status: "completed", + startedAt: 5, + finishedAt: 6, + relation: "created", + runtimeSessionId: "session-3", + runtimeTurnId: null, + }); + await Promise.resolve(); + }); + await vi.waitFor(() => expect(harness.workbenchPage).toHaveBeenCalled()); + expect(harness.skillsPage).toHaveBeenCalledTimes(skillPageCalls); + }); }); diff --git a/apps/main-2.0/src/renderer/src/App.tsx b/apps/main-2.0/src/renderer/src/App.tsx index e0e0a63bb..d2268a750 100644 --- a/apps/main-2.0/src/renderer/src/App.tsx +++ b/apps/main-2.0/src/renderer/src/App.tsx @@ -221,7 +221,11 @@ export function App(): ReactElement { const [preferredTeamChatRoomId, setPreferredTeamChatRoomId] = useState(); const [preferredTeamChatMessageId, setPreferredTeamChatMessageId] = useState(); const [preferredEvaluationRunId, setPreferredEvaluationRunId] = useState(); + const [preferredEvaluationCaseId, setPreferredEvaluationCaseId] = useState(); + const [preferredEvaluationEvaluatorId, setPreferredEvaluationEvaluatorId] = useState(); const [preferredRuntimeChannelId, setPreferredRuntimeChannelId] = useState(); + const [preferredRuntimeAgentId, setPreferredRuntimeAgentId] = useState(); + const [openSkillDiscoveryFromSession, setOpenSkillDiscoveryFromSession] = useState(false); useEffect(() => { if (activePage !== "workbench") return; let active = true; @@ -303,6 +307,8 @@ export function App(): ReactElement { stats, statsPeriod, setStatsPeriod, + statsOrigin, + setStatsOrigin, statsRefreshing, statsFeedback, quotas, @@ -506,7 +512,7 @@ export function App(): ReactElement { const requestId = ++metadataLoadSeqRef.current; const [nextTags, nextProjects, nextEnvironments, nextProjectTags] = await Promise.all([ window.sessionSearch.listTags(), - window.sessionSearch.listProjects(), + window.sessionSearch.listProjects({ origin }), window.sessionSearch.listEnvironments(), window.sessionSearch.listTagsByProject(), ]); @@ -515,7 +521,7 @@ export function App(): ReactElement { setProjects(nextProjects); setEnvironments(nextEnvironments); setProjectTags(nextProjectTags); - }, []); + }, [origin]); useEffect(() => { void loadSidebarMetadata(); @@ -1786,6 +1792,7 @@ export function App(): ReactElement { void refreshStats()} onRefreshQuotas={() => void loadQuotas("manual")} onOpenSettings={() => { setSettingsInitialSection("usage"); setSettingsOpen(true); }} @@ -2010,6 +2018,8 @@ export function App(): ReactElement { setEvalPreselectedSkill(skillName); void navigateToPage("evaluation"); }} + initialDiscoveryOpen={openSkillDiscoveryFromSession} + onInitialDiscoveryConsumed={() => setOpenSkillDiscoveryFromSession(false)} /> ) : null} @@ -2020,9 +2030,10 @@ export function App(): ReactElement { initialRequest={workflowInitialRequest} onInitialRequestConsumed={() => setWorkflowInitialRequest(undefined)} onOpenSession={(sessionKey) => { - void window.sessionSearch.getSession(sessionKey).then((session) => { - if (session) void openDetail(session); - }); + void (async () => { + const session = await window.sessionSearch.getSession(sessionKey); + if (session) await openDetail(session); + })().catch(reportSessionDetailError); }} /> : null} @@ -2036,9 +2047,10 @@ export function App(): ReactElement { setPreferredTeamChatMessageId(undefined); }} onOpenSession={(sessionKey) => { - void window.sessionSearch.getSession(sessionKey).then((session) => { - if (session) void openDetail(session); - }); + void (async () => { + const session = await window.sessionSearch.getSession(sessionKey); + if (session) await openDetail(session); + })().catch(reportSessionDetailError); }} /> ) : null} @@ -2050,7 +2062,13 @@ export function App(): ReactElement { preselectedSkill={evalPreselectedSkill} onPreselectedConsumed={() => setEvalPreselectedSkill(null)} initialRunId={preferredEvaluationRunId} - onInitialRunConsumed={() => setPreferredEvaluationRunId(undefined)} + initialCaseId={preferredEvaluationCaseId} + initialEvaluatorId={preferredEvaluationEvaluatorId} + onInitialRunConsumed={() => { + setPreferredEvaluationRunId(undefined); + setPreferredEvaluationCaseId(undefined); + setPreferredEvaluationEvaluatorId(undefined); + }} onOpenSettings={() => { setSettingsInitialSection("eval"); setSettingsOpen(true); @@ -2069,7 +2087,15 @@ export function App(): ReactElement { setPreferredRuntimeChannelId(undefined)} + onInitialChannelConsumed={() => { + setPreferredRuntimeChannelId(undefined); + setPreferredRuntimeAgentId(undefined); + }} + initialAgentId={preferredRuntimeAgentId} + onInitialAgentConsumed={() => { + setPreferredRuntimeAgentId(undefined); + setPreferredRuntimeChannelId(undefined); + }} onNavigationGuardChange={setPageNavigationGuard} /> ) : null} @@ -2207,16 +2233,34 @@ export function App(): ReactElement { } if (invocation.surface === "evaluation") { setPreferredEvaluationRunId(invocation.ownerReference.runId); + setPreferredEvaluationCaseId(invocation.ownerReference.caseId); + setPreferredEvaluationEvaluatorId(invocation.ownerReference.evaluatorId); void navigateToPage("evaluation"); return; } if (invocation.surface === "system") { setPreferredRuntimeChannelId(invocation.ownerReference.channelId); + setPreferredRuntimeAgentId(undefined); void navigateToPage("runtimes"); return; } - const page: AppPage = invocation.surface === "skill" ? "skills" : "workbench"; - void navigateToPage(page); + if (invocation.surface === "agent") { + const agentId = invocation.ownerReference.agentId; + if (agentId) { + setPreferredRuntimeAgentId(agentId); + setPreferredRuntimeChannelId(undefined); + void navigateToPage("runtimes"); + } else { + void navigateToPage("workbench"); + } + return; + } + if (invocation.surface === "skill") { + setOpenSkillDiscoveryFromSession(true); + void navigateToPage("skills"); + return; + } + void navigateToPage("workbench"); }, reveal: (session) => void runAction( `Opening ${FILE_MANAGER_LABEL}`, diff --git a/apps/main-2.0/src/renderer/src/features/automation/runtime-feature-page.tsx b/apps/main-2.0/src/renderer/src/features/automation/runtime-feature-page.tsx index 953ef7fd6..f87215f28 100644 --- a/apps/main-2.0/src/renderer/src/features/automation/runtime-feature-page.tsx +++ b/apps/main-2.0/src/renderer/src/features/automation/runtime-feature-page.tsx @@ -37,11 +37,15 @@ export function RuntimeFeaturePage({ language, initialChannelId, onInitialChannelConsumed, + initialAgentId, + onInitialAgentConsumed, onNavigationGuardChange, }: { language: LanguageMode; initialChannelId?: string; onInitialChannelConsumed?: () => void; + initialAgentId?: string; + onInitialAgentConsumed?: () => void; onNavigationGuardChange?: (guard: (() => Promise) | null) => void; }): ReactElement { const { api, snapshot, setSnapshot, detailsLoaded, loading, error, refresh } = useAutomationDetails(); @@ -76,6 +80,15 @@ export function RuntimeFeaturePage({ onInitialChannelConsumed?.(); }, [detailsLoaded, initialChannelId, manager.configChannels, manager.selectConfigChannel, onInitialChannelConsumed]); + useEffect(() => { + if (!initialAgentId || !detailsLoaded) return; + setView("agents"); + if (snapshot.configuredAgents.some((agent) => agent.id === initialAgentId)) { + setSelectedAgentId(initialAgentId); + } + onInitialAgentConsumed?.(); + }, [detailsLoaded, initialAgentId, onInitialAgentConsumed, snapshot.configuredAgents]); + const requestUnsavedDecision = useCallback((message: string): Promise => ( new Promise((resolve) => { unsavedDecisionRef.current?.("cancel"); diff --git a/apps/main-2.0/src/renderer/src/features/automation/workflow-feature-page.test.tsx b/apps/main-2.0/src/renderer/src/features/automation/workflow-feature-page.test.tsx index 4e1762018..ac7db0d2a 100644 --- a/apps/main-2.0/src/renderer/src/features/automation/workflow-feature-page.test.tsx +++ b/apps/main-2.0/src/renderer/src/features/automation/workflow-feature-page.test.tsx @@ -270,9 +270,12 @@ describe("WorkflowFeaturePage live output", () => { }); expect(sessionSearch.resolveRuntimeInvocationSession).toHaveBeenCalledWith({ - workflowId: "workflow-1", - runId: "run-1", - nodeId: "inspect-code", + surface: "workflow", + ownerReference: { + workflowId: "workflow-1", + runId: "run-1", + nodeId: "inspect-code", + }, }); expect(onOpenSession).toHaveBeenCalledWith("session-1"); }); diff --git a/apps/main-2.0/src/renderer/src/features/automation/workflow-feature-page.tsx b/apps/main-2.0/src/renderer/src/features/automation/workflow-feature-page.tsx index 0753e4eab..cd672a6aa 100644 --- a/apps/main-2.0/src/renderer/src/features/automation/workflow-feature-page.tsx +++ b/apps/main-2.0/src/renderer/src/features/automation/workflow-feature-page.tsx @@ -591,9 +591,12 @@ export function WorkflowFeaturePage({ setError(undefined); try { const resolution = await window.sessionSearch.resolveRuntimeInvocationSession({ - workflowId: draft.id, - runId: selectedRun.id, - ...(selectedNodeId ? { nodeId: selectedNodeId } : {}), + surface: "workflow", + ownerReference: { + workflowId: draft.id, + runId: selectedRun.id, + ...(selectedNodeId ? { nodeId: selectedNodeId } : {}), + }, }); if (resolution.status !== "found") { setError(runtimeSessionUnavailableMessage(resolution, { en: "this run", zh: "该运行" }, language)); diff --git a/apps/main-2.0/src/renderer/src/features/eval/eval-page.tsx b/apps/main-2.0/src/renderer/src/features/eval/eval-page.tsx index 30e66762b..3b8e7e676 100644 --- a/apps/main-2.0/src/renderer/src/features/eval/eval-page.tsx +++ b/apps/main-2.0/src/renderer/src/features/eval/eval-page.tsx @@ -41,6 +41,8 @@ export function EvalPage({ preselectedSkill, onPreselectedConsumed, initialRunId, + initialCaseId, + initialEvaluatorId, onInitialRunConsumed, }: { language: LanguageMode; @@ -51,6 +53,8 @@ export function EvalPage({ preselectedSkill?: string | null; onPreselectedConsumed?: () => void; initialRunId?: string; + initialCaseId?: string; + initialEvaluatorId?: string; onInitialRunConsumed?: () => void; }): ReactElement { const l = (en: string, zh: string) => localize(language, en, zh); @@ -213,6 +217,8 @@ export function EvalPage({ language={language} onOpenSession={onOpenSession} initialRunId={initialRunId} + initialCaseId={initialCaseId} + initialEvaluatorId={initialEvaluatorId} onInitialRunConsumed={onInitialRunConsumed} /> ) : !enabled ? ( diff --git a/apps/main-2.0/src/renderer/src/features/eval/eval-runs-page.test.tsx b/apps/main-2.0/src/renderer/src/features/eval/eval-runs-page.test.tsx index e302fc0fa..199146fe1 100644 --- a/apps/main-2.0/src/renderer/src/features/eval/eval-runs-page.test.tsx +++ b/apps/main-2.0/src/renderer/src/features/eval/eval-runs-page.test.tsx @@ -17,6 +17,7 @@ const harness = vi.hoisted(() => ({ listExperiments: vi.fn(), listEvaluators: vi.fn(), confirm: vi.fn(), + resolveSession: vi.fn(), })); function summary(overrides: Partial = {}): EvaluationRunSummary { @@ -85,6 +86,7 @@ function graphRun(): EvaluationRun { role: "judge", status: "excused", attribution: { type: "infra_failure", reason: "judge_runtime_not_configured" }, + facts: { evaluatorId: "judge-1" }, }, { nodeId: "skill-use", @@ -112,9 +114,11 @@ beforeEach(() => { harness.listEvaluators.mockReset().mockResolvedValue([]); harness.deleteRun.mockReset().mockResolvedValue(true); harness.confirm.mockReset().mockReturnValue(true); + harness.resolveSession.mockReset().mockResolvedValue({ status: "not_recorded" }); Object.assign(window, { confirm: harness.confirm, sessionSearch: { + resolveRuntimeInvocationSession: harness.resolveSession, automation: { listEvaluationRuns: harness.listRuns, getEvaluationRun: harness.getRun, @@ -155,12 +159,19 @@ describe("EvalRunsPage", () => { language: "zh", onOpenSession: () => undefined, initialRunId: "run-older", + initialCaseId: "run-1:item-1:1", + initialEvaluatorId: "judge-1", onInitialRunConsumed, })); }); await vi.waitFor(() => expect(harness.getRun).toHaveBeenCalledWith("run-older")); expect(onInitialRunConsumed).toHaveBeenCalledOnce(); + expect(container.querySelector('[data-eval-case-id="run-1:item-1:1"]')?.className) + .toContain("contains-selection"); + expect([...container.querySelectorAll(".eval-graph-nodes li")] + .find((item) => item.textContent?.includes("模型评判"))?.className) + .toContain("contains-selection"); }); it("groups each task's runs under an independently collapsible heading", async () => { @@ -243,6 +254,71 @@ describe("EvalRunsPage", () => { expect(text).not.toContain("未使用该 Skill"); }); + it("opens exact subject and judge invocations, including failed runs", async () => { + harness.listRuns.mockResolvedValue({ items: [summary()], total: 1, offset: 0, limit: 50 }); + harness.getRun.mockResolvedValue(graphRun()); + harness.resolveSession.mockResolvedValue({ + status: "found", + session: { sessionKey: "codex:linked" }, + }); + const onOpenSession = vi.fn(); + + await act(async () => { + root.render(createElement(EvalRunsPage, { language: "zh", onOpenSession })); + }); + const subject = [...container.querySelectorAll("button")] + .find((button) => button.textContent?.includes("被测会话")); + const judge = [...container.querySelectorAll("button")] + .find((button) => button.textContent?.includes("评分会话")); + if (!subject || !judge) throw new Error("Evaluation Session links were not rendered"); + + await act(async () => { + subject.click(); + await Promise.resolve(); + }); + expect(harness.resolveSession).toHaveBeenLastCalledWith({ + surface: "evaluation", + role: "subject", + ownerReference: { runId: "run-1", caseId: "run-1:item-1:1" }, + }); + + await act(async () => { + judge.click(); + await Promise.resolve(); + }); + expect(harness.resolveSession).toHaveBeenLastCalledWith({ + surface: "evaluation", + role: "judge", + ownerReference: { + runId: "run-1", + caseId: "run-1:item-1:1", + evaluatorId: "judge-1", + }, + }); + expect(onOpenSession).toHaveBeenCalledTimes(2); + }); + + it("shows a reliable no-reference result instead of guessing a Session", async () => { + harness.listRuns.mockResolvedValue({ items: [summary()], total: 1, offset: 0, limit: 50 }); + harness.getRun.mockResolvedValue(graphRun()); + harness.resolveSession.mockResolvedValue({ + status: "no_session_reference", + invocationId: "inv-dsh", + invocationStatus: "failed", + }); + await render(); + const subject = [...container.querySelectorAll("button")] + .find((button) => button.textContent?.includes("被测会话")); + if (!subject) throw new Error("Subject Session link was not rendered"); + + await act(async () => { + subject.click(); + await Promise.resolve(); + }); + + expect(container.querySelector('[role="alert"]')?.textContent).toContain("未返回 Session 引用"); + }); + it("explains that a run recorded before the graph engine has no steps", async () => { harness.listRuns.mockResolvedValue({ items: [summary({ engine: undefined })], diff --git a/apps/main-2.0/src/renderer/src/features/eval/eval-runs-page.tsx b/apps/main-2.0/src/renderer/src/features/eval/eval-runs-page.tsx index c3f7addff..8a036a082 100644 --- a/apps/main-2.0/src/renderer/src/features/eval/eval-runs-page.tsx +++ b/apps/main-2.0/src/renderer/src/features/eval/eval-runs-page.tsx @@ -4,6 +4,7 @@ import { AlertTriangle, ChevronDown, ChevronRight, + ExternalLink, History, RefreshCw, Trash2, @@ -19,6 +20,7 @@ import type { } from "../../../../automation/contracts"; import { formatRelativeTime } from "../../../../core/format-session"; import { localize, type LanguageMode } from "../../language"; +import { runtimeSessionUnavailableMessage } from "../sessions/runtime-session-resolution"; import { EvalCaseArtifact } from "./eval-case-artifact"; import { EvalDimensionCard } from "./eval-dimension-card"; import { @@ -45,11 +47,15 @@ export function EvalRunsPage({ language, onOpenSession, initialRunId, + initialCaseId, + initialEvaluatorId, onInitialRunConsumed, }: { language: LanguageMode; onOpenSession: (sessionKey: string) => void; initialRunId?: string; + initialCaseId?: string; + initialEvaluatorId?: string; onInitialRunConsumed?: () => void; }): ReactElement { const l = (en: string, zh: string) => localize(language, en, zh); @@ -63,6 +69,8 @@ export function EvalRunsPage({ const [loadingRun, setLoadingRun] = useState(false); const [error, setError] = useState(null); const requestedRunIdRef = useRef(undefined); + const requestedCaseIdRef = useRef(undefined); + const requestedEvaluatorIdRef = useRef(undefined); useEffect(() => { if (!initialRunId) return; @@ -71,6 +79,11 @@ export function EvalRunsPage({ onInitialRunConsumed?.(); }, [initialRunId, onInitialRunConsumed]); + useEffect(() => { + requestedCaseIdRef.current = initialCaseId; + requestedEvaluatorIdRef.current = initialEvaluatorId; + }, [initialCaseId, initialEvaluatorId]); + const reload = useCallback(async () => { setError(null); try { @@ -279,6 +292,8 @@ export function EvalRunsPage({ experiment={experiments?.find((item) => item.id === run.experimentId)} evaluators={evaluators ?? []} onOpenSession={onOpenSession} + focusedCaseId={requestedCaseIdRef.current} + focusedEvaluatorId={requestedEvaluatorIdRef.current} /> )}
@@ -293,15 +308,20 @@ function RunGraph({ experiment, evaluators, onOpenSession, + focusedCaseId, + focusedEvaluatorId, }: { language: LanguageMode; run: EvaluationRun; experiment?: EvaluationExperiment; evaluators: EvaluationEvaluator[]; onOpenSession: (sessionKey: string) => void; + focusedCaseId?: string; + focusedEvaluatorId?: string; }): ReactElement { const l = (en: string, zh: string) => localize(language, en, zh); const [selectedDimension, setSelectedDimension] = useState(null); + const [sessionFeedback, setSessionFeedback] = useState(null); const threshold = experiment?.scoring?.resolvedThreshold ?? 0.6; const evaluatorNames = new Map(evaluators.map((item) => [item.id, item.name || item.id])); const evaluatorDimensions = new Map( @@ -310,7 +330,29 @@ function RunGraph({ useEffect(() => { setSelectedDimension(null); + setSessionFeedback(null); }, [run.id]); + const openInvocationSession = async ( + role: "subject" | "judge", + ownerReference: Record, + label: { en: string; zh: string }, + ): Promise => { + setSessionFeedback(null); + try { + const resolution = await window.sessionSearch.resolveRuntimeInvocationSession({ + surface: "evaluation", + role, + ownerReference, + }); + if (resolution.status !== "found") { + setSessionFeedback(runtimeSessionUnavailableMessage(resolution, label, language)); + return; + } + onOpenSession(resolution.session.sessionKey); + } catch (cause) { + setSessionFeedback(cause instanceof Error ? cause.message : String(cause)); + } + }; return ( <>
@@ -376,6 +418,7 @@ function RunGraph({ /> ) : null} {run.error ?

{run.error}

: null} + {sessionFeedback ?

{sessionFeedback}

: null} {run.engine === undefined ? (

{" "} @@ -399,7 +442,11 @@ function RunGraph({ && result.scores.every((score) => score.passed) )); return ( -

  • +
  • {l(`Case ${index + 1}`, `用例 ${index + 1}`)} @@ -415,6 +462,19 @@ function RunGraph({ Skill {result.skillInjection.skillName}@{result.skillInjection.skillHash.slice(0, 8)} ) : null} + {(experiment?.source ?? "run_agent") === "run_agent" ? ( + + ) : null}

    {result.input}

    {result.nodes.map((node) => ( - + void openInvocationSession( + "judge", + { runId: run.id, caseId: result.id, evaluatorId }, + { en: "this judge run", zh: "该评分运行" }, + )} + /> ))} ) : ( @@ -645,14 +715,21 @@ function DimensionDiagnostics({ function GraphNodeRow({ language, node, + focusedEvaluatorId, + onOpenJudgeSession, }: { language: LanguageMode; node: EvaluationNodeRecord; + focusedEvaluatorId?: string; + onOpenJudgeSession: (evaluatorId: string) => void; }): ReactElement { const reason = node.attribution?.reason ?? node.pendingReason; const skillUse = node.nodeType === "skill_use_observe" ? skillUseText(language, node.facts) : null; + const evaluatorId = node.role === "judge" && typeof node.facts?.evaluatorId === "string" + ? node.facts.evaluatorId + : undefined; return ( -
  • +
  • {nodeLabel(language, node)} {nodeStatusText(language, node.status)} @@ -665,6 +742,16 @@ function GraphNodeRow({ .filter(Boolean) .join(" · ")} + {evaluatorId ? ( + + ) : null}
  • ); } diff --git a/apps/main-2.0/src/renderer/src/features/session-detail/detail-panel.test.tsx b/apps/main-2.0/src/renderer/src/features/session-detail/detail-panel.test.tsx index 403181812..19adece40 100644 --- a/apps/main-2.0/src/renderer/src/features/session-detail/detail-panel.test.tsx +++ b/apps/main-2.0/src/renderer/src/features/session-detail/detail-panel.test.tsx @@ -110,7 +110,6 @@ describe("DetailPanel Turn controls", () => { status: "completed", startedAt: Date.parse("2026-08-10T10:00:00.000Z"), finishedAt: Date.parse("2026-08-10T10:00:01.000Z"), - error: null, relation: "created", runtimeSessionId: "test-session", runtimeTurnId: "turn-1", diff --git a/apps/main-2.0/src/renderer/src/features/session-detail/detail-panel.tsx b/apps/main-2.0/src/renderer/src/features/session-detail/detail-panel.tsx index b35672b97..74e2a2cc1 100644 --- a/apps/main-2.0/src/renderer/src/features/session-detail/detail-panel.tsx +++ b/apps/main-2.0/src/renderer/src/features/session-detail/detail-panel.tsx @@ -180,7 +180,8 @@ function invocationOwnerActionLabel( (invocation.surface === "workflow" && Boolean(invocation.ownerReference.workflowId)) || (invocation.surface === "team_chat" && Boolean(invocation.ownerReference.roomId)) || (invocation.surface === "evaluation" && Boolean(invocation.ownerReference.runId)) - || (invocation.surface === "system" && Boolean(invocation.ownerReference.channelId)); + || (invocation.surface === "system" && Boolean(invocation.ownerReference.channelId)) + || (invocation.surface === "agent" && Boolean(invocation.ownerReference.agentId)); if (exactOwner) return localize(language, "Back to source", "返回调用来源"); if (invocation.surface === "skill") return localize(language, "Open Skills", "打开 Skills"); if (invocation.surface === "workflow") return localize(language, "Open Workflows", "打开工作流"); diff --git a/apps/main-2.0/src/renderer/src/features/skills/skills-page.tsx b/apps/main-2.0/src/renderer/src/features/skills/skills-page.tsx index 4a75f6a89..f17cec49d 100644 --- a/apps/main-2.0/src/renderer/src/features/skills/skills-page.tsx +++ b/apps/main-2.0/src/renderer/src/features/skills/skills-page.tsx @@ -49,6 +49,8 @@ export function SkillsPage({ onDelete, evalBadgeCounts, onNavigateToEval, + initialDiscoveryOpen, + onInitialDiscoveryConsumed, }: { snapshot: InstalledSkillsSnapshot; syncSnapshot: SkillSyncSnapshot; @@ -75,6 +77,8 @@ export function SkillsPage({ onDelete: (skill: InstalledSkill) => Promise; evalBadgeCounts?: { skill: string; low: number; medium: number }[]; onNavigateToEval?: (skillName: string) => void; + initialDiscoveryOpen?: boolean; + onInitialDiscoveryConsumed?: () => void; }): ReactElement { const l = (en: string, zh: string) => localize(language, en, zh); const managedSkills = useMemo(() => snapshot.skills.filter(isManagedSkill), [snapshot.skills]); @@ -128,6 +132,12 @@ export function SkillsPage({ onRefreshLoadedLocal(); }, [onRefreshLoadedLocal]); + useEffect(() => { + if (!initialDiscoveryOpen) return; + setDiscoveryOpen(true); + onInitialDiscoveryConsumed?.(); + }, [initialDiscoveryOpen, onInitialDiscoveryConsumed]); + useEffect(() => { if (selectedRemoteFingerprint) { if (selectedRemoteGroup) return; diff --git a/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.test.tsx b/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.test.tsx index 03ca7981e..e86e5e5a6 100644 --- a/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.test.tsx +++ b/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.test.tsx @@ -165,7 +165,11 @@ describe("TeamChatPage rooms", () => { await Promise.resolve(); }); - expect(resolveRuntimeInvocationSession).toHaveBeenCalledWith({ roomId: "room-alpha" }); + expect(resolveRuntimeInvocationSession).toHaveBeenCalledWith({ + surface: "team_chat", + role: "member", + ownerReference: { roomId: "room-alpha" }, + }); expect(onOpenSession).toHaveBeenCalledWith("session-1"); }); @@ -199,9 +203,13 @@ describe("TeamChatPage rooms", () => { }); expect(resolveRuntimeInvocationSession).toHaveBeenCalledWith({ - roomId: "room-alpha", - messageId: "human-message", - agentId: "member-1", + surface: "team_chat", + role: "member", + ownerReference: { + roomId: "room-alpha", + messageId: "human-message", + agentId: "member-1", + }, }); expect(onOpenSession).toHaveBeenCalledWith("session-message"); }); diff --git a/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.tsx b/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.tsx index 79ad47381..aa2ee0e33 100644 --- a/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.tsx +++ b/apps/main-2.0/src/renderer/src/features/team-chat/team-chat-page.tsx @@ -252,7 +252,9 @@ export function TeamChatPage({ if (!activeRoom) return; try { const resolution = await window.sessionSearch.resolveRuntimeInvocationSession({ - roomId: activeRoom.id, + surface: "team_chat", + role: "member", + ownerReference: { roomId: activeRoom.id }, }); if (resolution.status !== "found") { setContextFeedback(runtimeSessionUnavailableMessage( @@ -272,9 +274,13 @@ export function TeamChatPage({ const messageId = message.sourceMessageId ?? message.id; try { const resolution = await window.sessionSearch.resolveRuntimeInvocationSession({ - roomId: message.roomId, - messageId, - ...(message.senderAgentId ? { agentId: message.senderAgentId } : {}), + surface: "team_chat", + role: "member", + ownerReference: { + roomId: message.roomId, + messageId, + ...(message.senderAgentId ? { agentId: message.senderAgentId } : {}), + }, }); if (resolution.status !== "found") { setContextFeedback(runtimeSessionUnavailableMessage( diff --git a/apps/main-2.0/src/renderer/src/features/workbench/use-workbench-overview.ts b/apps/main-2.0/src/renderer/src/features/workbench/use-workbench-overview.ts index b6c3df385..7f1f19564 100644 --- a/apps/main-2.0/src/renderer/src/features/workbench/use-workbench-overview.ts +++ b/apps/main-2.0/src/renderer/src/features/workbench/use-workbench-overview.ts @@ -3,6 +3,7 @@ import { LIVE_SESSION_REFRESH_INTERVAL_MS, QUOTA_REFRESH_INTERVAL_MS } from "../ import type { LiveSessionSnapshot, SessionSearchResult, + SessionOriginFilter, SessionStats, SessionStatsPeriod, UsageQuotaSnapshot, @@ -48,6 +49,7 @@ export function useWorkbenchOverview(language: LanguageMode) { const [sessions, setSessions] = useState([]); const [stats, setStats] = useState(EMPTY_STATS); const [statsPeriod, setStatsPeriod] = useState("today"); + const [statsOrigin, setStatsOrigin] = useState("ordinary"); const [statsRefreshing, setStatsRefreshing] = useState(false); const [statsFeedback, setStatsFeedback] = useState(null); const [quotas, setQuotas] = useState(EMPTY_QUOTAS); @@ -77,6 +79,7 @@ export function useWorkbenchOverview(language: LanguageMode) { source: "all", visibility: "default", sortBy: "smart", + origin: statsOrigin, limit: WORKBENCH_SESSION_LIMIT, }); if (requestId === sessionsLoadSequence.current) setSessions(page.sessions); @@ -88,6 +91,7 @@ export function useWorkbenchOverview(language: LanguageMode) { source: "all", visibility: "default", sortBy: "activity", + origin: statsOrigin, liveStatus: liveDetectionFailed ? undefined : "closed", liveSessionKeys: liveDetectionFailed ? [] : liveSearchKeys, limit: WORKBENCH_SESSION_LIMIT, @@ -98,6 +102,7 @@ export function useWorkbenchOverview(language: LanguageMode) { source: "all", visibility: "default", sortBy: "activity", + origin: statsOrigin, liveStatus: "open", liveSessionKeys: liveSearchKeys, limit: WORKBENCH_SESSION_LIMIT, @@ -111,13 +116,16 @@ export function useWorkbenchOverview(language: LanguageMode) { sessionsByKey.set(session.sessionKey, session); } setSessions([...sessionsByKey.values()]); - }, [liveDetectionFailed, liveSearchKeys, query]); + }, [liveDetectionFailed, liveSearchKeys, query, statsOrigin]); const loadStats = useCallback(async (): Promise => { const requestId = ++statsLoadSequence.current; - const nextStats = await window.sessionSearch.getStats({ period: statsPeriod }); + const nextStats = await window.sessionSearch.getStats({ + period: statsPeriod, + origin: statsOrigin, + }); if (requestId === statsLoadSequence.current) setStats(nextStats); - }, [statsPeriod]); + }, [statsOrigin, statsPeriod]); const refreshStats = useCallback(async (): Promise => { setStatsRefreshing(true); @@ -223,6 +231,8 @@ export function useWorkbenchOverview(language: LanguageMode) { stats, statsPeriod, setStatsPeriod, + statsOrigin, + setStatsOrigin, statsRefreshing, statsFeedback, quotas, diff --git a/apps/main-2.0/src/renderer/src/features/workbench/workbench-page.tsx b/apps/main-2.0/src/renderer/src/features/workbench/workbench-page.tsx index 452d9c318..d9dca15f2 100644 --- a/apps/main-2.0/src/renderer/src/features/workbench/workbench-page.tsx +++ b/apps/main-2.0/src/renderer/src/features/workbench/workbench-page.tsx @@ -31,6 +31,7 @@ import type { WorkflowWorkbenchItem } from "../../../../shared/ipc/automation"; import type { TeamChatRoomSummary } from "../../../../shared/team-chat"; import type { SessionSearchResult, + SessionOriginFilter, SessionDailyTokenUsage, SessionStats, SessionStatsPeriod, @@ -59,6 +60,7 @@ import { } from "../../session-ui"; const PERIODS: SessionStatsPeriod[] = ["today", "sevenDay", "thirtyDay", "allTime"]; +const ORIGINS: SessionOriginFilter[] = ["ordinary", "agentrecall", "all"]; const WORKBENCH_CARD_ORDER_STORAGE_KEY = "agent-recall.workbench-card-order.v2"; export const DEFAULT_WORKBENCH_CARD_ORDER = [ @@ -123,6 +125,7 @@ function loadWorkbenchCardOrder(): WorkbenchCardId[] { export interface WorkbenchPageProps { stats: SessionStats; statsPeriod: SessionStatsPeriod; + statsOrigin: SessionOriginFilter; statsRefreshing: boolean; statsFeedback: StatsFeedback; quotas: UsageQuotaSnapshot; @@ -135,6 +138,7 @@ export interface WorkbenchPageProps { platform: NodeJS.Platform; language: LanguageMode; onStatsPeriodChange: (period: SessionStatsPeriod) => void; + onStatsOriginChange: (origin: SessionOriginFilter) => void; onRefreshStats: () => void; onRefreshQuotas: () => void; onOpenSettings: () => void; @@ -171,6 +175,7 @@ export interface WorkbenchPageProps { export function WorkbenchPage({ stats, statsPeriod, + statsOrigin, statsRefreshing, statsFeedback, quotas, @@ -183,6 +188,7 @@ export function WorkbenchPage({ platform, language, onStatsPeriodChange, + onStatsOriginChange, onRefreshStats, onRefreshQuotas, onOpenSettings, @@ -336,6 +342,22 @@ export function WorkbenchPage({
    {l("Usage", "用量")}
    +