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..c4779138e --- /dev/null +++ b/.release-notes/fix-issue-535-runtime-sessions.md @@ -0,0 +1,8 @@ +# 归类 AgentRecall 发起的 Runtime 会话 + + + +## 新增功能 + +- AgentRecall 发起且由 Runtime 返回可靠 Session 引用的会话现在可通过“普通会话”右侧的“AgentRecall 调用”切换项单独查看,并可从切换项的折叠菜单按 `workflow`、`eval`、`chat`、`agent`、`skill` 和 `system` 类型筛选,不再挤占普通会话列表;用量和项目计数也采用相同口径。 +- Session 详情和 Workflow、Eval、Team Chat 业务记录现在提供精确的双向入口;Runtime 未返回 Session 引用时会保留调用记录并明确说明,不再根据目录变化、标题、路径或时间猜测归属。 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..e2a6d3bb0 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,9 @@ 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"; + +const MAX_STDERR_CHARS = 8_000; export interface HermesRunOptions { executable: string; @@ -21,11 +24,16 @@ export class HermesRunner { constructor(private readonly options: HermesRunOptions) {} async start(): Promise { - const args = ["-z", this.options.prompt]; + // Requires a Hermes CLI whose `chat` subcommand supports `--quiet`, + // `--query`, and `--source tool` and emits `session_id: ` on stderr. + // Older builds only accept the bare `-z ` form; an unsupported + // flag exits non-zero and surfaces as a failed invocation. + 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, @@ -43,6 +51,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(); }); @@ -50,6 +78,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) => { @@ -64,6 +94,11 @@ export class HermesRunner { proc.once("exit", (code) => { finish(() => { const content = stdout.trim(); + // Include the unterminated final line: the CLI may exit without a + // trailing newline. A process killed mid-write of the reference + // line could bind a truncated id, which can only produce an + // unmatchable binding, never a misattribution. + reportSessionReference(true); if (!this.stopping && code === 0) { if (content) this.options.onEvent({ type: "completed", content }); else this.options.onEvent({ type: "error", error: "Hermes completed without assistant text." }); @@ -93,3 +128,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..0455a499c 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; @@ -59,10 +60,13 @@ export class OpenClawRunner { constructor(private readonly options: OpenClawRunOptions) {} async start(): Promise { + // Requires an OpenClaw CLI that keys agent runs by `--session-id` + // (earlier builds used `--session-key`); an unsupported flag exits + // non-zero and surfaces as a failed invocation. const args = [ "agent", - "--session-key", - this.options.sessionKey, + "--session-id", + this.options.sessionId, "--message", this.options.prompt, "--json", @@ -82,6 +86,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/interactive-session-manager.ts b/apps/main-2.0/src/automation/engine/main/agents/runtime/interactive-session-manager.ts index dd1df115e..a7ba05c55 100644 --- a/apps/main-2.0/src/automation/engine/main/agents/runtime/interactive-session-manager.ts +++ b/apps/main-2.0/src/automation/engine/main/agents/runtime/interactive-session-manager.ts @@ -1,4 +1,8 @@ -import type { InteractiveSession, InteractiveSessionContext } from "./runtime-driver"; +import type { + InteractiveSession, + InteractiveSessionContext, + InteractiveSessionInterruption, +} from "./runtime-driver"; import { ProcessLease } from "../shared/process-lease"; interface InteractiveSessionManagerOptions { @@ -67,10 +71,10 @@ export class InteractiveSessionManager { await run; } - async interrupt(chatId: string): Promise { + async interrupt(chatId: string, interruption?: InteractiveSessionInterruption): Promise { const managed = this.sessions.get(chatId); if (!managed) return; - await managed.session.interrupt(); + await managed.session.interrupt(interruption); } async dispose(chatId: string, reason: "idle_timeout" | "app_shutdown" | "error"): Promise { 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..9f3010588 --- /dev/null +++ b/apps/main-2.0/src/automation/engine/main/agents/runtime/native-session-reporting.test.ts @@ -0,0 +1,176 @@ +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" }); + }); + + 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-driver.ts b/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-driver.ts index 592103c8d..373936d18 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, @@ -28,6 +29,11 @@ export interface InteractiveSessionSnapshot { runtimeConversation?: RuntimeConversation; } +export interface InteractiveSessionInterruption { + status: "cancelled" | "timed_out"; + error?: unknown; +} + export interface InteractiveSessionContext extends RuntimeRequest { chatId: string; configuredAgentId: string; @@ -52,6 +58,7 @@ export interface RuntimeWorkflowRequestContext extends RuntimeRequest { workDir: string; onEvent?: ((event: WorkflowAgentEvent) => void) | undefined; signal?: AbortSignal | undefined; + reportExecutionReference?: ((reference: RuntimeExecutionReference) => void) | undefined; } export interface RuntimeChannelTestContext { @@ -59,6 +66,12 @@ export interface RuntimeChannelTestContext { channelId: string; modelId: string; workDir: string; + /** Stable identifier shared by the channel-test request and its logs. */ + 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; } @@ -71,7 +84,7 @@ export interface InteractiveSession { reconfigure(context: InteractiveSessionContext): void; ensureAttached(): Promise; sendPrompt(prompt: string): Promise; - interrupt(): Promise; + interrupt(interruption?: InteractiveSessionInterruption): Promise; detach(reason: "idle_timeout" | "app_shutdown" | "error"): Promise; detachIfStillExpired(input: { expectedGeneration: number; 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..2fa32bfa3 --- /dev/null +++ b/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-invocation-recorder.ts @@ -0,0 +1,102 @@ +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 = + | "pending" + | "completed" + | "failed" + | "cancelled" + | "timed_out"; + +/** Whether a dispatch created its bound Runtime Session or resumed an existing one. */ +export type RuntimeSessionRelation = "created" | "continued"; + +/** Data written before a Runtime process is dispatched. */ +export interface RuntimeInvocationStart { + /** Stable identifier shared by the invocation row and all bindings. */ + id: string; + /** Product process that owns the invocation record. */ + initiator: "agentrecall"; + /** Business surface and owner identifiers supplied by the caller. */ + invocation: RuntimeInvocationRequest; + /** Runtime driver that receives the dispatch. */ + runtimeId: AgentId; + /** Optional channel selected for this dispatch. */ + channelId?: string; + /** Execution environment containing the native Runtime Session. */ + environmentId?: string; + /** Unix epoch timestamp when the pending record is created. */ + startedAt: number; +} + +/** Explicit link between a Runtime invocation and its native Session or Turn. */ +export interface RuntimeSessionBinding { + /** Runtime driver that owns the native Session identifier. */ + runtimeId: AgentId; + /** Optional channel selected for this Session. */ + channelId?: string; + /** Execution environment containing the native Session. */ + environmentId?: string; + /** Native Session identifier emitted by the Runtime. */ + sessionId: string; + /** Optional native Turn identifier emitted with the Session reference. */ + turnId?: string; + /** Whether the invocation created or continued the Session. */ + relation: RuntimeSessionRelation; + /** Unix epoch timestamp when the binding was observed. */ + boundAt: number; +} + +/** Persistence boundary for Runtime invocation lifecycle and Session bindings. */ +export interface RuntimeInvocationRecorder { + /** Persists a pending invocation before dispatch begins. */ + begin(input: RuntimeInvocationStart): Promise; + /** Persists an explicit native Session or Turn binding. */ + bind(invocationId: string, binding: RuntimeSessionBinding): Promise; + /** Persists the terminal status and optional failure message. */ + finish( + invocationId: string, + status: Exclude, + finishedAt: number, + error?: string, + ): Promise; +} + +/** 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 new file mode 100644 index 000000000..0d65e4e1e --- /dev/null +++ b/apps/main-2.0/src/automation/engine/main/agents/runtime/runtime-router-invocation.test.ts @@ -0,0 +1,584 @@ +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 { runtimeInvocationErrorMessage } 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-preferred", "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("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) => { + 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("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) => { + 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"); + }); + + test("does not bind the requested Session when continuation fails before Runtime reports a reference", async () => { + const events: string[] = []; + const runtimeDriver = driver(async () => { + throw new Error("resume failed before attach"); + }); + const router = new RuntimeRouter( + new RuntimeDriverRegistry([runtimeDriver]), + recorder(events), + () => 2_500, + () => "invocation-resume-before-reference", + ); + + await expect(router.askWorkflow(request(conversation("thread-requested")))) + .rejects.toThrow("resume failed before attach"); + expect(events).toEqual([ + "begin:workflow", + "finish:failed", + ]); + }); + + test("propagates one invocation id through the Runtime request and emitted events", async () => { + const events: string[] = []; + const onEvent = vi.fn(); + const runtimeDriver = driver(async (input) => { + expect(input.invocationId).toBe("invocation-propagated"); + expect(input.environmentId).toBe("ssh-dev"); + input.onEvent?.({ requestId: input.requestId, type: "delta", content: "working" }); + input.reportExecutionReference?.({ sessionId: "thread-propagated" }); + return { content: "done" }; + }); + const router = new RuntimeRouter( + new RuntimeDriverRegistry([runtimeDriver]), + recorder(events), + () => 3_000, + () => "invocation-propagated", + ); + + 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 () => { + 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) => { + input.reportExecutionReference?.({ sessionId: "thread-new" }); + return { content: "done", runtimeConversation: conversation("thread-new") }; + }); + const router = new RuntimeRouter( + new RuntimeDriverRegistry([runtimeDriver]), + recorder(events), + () => 4_000, + () => "invocation-fallback", + ); + + await router.askWorkflow({ + ...request(conversation("thread-old")), + continuationPolicy: "resume-preferred", + }); + + expect(events).toContain("bind:thread-new:created"); + expect(events).not.toContain("bind:thread-new:continued"); + expect(events).not.toContain("bind:thread-old:continued"); + }); + + test("reports binding failures instead of publishing a successful Workflow result", async () => { + const bindingError = new Error("binding failed"); + const failingRecorder: RuntimeInvocationRecorder = { + begin: vi.fn(async () => undefined), + bind: vi.fn(async () => { throw bindingError; }), + finish: vi.fn(async () => undefined), + }; + const runtimeDriver = driver(async (input) => { + input.reportExecutionReference?.({ sessionId: "thread-unbound" }); + return { content: "done" }; + }); + const router = new RuntimeRouter( + new RuntimeDriverRegistry([runtimeDriver]), + failingRecorder, + () => 5_000, + () => "invocation-bind-failure", + ); + + await expect(router.askWorkflow(request())).rejects.toThrow("binding failed"); + }); + + test("records a null one-shot exit as cancelled", async () => { + const events: string[] = []; + const onExit = vi.fn(); + const runtimeDriver: RuntimeDriver = { + ...driver(async () => ({ content: "unused" })), + surfaceSupport: [{ + surface: "task", + executionModes: ["oneshot"], + continuationPolicies: ["fresh"], + }], + createOneShotExecutor: (input) => ({ + start: async () => { input.onExit(null); }, + stop: async () => undefined, + }), + }; + const router = new RuntimeRouter( + new RuntimeDriverRegistry([runtimeDriver]), + recorder(events), + () => 6_000, + () => "invocation-cancelled", + ); + const executor = router.createOneShotExecutor({ + runId: "task-1", + runKind: "task", + runtimeId: "codex", + executionMode: "oneshot", + continuationPolicy: "fresh", + runtimeConfig: { model: "default" }, + invocation: { surface: "agent", role: "task", ownerReference: { taskId: "task-1" } }, + runtime, + channelId: "codex-default", + prompt: "Run it", + workDir: "/workspace", + developerInstructions: "", + emit: vi.fn(), + onExit, + }); + + await executor.start(); + + expect(events).toContain("finish:cancelled"); + expect(events).not.toContain("finish:completed"); + expect(onExit).toHaveBeenCalledWith(null); + }); + + test("keeps an interactive timeout status when interrupting the active prompt", async () => { + const events: string[] = []; + const durableRecorder = recorder(events); + let releasePrompt!: () => void; + const pendingPrompt = new Promise((resolve) => { + releasePrompt = resolve; + }); + const runtimeDriver: RuntimeDriver = { + ...driver(async () => ({ content: "unused" })), + surfaceSupport: [{ + surface: "chat", + executionModes: ["interactive"], + continuationPolicies: ["fresh"], + }], + createInteractiveSession: () => ({ + reconfigure: vi.fn(), + ensureAttached: async () => undefined, + sendPrompt: async () => pendingPrompt, + interrupt: async () => { + releasePrompt(); + }, + detach: async () => undefined, + detachIfStillExpired: async () => undefined, + snapshot: () => ({ + runtimeState: { + executionStyle: "interactive", + attachmentState: "running", + attachmentGeneration: 1, + capabilities: { + supportsInProcessConversationResume: true, + supportsResumeAfterDetach: true, + supportsResumeAfterAppRestart: true, + supportsTurnResume: true, + supportsInterrupt: true, + supportsContinue: true, + supportsApprovalRequests: true, + supportsUserInputRequests: true, + }, + }, + }), + }), + }; + const router = new RuntimeRouter( + new RuntimeDriverRegistry([runtimeDriver]), + durableRecorder, + () => 6_500, + () => "invocation-timeout", + ); + const session = router.createInteractiveSession({ + chatId: "workflow-draft:workflow-1", + configuredAgentId: "agent-1", + runtimeId: "codex", + executionMode: "interactive", + continuationPolicy: "fresh", + runtimeConfig: { model: "default" }, + invocation: { + surface: "workflow", + role: "draft", + ownerReference: { workflowId: "workflow-1", requestId: "request-1" }, + }, + runtime, + channelId: "codex-default", + workDir: "/workspace", + developerInstructions: "", + emit: vi.fn(), + }); + const sending = session.sendPrompt("Plan it"); + await vi.waitFor(() => expect(durableRecorder.begin).toHaveBeenCalled()); + const timeoutError = new Error("Workflow planning agent timed out"); + + await session.interrupt({ status: "timed_out", error: timeoutError }); + await expect(sending).resolves.toBeUndefined(); + + expect(events.filter((event) => event.startsWith("finish:"))).toEqual(["finish:timed_out"]); + expect(durableRecorder.finish).toHaveBeenCalledWith( + "invocation-timeout", + "timed_out", + 6_500, + "Workflow planning agent timed out", + ); + }); + + test("publishes a terminal one-shot event only after the invocation is durable", async () => { + const finishGate = deferred(); + const emitted = vi.fn(); + const onExit = vi.fn(); + const durableRecorder: RuntimeInvocationRecorder = { + begin: vi.fn(async () => undefined), + bind: vi.fn(async () => undefined), + finish: vi.fn(async () => finishGate.promise), + }; + const runtimeDriver: RuntimeDriver = { + ...driver(async () => ({ content: "unused" })), + surfaceSupport: [{ + surface: "task", + executionModes: ["oneshot"], + continuationPolicies: ["fresh"], + }], + createOneShotExecutor: (input) => ({ + start: async () => { + input.emit({ type: "completed" }); + input.onExit(0); + }, + stop: async () => undefined, + }), + }; + const router = new RuntimeRouter( + new RuntimeDriverRegistry([runtimeDriver]), + durableRecorder, + () => 7_000, + () => "invocation-durable", + ); + const executor = router.createOneShotExecutor({ + runId: "task-2", + runKind: "task", + runtimeId: "codex", + executionMode: "oneshot", + continuationPolicy: "fresh", + runtimeConfig: { model: "default" }, + invocation: { surface: "agent", role: "task", ownerReference: { taskId: "task-2" } }, + runtime, + channelId: "codex-default", + prompt: "Run it", + workDir: "/workspace", + developerInstructions: "", + emit: emitted, + onExit, + }); + + const start = executor.start(); + await vi.waitFor(() => expect(durableRecorder.finish).toHaveBeenCalled()); + expect(emitted).not.toHaveBeenCalled(); + expect(onExit).not.toHaveBeenCalled(); + + finishGate.resolve(); + await start; + + expect(emitted).toHaveBeenCalledWith(expect.objectContaining({ + type: "completed", + invocationId: "invocation-durable", + })); + expect(onExit).toHaveBeenCalledWith(0); + }); +}); + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} 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..55567f555 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,11 +1,22 @@ +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"; import type { InteractiveSession, InteractiveSessionContext, + InteractiveSessionInterruption, RuntimeChannelTestContext, RuntimeDriver, RuntimeSessionCleanupContext, @@ -14,21 +25,38 @@ import type { } from "./runtime-driver"; import type { RuntimeCapabilities } from "./runtime-capabilities"; import type { RuntimeStateCodec } from "./runtime-state-codec"; +import { + 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; continuationPolicy: RuntimeContinuationPolicy; runtimeConversation?: RuntimeConversation; + invocationId?: string; + environmentId?: string; }; export class RuntimeRouter { - constructor(private readonly registry: RuntimeDriverRegistry) {} + constructor( + private readonly registry: RuntimeDriverRegistry, + private readonly invocationRecorder: RuntimeInvocationRecorder = MISSING_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); } + /** Returns whether a registered Runtime can serve the requested application surface. */ supportsSurface(runtimeId: AgentId, surface: RuntimeSurface): boolean { return this.registry .maybeDriverFor(runtimeId) @@ -41,7 +69,88 @@ export class RuntimeRouter { if (!driver.createOneShotExecutor) { throw new Error(`${context.runtimeId} runtime does not provide one-shot execution for ${surface}.`); } - return driver.createOneShotExecutor(input); + const invocationId = this.createInvocationId(); + let lifecycle: RuntimeInvocationLifecycle | undefined; + let cancelling = false; + let exitDelivered = false; + let persistenceFailureReported = false; + let callbackQueue = Promise.resolve(); + const emit = input.emit; + const onExit = input.onExit; + const reportPersistenceFailure = (error: unknown): void => { + if (persistenceFailureReported) return; + persistenceFailureReported = true; + const message = error instanceof Error ? error.message : String(error); + emit({ + type: "error", + error: `Runtime invocation tracking failed: ${message}`, + invocationId, + }); + if (!exitDelivered) { + exitDelivered = true; + onExit(1); + } + }; + const enqueue = (operation: () => Promise): void => { + callbackQueue = callbackQueue.then(operation).catch((error) => { + reportPersistenceFailure(error); + }); + }; + const wrappedInput: AgentExecutionContext = { + ...input, + invocationId, + emit: (event) => { + enqueue(async () => { + if (lifecycle) { + await this.observeAgentEvent( + lifecycle, + event, + cancelling ? { status: "cancelled" } : undefined, + ); + } + emit({ ...event, invocationId }); + }); + }, + onExit: (code) => { + enqueue(async () => { + if (lifecycle) { + const status: Exclude = + code === null || cancelling ? "cancelled" : code !== 0 ? "failed" : "completed"; + await lifecycle.finish(status); + } + if (exitDelivered) return; + exitDelivered = true; + onExit(code); + }); + }, + }; + const executor = driver.createOneShotExecutor(wrappedInput); + return { + start: async () => { + lifecycle = this.createLifecycle({ ...input, invocationId }, input.channelId); + await lifecycle.begin(); + try { + await executor.start(); + await callbackQueue; + } catch (error) { + try { + await callbackQueue; + } finally { + await lifecycle.finish(this.statusForError(error), error); + } + throw error; + } + }, + stop: async () => { + cancelling = true; + try { + await executor.stop(); + await callbackQueue; + } finally { + await lifecycle?.finish("cancelled"); + } + }, + }; } createInteractiveSession(context: InteractiveSessionContext): InteractiveSession { @@ -49,7 +158,92 @@ 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 interruption: InteractiveSessionInterruption | undefined; + let callbackQueue = Promise.resolve(); + const wrap = (next: InteractiveSessionContext): InteractiveSessionContext => ({ + ...next, + emit: (event) => { + callbackQueue = callbackQueue.then(async () => { + if (lifecycle) await this.observeAgentEvent(lifecycle, event, interruption); + next.emit({ ...event, ...(lifecycle ? { invocationId: lifecycle.id } : {}) }); + }); + void callbackQueue.catch(() => undefined); + }, + }); + const session = driver.createInteractiveSession(wrap(input)); + const ensureInvocation = async (): Promise => { + if (lifecycle && !lifecycle.isFinished()) return lifecycle; + interruption = undefined; + lifecycle = this.createLifecycle(currentInput, currentInput.channelId); + await lifecycle.begin(); + currentInput = { ...currentInput, invocationId: lifecycle.id }; + session.reconfigure(wrap(currentInput)); + return lifecycle; + }; + return { + reconfigure: (next) => { + currentInput = next; + session.reconfigure(wrap(next)); + }, + ensureAttached: async () => { + const active = await ensureInvocation(); + try { + await session.ensureAttached(); + await callbackQueue; + } catch (error) { + try { + await callbackQueue; + } finally { + await active.finish( + interruption?.status ?? this.statusForError(error), + interruption?.error ?? error, + ); + } + throw error; + } + }, + sendPrompt: async (prompt) => { + const active = await ensureInvocation(); + try { + await session.sendPrompt(prompt); + await callbackQueue; + await active.finish(interruption?.status ?? "completed", interruption?.error); + } catch (error) { + try { + await callbackQueue; + } finally { + await active.finish( + interruption?.status ?? this.statusForError(error), + interruption?.error ?? error, + ); + } + throw error; + } + }, + interrupt: async (requestedInterruption) => { + interruption ??= requestedInterruption ?? { status: "cancelled" }; + try { + await session.interrupt(); + await callbackQueue; + } finally { + await lifecycle?.finish(interruption.status, interruption.error); + } + }, + detach: async (reason) => { + try { + await session.detach(reason); + await callbackQueue; + } 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 +251,55 @@ 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(); + const reportExecutionReference = normalizedInput.reportExecutionReference; + let callbackQueue = Promise.resolve(); + const enqueue = (operation: () => Promise): void => { + callbackQueue = callbackQueue.then(operation); + void callbackQueue.catch(() => undefined); + }; + try { + const response = await driver.askWorkflow({ + ...normalizedInput, + invocationId: lifecycle.id, + reportExecutionReference: (reference) => { + const invocationReference = { ...reference, invocationId: lifecycle.id }; + enqueue(async () => { + await lifecycle.bindReference(invocationReference); + reportExecutionReference?.(invocationReference); + }); + }, + onEvent: (event) => { + enqueue(async () => { + if (event.type === "completed" && event.runtimeConversation) { + await lifecycle.bindConversation(event.runtimeConversation); + } + normalizedInput.onEvent?.({ ...event, invocationId: lifecycle.id }); + }); + }, + }); + await callbackQueue; + if (response.runtimeConversation) await lifecycle.bindConversation(response.runtimeConversation); + if (response.executionReference) await lifecycle.bindReference(response.executionReference); + await lifecycle.finish("completed"); + return normalizedInput.invocationId + ? { + ...response, + executionReference: { + ...response.executionReference, + invocationId: lifecycle.id, + }, + } + : response; + } catch (error) { + try { + await callbackQueue; + } finally { + await lifecycle.finish(this.statusForError(error, normalizedInput.signal), error); + } + throw error; + } } async testChannel(runtimeId: AgentId, input: RuntimeChannelTestContext): Promise { @@ -65,7 +307,39 @@ 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", + environmentId: input.environmentId, + invocation: { + surface: "system", + role: "channel_test", + ownerReference: { channelId: input.channelId }, + }, + }, input.channelId); + await lifecycle.begin(); + let callbackQueue = Promise.resolve(); + try { + 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) { + try { + await callbackQueue; + } finally { + await lifecycle.finish(this.statusForError(error), error); + } + throw error; + } } async deleteSessionArtifacts(runtimeId: AgentId, input: RuntimeSessionCleanupContext): Promise { @@ -183,4 +457,185 @@ export class RuntimeRouter { throw new Error(`${runtimeId} cannot use runtimeConversation owned by ${conversation.runtimeId}.`); } } + + private createLifecycle( + input: { + runtimeId: AgentId; + continuationPolicy: RuntimeContinuationPolicy; + runtimeConversation?: RuntimeConversation; + invocationId?: string; + environmentId?: string; + invocation: RuntimeInvocationRequest; + }, + channelId?: string, + ): RuntimeInvocationLifecycle { + return new RuntimeInvocationLifecycle({ + recorder: this.invocationRecorder, + invocationId: input.invocationId ?? this.createInvocationId(), + invocation: input.invocation, + runtimeId: input.runtimeId, + channelId, + environmentId: input.environmentId, + continuedSessionId: input.runtimeConversation + ? this.sessionIdFromConversation(input.runtimeConversation) + : undefined, + now: this.now, + sessionIdFromConversation: (conversation) => this.sessionIdFromConversation(conversation), + }); + } + + private async observeAgentEvent( + lifecycle: RuntimeInvocationLifecycle, + event: AgentEvent, + interruption?: InteractiveSessionInterruption, + ): Promise { + if (event.type === "runtime_conversation") await lifecycle.bindConversation(event.runtimeConversation); + else if (event.type === "completed") { + await lifecycle.finish(interruption?.status ?? "completed", interruption?.error); + } + else if (event.type === "error") { + await lifecycle.finish( + interruption?.status ?? this.statusForError(event.error), + interruption?.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 code = error instanceof Error ? String((error as NodeJS.ErrnoException).code ?? "") : ""; + const message = error instanceof Error ? error.message : String(error); + if (name === "TimeoutError" || code === "ETIMEDOUT" || /\btimed out\b/iu.test(message)) return "timed_out"; + if (signal?.aborted || name === "AbortError" || /\binterrupted\b|\bcancelled\b|\bcanceled\b/iu.test(message)) { + return "cancelled"; + } + return "failed"; + } +} + +class RuntimeInvocationLifecycle { + private writeQueue: Promise = Promise.resolve(); + private finished = false; + /** + * Runtime dispatch only spawns CLI subprocesses on the indexing machine, so + * invocations and bindings default to the reserved `local` environment; the + * ssh/wsl environments in the sessions index are sync-only sources and never + * dispatch targets. A caller that starts dispatching on a synced environment + * must pass its environment id explicitly — the binding-match SQL keys on + * environment equality, so a wrong default silently drops attribution + * instead of misattributing the Session. + */ + private readonly environmentId: string; + + constructor(private readonly options: { + recorder: RuntimeInvocationRecorder; + invocationId: string; + invocation: RuntimeInvocationRequest; + runtimeId: AgentId; + channelId?: string; + environmentId?: string; + continuedSessionId?: string; + now: () => number; + sessionIdFromConversation: (conversation: RuntimeConversation) => string | undefined; + }) { + this.environmentId = options.environmentId ?? "local"; + } + + get id(): string { + return this.options.invocationId; + } + + async begin(): 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: this.environmentId, + startedAt: this.options.now(), + }); + } + + 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.enqueueBinding( + { + sessionId: reference.sessionId, + ...(reference.turnId ? { turnId: reference.turnId } : {}), + }, + this.options.continuedSessionId === reference.sessionId ? "continued" : "created", + ); + await this.writeQueue; + } + + private enqueueBinding( + reference: { sessionId: string; turnId?: string }, + relation: RuntimeSessionRelation, + ): void { + const binding = { + runtimeId: this.options.runtimeId, + ...(this.options.channelId ? { channelId: this.options.channelId } : {}), + environmentId: this.environmentId, + sessionId: reference.sessionId, + ...(reference.turnId ? { turnId: reference.turnId } : {}), + relation, + boundAt: this.options.now(), + } as const; + this.writeQueue = this.writeQueue.then(() => + this.options.recorder.bind(this.options.invocationId, binding)); + } + + 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 : runtimeInvocationErrorMessage(error); + const pendingWrites = this.writeQueue; + this.writeQueue = pendingWrites.catch(() => undefined).then(() => this.options.recorder.finish( + this.options.invocationId, + status, + this.options.now(), + message, + )); + try { + await pendingWrites; + } catch (writeError) { + await this.writeQueue; + throw writeError; + } + 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..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 @@ -33,6 +33,8 @@ export type EvaluationExecutionRequest = { configuredAgentId: string; prompt: string; developerInstructions?: string; + role: string; + ownerReference: Record; }; export interface EvaluationExecutionResult { @@ -64,12 +66,14 @@ 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. */ 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, @@ -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-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.test.ts b/apps/main-2.0/src/automation/engine/main/hub/agent-hub.test.ts index 80333b517..7242ffb7b 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 @@ -7,7 +7,7 @@ import { DEFAULT_MODEL_ID } from "../../shared/models"; import { projectNodeStates } from "../../shared/workflow-v2/runtime-utils"; import { createWorkflowV2InlineScriptSpec } from "../../shared/workflow-v2/definition"; import { createDirectWorkflowTransactionPolicy } from "../../shared/workflow-v2/transaction"; -import type { AgentChannel, AgentId, ChatRuntimeSessionState, ConfiguredAgent, RuntimeConversation } from "../../shared/types"; +import type { AgentChannel, AgentEvent, AgentId, ChatRuntimeSessionState, ConfiguredAgent, RuntimeConversation } from "../../shared/types"; import { createRuntimeDriverRegistry, RuntimeDriverRegistry } from "./runtime/executor/agent-executor"; import type { AgentExecutionContext, @@ -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", @@ -1239,6 +1241,109 @@ describe("AgentHub chat sessions", () => { } }); + test("waits for an idle workflow draft invocation to finish timing out before rejecting", async () => { + vi.useFakeTimers(); + try { + const hub = new AgentHub(); + (hub as any).runtimes.set("codex", { + id: "codex", + label: "Codex", + command: "codex", + version: "test", + available: true, + }); + const interactiveSessions = (hub as any).interactiveSessions; + let emitAfterTimeout: ((event: AgentEvent) => void) | undefined; + vi.spyOn(interactiveSessions, "dispatch").mockImplementation((...args: unknown[]) => { + const context = args[1] as { emit: (event: AgentEvent) => void }; + emitAfterTimeout = context.emit; + return new Promise(() => undefined); + }); + let finishInterrupt: (() => void) | undefined; + const interrupt = vi.spyOn(interactiveSessions, "interrupt").mockImplementation( + () => new Promise((resolve) => { + finishInterrupt = resolve; + }), + ); + + const response = (hub as any).askWorkflowDraftAgent({ + workflowId: "workflow-timeout", + requestId: "request-timeout", + prompt: "Plan the workflow", + configuredAgentId: TEST_CODEX_AGENT_ID, + modelId: DEFAULT_MODEL_ID, + workDir: "/workspace", + starting: true, + }); + let responseSettled = false; + void response.then( + () => { responseSettled = true; }, + () => { responseSettled = true; }, + ); + const rejection = expect(response).rejects.toThrow( + "Workflow planning agent timed out after 10 minutes without activity", + ); + await vi.advanceTimersByTimeAsync(10 * 60_000); + + expect(interrupt).toHaveBeenCalledWith( + "workflow-draft:workflow-timeout", + { + status: "timed_out", + error: expect.objectContaining({ + message: "Workflow planning agent timed out after 10 minutes without activity", + }), + }, + ); + expect(responseSettled).toBe(false); + + emitAfterTimeout?.({ type: "completed", content: "Late completion" }); + await Promise.resolve(); + expect(responseSettled).toBe(false); + + finishInterrupt?.(); + await rejection; + expect(responseSettled).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + test("rejects an idle workflow draft invocation when its timeout interruption fails", async () => { + vi.useFakeTimers(); + try { + const hub = new AgentHub(); + (hub as any).runtimes.set("codex", { + id: "codex", + label: "Codex", + command: "codex", + version: "test", + available: true, + }); + const interactiveSessions = (hub as any).interactiveSessions; + vi.spyOn(interactiveSessions, "dispatch").mockImplementation( + () => new Promise(() => undefined), + ); + const interruptError = new Error("Failed to persist the timed out invocation"); + vi.spyOn(interactiveSessions, "interrupt").mockRejectedValue(interruptError); + + const response = (hub as any).askWorkflowDraftAgent({ + workflowId: "workflow-timeout", + requestId: "request-timeout", + prompt: "Plan the workflow", + configuredAgentId: TEST_CODEX_AGENT_ID, + modelId: DEFAULT_MODEL_ID, + workDir: "/workspace", + starting: true, + }); + const rejection = expect(response).rejects.toThrow(interruptError.message); + + await vi.advanceTimersByTimeAsync(10 * 60_000); + await rejection; + } finally { + vi.useRealTimers(); + } + }); + test("stores tool calls and results as structured chat events", () => { const hub = new AgentHub(); const chatId = hub.snapshot().activeChatId!; @@ -3361,11 +3466,12 @@ fs.writeFileSync(${JSON.stringify(argsPath)}, process.argv.slice(2).join("\\n") executionReference: { sessionId: "thread-1", turnId: "turn-1" }, }); expect(events).toEqual([ - { requestId: "workflow-test", type: "delta", content: "artifact-1" }, + { requestId: "workflow-test", type: "delta", content: "artifact-1", invocationId: expect.any(String) }, { requestId: "workflow-test", type: "completed", content: "artifact-1", + invocationId: expect.any(String), runtimeConversation: runtimeConversation("codex", { native: { threadId: "thread-1" } }), }, ]); @@ -3428,6 +3534,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, @@ -3486,11 +3593,12 @@ fs.writeFileSync(${JSON.stringify(argsPath)}, process.argv.slice(2).join("\\n") executionReference: { sessionId: "claude-session-7" }, }); expect(events).toEqual([ - { requestId: "claude-workflow-test", type: "delta", content: "workflow-sdk" }, + { requestId: "claude-workflow-test", type: "delta", content: "workflow-sdk", invocationId: expect.any(String) }, { requestId: "claude-workflow-test", type: "completed", content: "workflow-sdk", + invocationId: expect.any(String), runtimeConversation: runtimeConversation("claude", { native: { sessionId: "claude-session-7" } }), }, ]); @@ -3558,6 +3666,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..487dcc73d 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,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 { + 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"; @@ -424,6 +429,7 @@ export class AgentHub { runtimeDrivers?: RuntimeDriverRegistry, modelCatalogDiscoverer: ModelCatalogDiscoverer = discoverChannelModels, private readonly workflowMessageProvider?: WorkflowMessageProvider, + runtimeInvocationRecorder?: RuntimeInvocationRecorder, ) { this.executables = resolveRuntimeExecutables(executables); this.modelCatalogDiscoverer = modelCatalogDiscoverer; @@ -437,7 +443,12 @@ export class AgentHub { mcpServersForAgent: (configuredAgentId, allowedMcpTools) => this.boundMcpServersForAgent(configuredAgentId, allowedMcpTools), requestApproval: this.runtimeApprovals.request, }); - this.runtimeRouter = new RuntimeRouter(this.runtimeDrivers); + // 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(), @@ -590,6 +601,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 +1499,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 +2372,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); @@ -2442,11 +2468,12 @@ export class AgentHub { let content = ""; let latestRuntimeConversation = runtimeConversation; let settled = false; + let timingOut = false; let timeout: ReturnType | undefined; return new Promise((resolve, reject) => { const settle = (callback: () => void): void => { - if (settled) return; + if (settled || timingOut) return; settled = true; timeout?.clear(); callback(); @@ -2455,13 +2482,25 @@ export class AgentHub { const normalized = error instanceof Error ? error : new Error(String(error)); settle(() => reject(normalized)); }; + const finishTimeout = (error: unknown): void => { + if (settled || !timingOut) return; + const normalized = error instanceof Error ? error : new Error(String(error)); + timingOut = false; + settled = true; + reject(normalized); + }; timeout = createWorkflowAgentTimeout({ timeoutMs: WORKFLOW_AGENT_IDLE_TIMEOUT_MS, onTimeout: () => { + if (settled || timingOut) return; + timingOut = true; + timeout?.clear(); + const error = new Error("Workflow planning agent timed out after 10 minutes without activity"); this.runtimeApprovals.cancelOwner(sessionKey); - void this.interactiveSessions.interrupt(sessionKey); - fail(new Error("Workflow planning agent timed out after 10 minutes without activity")); + void this.interactiveSessions + .interrupt(sessionKey, { status: "timed_out", error }) + .then(() => finishTimeout(error), finishTimeout); }, }); @@ -2480,11 +2519,16 @@ 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"), emit: (event) => { - if (settled) return; + if (settled || timingOut) return; timeout?.refresh(); if (emitWorkflowAgentApprovalEvent({ requestId: input.requestId, onEvent }, event)) return; if (event.type === "runtime_conversation") { @@ -2527,7 +2571,7 @@ export class AgentHub { if (event.type === "error") fail(new Error(event.error)); }, syncState: (state) => { - if (settled) return; + if (settled || timingOut) return; if (state.runtimeConversation) { latestRuntimeConversation = this.runtimeRouter.cloneConversation(state.runtimeConversation); } 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..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 @@ -87,6 +87,14 @@ 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, + agentId: input.chat.configuredAgentId, + }, + }, ...(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/chat/agent-hub-run-events.ts b/apps/main-2.0/src/automation/engine/main/hub/chat/agent-hub-run-events.ts index 7f68e5425..f6166476b 100644 --- a/apps/main-2.0/src/automation/engine/main/hub/chat/agent-hub-run-events.ts +++ b/apps/main-2.0/src/automation/engine/main/hub/chat/agent-hub-run-events.ts @@ -115,6 +115,7 @@ export function handleAgentEvent(input: { ...("name" in event && event.name ? { name: event.name } : {}), ...("fromAgentId" in event && event.fromAgentId ? { fromAgentId: event.fromAgentId } : {}), ...("toAgentId" in event && event.toAgentId ? { toAgentId: event.toAgentId } : {}), + ...(event.invocationId ? { invocationId: event.invocationId } : {}), ...("metadata" in event && event.metadata ? { metadata: event.metadata } : {}), }); run.updatedAt = Date.now(); @@ -131,6 +132,7 @@ export function handleAgentEvent(input: { requestId: event.requestId, requestState: "live", timestamp: Date.now(), + ...(event.invocationId ? { invocationId: event.invocationId } : {}), ...(event.metadata ? { metadata: event.metadata } : {}), }); run.updatedAt = Date.now(); @@ -148,6 +150,7 @@ export function handleAgentEvent(input: { requestId: event.requestId, timestamp: Date.now(), ...(event.type === "approval_response" ? { decision: event.decision } : {}), + ...(event.invocationId ? { invocationId: event.invocationId } : {}), ...(event.metadata ? { metadata: event.metadata } : {}), }); run.updatedAt = Date.now(); 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/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-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 bc29c8728..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, @@ -187,6 +189,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..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", @@ -79,6 +83,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 +110,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..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 @@ -66,6 +66,7 @@ function workflowInput( instructionScope: "agent", onEvent: (event) => events.push(event), ...overrides, + invocation: overrides.invocation ?? { surface: "workflow" }, }, events, }; @@ -103,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({ @@ -211,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 119458029..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( @@ -124,11 +145,13 @@ 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, 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 5a7b6ccc2..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,9 +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 a62d525d3..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,9 +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 8bc7c3009..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,9 +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/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..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"; @@ -57,6 +58,7 @@ function buildTaskContext(overrides: Partial = {}): Agent emit: () => undefined, onExit: () => undefined, ...overrides, + invocation: overrides.invocation ?? { surface: "agent", role: "task" }, }; } @@ -76,6 +78,7 @@ function buildInteractiveContext( developerInstructions: "", emit: () => undefined, ...overrides, + invocation: overrides.invocation ?? { surface: "agent", role: "chat" }, }; } @@ -93,6 +96,7 @@ function buildWorkflowContext( channelId: "api-default", workDir: "C:/repo", ...overrides, + invocation: overrides.invocation ?? { surface: "workflow" }, }; } @@ -117,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", @@ -125,7 +129,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..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 @@ -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,26 @@ 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 }), + agentId: input.resolved.agent.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 } + : {}), + ...(input.run.kind === "task" && input.run.workflowNodeExecutionId + ? { executionId: input.run.workflowNodeExecutionId } + : {}), + }, + }, ...(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..f99e3f17a 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,9 @@ export function buildWorkflowAgentExecution; @@ -127,6 +130,9 @@ export function buildWorkflowAgentExecution { await service.runOneShot({ configuredAgentId: agent.id, prompt: "Complete the node", + invocation: { surface: "workflow", role: "node" }, workflowExecution: { workflowId: "workflow", runId: "run", @@ -42,10 +43,12 @@ describe("ConfiguredAgentExecutionService", () => { }); expect(execute).toHaveBeenCalledWith(expect.objectContaining({ + invocationId: expect.any(String), planningWorkflowId: "workflow", 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..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,8 +1,10 @@ +import { randomUUID } from "node:crypto"; import type { AgentRecallMcpContext, AgentChannel, ConfiguredAgent, RuntimeConversation, + RuntimeInvocationRequest, WorkflowAgentEvent, WorkflowAgentRequest, WorkflowAgentResponse, @@ -45,6 +47,7 @@ export class ConfiguredAgentExecutionService { nodeId: string; executionId: string; }; + invocation: RuntimeInvocationRequest; }, onEvent?: (event: WorkflowAgentEvent) => void, signal?: AbortSignal, @@ -71,6 +74,7 @@ export class ConfiguredAgentExecutionService { runtimeConversation?: RuntimeConversation; developerInstructions?: string; agentRecallMcp?: AgentRecallMcpContext; + invocation: RuntimeInvocationRequest; }, onEvent?: (event: WorkflowAgentEvent) => void, signal?: AbortSignal, @@ -91,6 +95,7 @@ export class ConfiguredAgentExecutionService { runtimeConversation?: RuntimeConversation; developerInstructions?: string; agentRecallMcp?: AgentRecallMcpContext; + invocation: RuntimeInvocationRequest; workflowExecution?: { workflowId: string; runId: string; @@ -117,6 +122,7 @@ export class ConfiguredAgentExecutionService { ? structuredClone(input.runtimeConversation) : undefined; const request: WorkflowAgentRequest = { + invocationId: randomUUID(), configuredAgentId: input.configuredAgentId, prompt: input.prompt, runtimeId: target.runtimeId, @@ -128,6 +134,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..50685d12c 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,6 +352,16 @@ export type ExecutionStyle = "oneshot" | "interactive"; export type RuntimeExecutionMode = ExecutionStyle; export type RuntimeContinuationPolicy = "fresh" | "resume-preferred" | "resume-required"; +/** Stable business metadata attached to every Runtime dispatch. */ +export interface RuntimeInvocationRequest { + /** Runtime caller category used for Session grouping and history labels. */ + surface: AgentRecallInvocationSurface; + /** Optional role within the selected surface, such as node or judge. */ + role?: string; + /** Exact stable identifiers used to navigate back to the owning record. */ + ownerReference?: Record; +} + export interface RuntimeConfig { model: string; reasoningEffort?: string; @@ -369,6 +385,18 @@ export interface RuntimeRequest { agentRecallMcp?: AgentRecallMcpContext; workflowNodeExecutionId?: string; allowedMcpTools?: string[]; + /** Stable identifier shared by the Runtime request, status, and emitted logs. */ + invocationId?: string; + /** + * Runtime execution environment used to scope native Session identifiers. + * Defaults to the reserved `local` environment: Runtime dispatch only spawns + * CLI subprocesses on the indexing machine. Dispatch outside that machine + * must pass the owning environment id explicitly or Session attribution is + * silently dropped. + */ + environmentId?: string; + /** Identifies the AgentRecall caller and the exact owner record for this dispatch. */ + invocation: RuntimeInvocationRequest; } export interface RuntimeResumeCapabilities { @@ -397,7 +425,7 @@ export interface ChatRuntimeSessionState { capabilities: RuntimeResumeCapabilities & RuntimeInteractionCapabilities; } -export type AgentEvent = +export type AgentEvent = ( | { type: "runtime_conversation"; runtimeConversation: RuntimeConversation } | { type: "usage"; usage: RuntimeUsage } | { type: "delta"; content: string } @@ -411,7 +439,11 @@ export type AgentEvent = | { type: "user_input_request"; requestId: string; content: string; metadata?: Record } | { type: "user_input_response"; requestId: string; content: string; metadata?: Record } | { type: "completed"; content?: string } - | { type: "error"; error: string }; + | { type: "error"; error: string } +) & { + /** Stable identifier for the Runtime invocation that emitted this event. */ + invocationId?: string; +}; export interface SendPromptRequest { prompt: string; @@ -450,6 +482,8 @@ export interface ChatEvent { requestId?: string; requestState?: InteractionRequestState; decision?: ApprovalDecision; + /** Stable Runtime invocation identifier for this persisted event. */ + invocationId?: string; metadata?: Record; } @@ -524,17 +558,23 @@ export interface WorkflowAgentResponse { } export interface RuntimeExecutionReference { + /** AgentRecall ledger row that owns this native reference. */ + invocationId?: string; sessionId?: string; turnId?: string; } -export type WorkflowAgentEvent = +export type WorkflowAgentEvent = ( | { requestId: string; type: "delta"; content: string } | { requestId: string; type: "tool_call" | "tool_result"; content: string; name?: string; metadata?: Record } | { requestId: string; type: "approval_request"; approvalRequestId: string; content: string; metadata?: Record } | { requestId: string; type: "approval_response"; approvalRequestId: string; decision: ApprovalDecision; content?: string; metadata?: Record } | { requestId: string; type: "completed"; content: string; runtimeConversation?: RuntimeConversation } - | { requestId: string; type: "error"; error: string }; + | { requestId: string; type: "error"; error: string } +) & { + /** Stable identifier for the Runtime invocation that emitted this event. */ + invocationId?: string; +}; export type AgentTeamMode = "pipeline" | "parallel" | "supervisor"; export type AgentWorkflowTargetKind = "workspace" | "task" | "custom"; 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..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; } @@ -168,7 +169,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,11 +183,16 @@ 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. */ - 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/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..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 @@ -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, @@ -200,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 } }); @@ -213,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 new file mode 100644 index 000000000..9ccf057ba --- /dev/null +++ b/apps/main-2.0/src/core/postgres/runtime-invocation-repository.ts @@ -0,0 +1,98 @@ +import type { + RuntimeInvocationRecorder, + RuntimeInvocationStart, + 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"; + +/** Stores AgentRecall Runtime invocation lifecycle records in PostgreSQL. */ +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(runtimeInvocationErrorMessage(error)) : null, + ], + ); + } + + /** 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.test.ts b/apps/main-2.0/src/core/postgres/schema.test.ts index 360eb445e..4f6bfec1a 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,101 @@ 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_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, { + migrationLock: false, + migrations: POSTGRES_MIGRATIONS, + }); + 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, 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 + on bindings.invocation_id = invocations.id + where invocations.id = 'legacy-evaluation:case-history' + `); + 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 ( + 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..b5645520a 100644 --- a/apps/main-2.0/src/core/postgres/schema.ts +++ b/apps/main-2.0/src/core/postgres/schema.ts @@ -1810,4 +1810,214 @@ 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.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, + 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, + '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), + left(error, 4000) + 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.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 + ) + 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.target_agent_id, + dispatches.task_id, + room_agents.channel_id, + row_number() OVER ( + 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 + 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, + 'agentId', target_agent_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), + left(error, 4000) + 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, 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 + 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..b096f6019 100644 --- a/apps/main-2.0/src/core/postgres/session-records.ts +++ b/apps/main-2.0/src/core/postgres/session-records.ts @@ -1,6 +1,8 @@ import { cleanTitle } from "../format-adapters"; import type { EnvironmentKind, + RuntimeInvocationSummary, + SessionOriginFilter, SessionSearchResult, SessionSource, SessionTurnMatch, @@ -57,6 +59,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,7 +158,63 @@ export const SESSION_ACTIVITY_SQL = ` ) `; -export const SESSION_SELECT_SQL = ` +/** + * SQL predicate matching a Runtime binding to its indexed Session family. + * + * Indexed Sessions do not carry a trustworthy channel identifier. When the + * same Runtime/environment/native Session id is observed on more than one + * channel, refusing to project either binding is safer than attributing one + * channel's Session to another. + */ +export const RUNTIME_SESSION_BINDING_MATCH_SQL = ` + bindings.environment_id = sessions.environment_id + and bindings.runtime_session_id = sessions.raw_id + and not exists ( + select 1 + from agent_recall.runtime_session_bindings conflicting_bindings + where conflicting_bindings.environment_id = bindings.environment_id + and conflicting_bindings.runtime_id = bindings.runtime_id + and conflicting_bindings.runtime_session_id = bindings.runtime_session_id + and nullif(conflicting_bindings.channel_id, '') is distinct from nullif(bindings.channel_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') + ) +`; + +/** SQL predicate identifying Sessions created by a persisted AgentRecall invocation. */ +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 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( ( @@ -188,8 +248,50 @@ export const SESSION_SELECT_SQL = ` where session_tags.session_key = sessions.session_key ), array[]::text[] - ) as tag_names + ) as tag_names, + ${AGENTRECALL_CREATED_SESSION_SQL} as created_by_agent_recall, + coalesce( + ( + select jsonb_agg( + history.payload order by history.started_at desc, history.invocation_id desc + ) + 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; @@ -465,7 +567,55 @@ 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), + 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 8388371a2..a15a3a4da 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,9 @@ import type { ProjectQueryOptions, ProjectSummary, ProjectTagEntry, + RuntimeInvocationSessionResolution, + RuntimeInvocationLookup, + RuntimeInvocationSummary, SessionMessage, SessionMessageEvent, SessionSearchResult, @@ -30,7 +33,10 @@ 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, postgresJsonValue, @@ -1450,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}`); @@ -1886,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 @@ -1911,6 +1919,89 @@ export class PostgresSessionRepository { return result.rows[0] ? hydrateSession(result.rows[0]) : null; } + /** Resolves an invocation owner without letting an unbound retry hide a usable Session. */ + async resolveRuntimeInvocationSession( + lookup: RuntimeInvocationLookup, + ): Promise { + if (!lookup.invocationId && Object.keys(lookup.ownerReference ?? {}).length === 0) { + return { status: "not_recorded" }; + } + const conditions = ["runtime_invocations.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("runtime_invocations.id = ?", postgresText(lookup.invocationId)); + if (lookup.surface) addCondition("runtime_invocations.surface = ?", lookup.surface); + if (lookup.role) addCondition("runtime_invocations.role = ?", postgresText(lookup.role)); + if (lookup.ownerReference && Object.keys(lookup.ownerReference).length > 0) { + addCondition("runtime_invocations.owner_reference @> ?::jsonb", postgresJsonValue(lookup.ownerReference)); + } + const invocation = (await this.database.query<{ + id: string; + status: RuntimeInvocationSummary["status"]; + }>( + ` + select runtime_invocations.id, runtime_invocations.status + from agent_recall.runtime_invocations + where ${conditions.join("\n and ")} + order by ${lookup.invocationId + ? "runtime_invocations.started_at desc, runtime_invocations.id desc" + : ` + case when exists ( + select 1 + from agent_recall.runtime_session_bindings candidate_bindings + where candidate_bindings.invocation_id = runtime_invocations.id + ) then 0 else 1 end, + runtime_invocations.started_at desc, + runtime_invocations.id desc` + } + limit 1 + `, + parameters, + )).rows[0]; + if (!invocation) return { status: "not_recorded" }; + 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.id = $1 + ) + order by ${SESSION_ACTIVITY_SQL} desc, sessions.session_key + limit 1 + `, + [invocation.id], + ); + 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 { 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..7d02a22fb 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,11 +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, @@ -35,6 +38,21 @@ const LIVE_SESSION_KEY_SQL = ` end `; +function agentRecallCreatedSurfaceSql(surface: string, bind: (value: unknown) => 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 = ${bind(surface)} + ) + `; +} + export class PostgresSessionSearchRepository { constructor(private readonly database: PostgresDatabase) {} @@ -141,6 +159,18 @@ 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, bind)); + } + 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 +268,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 +314,11 @@ 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 invocationSurfaceFilteredSessionsSql = buildFilteredSessionsSql(invocationSurfaceCountFilters); const result = await this.database.query( ` select @@ -308,10 +341,53 @@ 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]; + const invocationSurfaceCountParams = [...invocationSurfaceCountValues]; + const invocationSurfaceCountBind = (value: unknown): string => { + invocationSurfaceCountParams.push(value); + return `$${invocationSurfaceCountParams.length}`; + }; + const invocationSurfaceCountRow = (await this.database.query>( + ` + select + ${AGENT_RECALL_INVOCATION_SURFACES.map((surface) => + `count(*) filter (where ${agentRecallCreatedSurfaceSql(surface, invocationSurfaceCountBind)}) as ${surface}_count`).join(",\n ")}, + count(*) filter (where ${AGENTRECALL_CREATED_SESSION_SQL}) as all_count + ${invocationSurfaceFilteredSessionsSql} + `, + invocationSurfaceCountParams, + )).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), + }, + 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 063d9ceea..78ca142d3 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,285 @@ 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.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({ + 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" }, + })], + }); + 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 }); + 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({ ownerReference: { runId: "run-1" } })) + .resolves.toMatchObject({ status: "found", session: { sessionKey: "codex:one" } }); + 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({ + 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({ ownerReference: { 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({ ownerReference: { runId: "run-awaiting-index" } })) + .resolves.toEqual({ status: "not_indexed", invocationId: "inv-awaiting-index" }); + }); + + it("resolves the newest bound invocation when a newer retry has no Session reference", async () => { + const invocations = new PostgresRuntimeInvocationRepository(database); + await invocations.begin({ + id: "inv-retry-bound", + initiator: "agentrecall", + invocation: { surface: "team_chat", role: "member", ownerReference: { roomId: "room-retry", messageId: "message-retry" } }, + runtimeId: "codex", + channelId: "codex-default", + environmentId: "local", + startedAt: Date.parse("2026-07-25T08:00:00.000Z"), + }); + await invocations.bind("inv-retry-bound", { + runtimeId: "codex", + channelId: "codex-default", + environmentId: "local", + sessionId: "one", + relation: "created", + boundAt: Date.parse("2026-07-25T08:00:01.000Z"), + }); + await invocations.finish("inv-retry-bound", "completed", Date.parse("2026-07-25T08:00:02.000Z")); + + await invocations.begin({ + id: "inv-retry-unbound", + initiator: "agentrecall", + invocation: { surface: "team_chat", role: "member", ownerReference: { roomId: "room-retry", messageId: "message-retry" } }, + runtimeId: "codex", + channelId: "codex-default", + environmentId: "local", + startedAt: Date.parse("2026-07-25T08:00:03.000Z"), + }); + await invocations.finish("inv-retry-unbound", "failed", Date.parse("2026-07-25T08:00:04.000Z")); + + await expect(repository.resolveRuntimeInvocationSession({ + surface: "team_chat", + role: "member", + ownerReference: { roomId: "room-retry", messageId: "message-retry" }, + })).resolves.toMatchObject({ status: "found", session: { sessionKey: "codex:one" } }); + await expect(repository.resolveRuntimeInvocationSession({ invocationId: "inv-retry-unbound" })) + .resolves.toEqual({ + status: "no_session_reference", + invocationId: "inv-retry-unbound", + invocationStatus: "failed", + }); + }); + + 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("does not attribute a Session when channel-scoped bindings disagree", async () => { + await repository.upsertIndexedSession( + session("codex:channel-shared", "Channel scoped Session", "2026-07-25T08:00:00.000Z"), + [message("user", "channel collision", "2026-07-25T08:00:00.000Z", 0)], + ); + const invocations = new PostgresRuntimeInvocationRepository(database); + await invocations.begin({ + id: "inv-channel-a", + initiator: "agentrecall", + invocation: { surface: "workflow", ownerReference: { runId: "channel-run-a" } }, + runtimeId: "codex", + channelId: "codex-channel-a", + environmentId: "local", + startedAt: Date.parse("2026-07-25T08:00:01.000Z"), + }); + await invocations.bind("inv-channel-a", { + runtimeId: "codex", + channelId: "codex-channel-a", + environmentId: "local", + sessionId: "channel-shared", + relation: "created", + boundAt: Date.parse("2026-07-25T08:00:02.000Z"), + }); + await invocations.finish("inv-channel-a", "completed", Date.parse("2026-07-25T08:00:03.000Z")); + + await expect(searchRepository.searchSessionPage({ origin: "agentrecall" })) + .resolves.toMatchObject({ + sessions: [expect.objectContaining({ sessionKey: "codex:channel-shared", createdByAgentRecall: true })], + }); + + await invocations.begin({ + id: "inv-channel-b", + initiator: "agentrecall", + invocation: { surface: "workflow", ownerReference: { runId: "channel-run-b" } }, + runtimeId: "codex", + channelId: "codex-channel-b", + environmentId: "local", + startedAt: Date.parse("2026-07-25T08:00:04.000Z"), + }); + await invocations.bind("inv-channel-b", { + runtimeId: "codex", + channelId: "codex-channel-b", + environmentId: "local", + sessionId: "channel-shared", + relation: "created", + boundAt: Date.parse("2026-07-25T08:00:05.000Z"), + }); + await invocations.finish("inv-channel-b", "completed", Date.parse("2026-07-25T08:00:06.000Z")); + + const ambiguous = await searchRepository.searchSessionPage({ origin: "agentrecall" }); + expect(ambiguous.sessions.some((item) => item.sessionKey === "codex:channel-shared")).toBe(false); + await expect(repository.resolveRuntimeInvocationSession({ invocationId: "inv-channel-a" })) + .resolves.toEqual({ status: "not_indexed", invocationId: "inv-channel-a" }); + }); + 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/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 f020c377a..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,8 @@ import type { ProjectQueryOptions, ProjectSummary, ProjectTagEntry, + RuntimeInvocationLookup, + RuntimeInvocationSessionResolution, SearchOptions, SessionEnvironment, SessionMessage, @@ -608,6 +610,14 @@ export class SessionStore { return this.sessions.findByRawId(rawId); } + /** Resolves a Runtime invocation owner and preserves binding diagnostics. */ + async resolveRuntimeInvocationSession( + lookup: RuntimeInvocationLookup, + ): Promise { + await this.ready; + return this.sessions.resolveRuntimeInvocationSession(lookup); + } + 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..4cb819431 100644 --- a/apps/main-2.0/src/core/types.ts +++ b/apps/main-2.0/src/core/types.ts @@ -1,3 +1,10 @@ +import type { + AgentRecallInvocationSurface, + SessionInvocationSurfaceFilter, +} from "../shared/runtime-invocation"; + +export type { SessionInvocationSurfaceFilter } from "../shared/runtime-invocation"; + export type SessionSource = | "claude-cli" | "claude-app" @@ -365,7 +372,8 @@ 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; @@ -382,11 +390,14 @@ export interface SearchOptions { offset?: number; excludeSubagents?: boolean; prioritizeFavorites?: boolean; + origin?: SessionOriginFilter; + invocationSurface?: SessionInvocationSurfaceFilter; } export interface ProjectQueryOptions { excludeSubagents?: boolean; environmentId?: string; + origin?: SessionOriginFilter; } export interface TagListOptions { @@ -440,6 +451,57 @@ export interface SessionSearchResult extends IndexedSession { metadataMatch?: "title" | "project" | "summary" | null; bestTurn?: SessionTurnMatch | null; turnMatchCount?: number; + createdByAgentRecall?: boolean; + runtimeInvocations?: RuntimeInvocationSummary[]; +} + +/** Runtime invocation history attached to an indexed Session. */ +export interface RuntimeInvocationSummary { + /** Durable invocation identifier. */ + invocationId: string; + /** Caller surface, including unknown future values preserved as text. */ + surface: string; + /** Optional caller role. */ + role: string | null; + /** Exact owner identifiers for returning to the source page. */ + ownerReference: Record; + /** Runtime that owns the native Session. */ + runtimeId: string; + /** Optional Runtime channel. */ + channelId: string | null; + /** Execution environment containing the Session. */ + environmentId: string; + /** Terminal or in-progress invocation state. */ + status: "pending" | "completed" | "failed" | "cancelled" | "timed_out"; + /** Unix epoch timestamp when dispatch started. */ + startedAt: number; + /** Unix epoch timestamp when dispatch reached a terminal state. */ + finishedAt: number | null; + /** Whether this invocation created or continued the Session. */ + relation: "created" | "continued"; + /** Native Runtime Session identifier. */ + runtimeSessionId: string; + /** Optional native Runtime Turn identifier. */ + 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" }; + +/** 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 { @@ -465,6 +527,14 @@ export interface SessionSearchPage { sessions: SessionSearchResult[]; totalCount: number; hasMore: boolean; + /** Counts for each origin under the active non-origin filters. */ + originCounts: { + ordinary: number; + agentRecall: number; + all: number; + }; + /** AgentRecall-created Session counts under active non-origin and non-surface filters. */ + invocationSurfaceCounts: Record; } export interface SessionStatsSummary extends TokenUsage { @@ -487,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 new file mode 100644 index 000000000..e9672b4a2 --- /dev/null +++ b/apps/main-2.0/src/main/ipc/session-catalog.test.ts @@ -0,0 +1,40 @@ +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 an exact bounded lookup and rejects malformed owner references", async () => { + const handlers = new Map unknown>(); + 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; + }, + }, { + resolveRuntimeInvocationSession, + } as unknown as SessionCatalogService); + const handler = handlers.get("session:resolve-runtime-owner"); + expect(handler).toBeTypeOf("function"); + + 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({ + surface: "workflow", + role: "node", + ownerReference: { workflowId: "workflow-1", runId: "run-1" }, + }); + expect(() => handler?.({}, [])).toThrow(/must be an object/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 a915fdd40..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,6 +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, 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)); @@ -70,3 +74,58 @@ export function registerSessionCatalogIpc( ipc.handle("index:refresh", () => service.refreshIndex()); 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."); + } + 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; +} + +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/automation-service.ts b/apps/main-2.0/src/main/services/automation-service.ts index eeebee64a..701d2feb3 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"; @@ -274,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; @@ -307,7 +309,15 @@ export class NativeAutomationService { dependencies: AutomationServiceDependencies = {}, ) { this.paths = resolveAutomationPaths(options.userDataPath); - this.hubInstance = dependencies.hub ?? new AgentHub(); + this.runtimeInvocations = new PostgresRuntimeInvocationRepository(options.database); + this.hubInstance = dependencies.hub ?? new AgentHub( + {}, + undefined, + undefined, + undefined, + undefined, + this.runtimeInvocations, + ); this.appStore = new PostgresAppStore(options.database, this.paths.fileStoragePath); this.registryInstance = dependencies.registry ?? new McpRegistryStore(options.database); this.loadWorkflows = dependencies.loadBundledWorkflows ?? loadBundledWorkflows; @@ -351,6 +361,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 +401,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 +439,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; @@ -584,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, { @@ -773,6 +801,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..ba9523c02 100644 --- a/apps/main-2.0/src/main/services/evaluation-service.ts +++ b/apps/main-2.0/src/main/services/evaluation-service.ts @@ -34,13 +34,15 @@ export type EvaluationAgentExecution = ( prompt: string; /** Injected with the task; carries the selected skill's instructions. */ developerInstructions?: string; + role: string; + ownerReference: Record; }, signal?: AbortSignal, ) => Promise<{ 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 @@ -81,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?: ( @@ -369,11 +373,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 +442,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..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 @@ -17,6 +17,8 @@ import type { ProjectQueryOptions, ProjectSummary, ProjectTagEntry, + RuntimeInvocationSessionResolution, + RuntimeInvocationLookup, SearchOptions, SessionEnvironment, SessionMessage, @@ -80,6 +82,13 @@ export class SessionCatalogService { return this.dependencies.store.findByRawId(rawId); } + /** Resolves an invocation owner to a Session or an explicit unavailable reason. */ + async resolveRuntimeInvocationSession( + lookup: RuntimeInvocationLookup, + ): Promise { + return this.dependencies.store.resolveRuntimeInvocationSession(lookup); + } + 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..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 @@ -423,6 +423,7 @@ type ExecuteInput = { runtimeConversation?: RuntimeConversation; developerInstructions?: string; agentRecallMcp?: { studioToken?: string }; + ownerReference: Record; }; async function createFixture(options: { @@ -979,6 +980,13 @@ describe("TeamChatService studio employees", () => { } expect(calls[0]?.runtimeConversation).toBeUndefined(); + expect(calls[0]?.ownerReference).toMatchObject({ + roomId: fixture.room.id, + messageId: expect.any(String), + agentId: one!.agentId, + 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..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 @@ -71,6 +71,7 @@ interface TeamChatServiceDependencies { runtimeConversation?: RuntimeConversation; developerInstructions?: string; agentRecallMcp?: AgentRecallMcpContext; + ownerReference: Record; }, onEvent?: (event: WorkflowAgentEvent) => void, signal?: AbortSignal, @@ -1067,6 +1068,14 @@ export class TeamChatService { ...(currentSession ? { runtimeConversation: currentSession.runtimeConversation } : {}), developerInstructions: buildStudioDeveloperInstructions(input.room, target), agentRecallMcp: { studioToken }, + ownerReference: { + roomId: input.room.id, + messageId: input.sourceMessage.id, + agentId: target.agentId, + 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..649b8d340 100644 --- a/apps/main-2.0/src/preload/index.ts +++ b/apps/main-2.0/src/preload/index.ts @@ -21,6 +21,8 @@ import type { ProjectSummary, ProjectQueryOptions, ProjectTagEntry, + RuntimeInvocationLookup, + RuntimeInvocationSessionResolution, SearchOptions, SessionEnvironment, SessionMessage, @@ -62,6 +64,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), + /** Resolves the Session associated with an exact AgentRecall invocation owner. */ + resolveRuntimeInvocationSession: ( + 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..398db3abf 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,12 @@ 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), + teamChatPage: vi.fn((_props: unknown) => null), remoteSessionsDialog: vi.fn((_props: unknown) => null), loadCatalog: vi.fn(async () => undefined), loadWorkbenchSessions: vi.fn(async () => undefined), @@ -40,9 +46,12 @@ 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/team-chat/team-chat-page", () => ({ TeamChatPage: harness.teamChatPage })); vi.mock("./features/remote-sessions/remote-sessions-dialog", () => ({ RemoteSessionsDialog: harness.remoteSessionsDialog, })); @@ -401,4 +410,114 @@ describe("external session opening", () => { vi.useRealTimers(); } }); + + it("returns invocation history to 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, + }); + + await act(async () => { + detailsProps.actions.openInvocationOwner({ + invocationId: "team-chat-invocation", + surface: "team_chat", + role: "member", + ownerReference: { + roomId: "room-1", + messageId: "message-1", + agentId: "member-2", + }, + 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.teamChatPage).toHaveBeenCalled()); + expect(harness.teamChatPage.mock.calls.at(-1)?.[0]).toMatchObject({ + preferredRoomId: "room-1", + preferredMessageId: "message-1", + preferredAgentId: "member-2", + }); + + 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: 7, + finishedAt: 8, + relation: "created", + runtimeSessionId: "session-4", + 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 631a0d890..10f26e144 100644 --- a/apps/main-2.0/src/renderer/src/App.tsx +++ b/apps/main-2.0/src/renderer/src/App.tsx @@ -219,6 +219,14 @@ export function App(): ReactElement { const [workbenchMemoryLoading, setWorkbenchMemoryLoading] = useState(true); const [workbenchSkills, setWorkbenchSkills] = useState(null); const [preferredTeamChatRoomId, setPreferredTeamChatRoomId] = useState(); + const [preferredTeamChatMessageId, setPreferredTeamChatMessageId] = useState(); + const [preferredTeamChatAgentId, setPreferredTeamChatAgentId] = 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; @@ -300,6 +308,8 @@ export function App(): ReactElement { stats, statsPeriod, setStatsPeriod, + statsOrigin, + setStatsOrigin, statsRefreshing, statsFeedback, quotas, @@ -321,6 +331,12 @@ export function App(): ReactElement { setQuery, source, setSource, + origin, + setOrigin, + originCounts, + invocationSurface, + setInvocationSurface, + invocationSurfaceCounts, environmentId, setEnvironmentId, tag, @@ -497,7 +513,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(), ]); @@ -506,7 +522,7 @@ export function App(): ReactElement { setProjects(nextProjects); setEnvironments(nextEnvironments); setProjectTags(nextProjectTags); - }, []); + }, [origin]); useEffect(() => { void loadSidebarMetadata(); @@ -1777,6 +1793,7 @@ export function App(): ReactElement { void refreshStats()} onRefreshQuotas={() => void loadQuotas("manual")} onOpenSettings={() => { setSettingsInitialSection("usage"); setSettingsOpen(true); }} @@ -1840,6 +1858,7 @@ export function App(): ReactElement { onShowMcp={() => void navigateToPage("mcp")} onShowChat={(roomId) => { setPreferredTeamChatRoomId(roomId); + setPreferredTeamChatMessageId(undefined); void navigateToPage("team-chat"); }} onShowMemories={() => void navigateToPage("memories")} @@ -1863,6 +1882,10 @@ export function App(): ReactElement { collapsedProjectGroups, expandedTreeProjects: collapsedTreeProjects, source, + origin, + originCounts, + invocationSurface, + invocationSurfaceCounts, sourceFilters: visibleSourceFilters, visibility, searchRef, @@ -1913,6 +1936,8 @@ export function App(): ReactElement { }, deleteTag: setDeleteTagName, setSource, + setOrigin, + setInvocationSurface, setTag, setVisibility, search: setQuery, @@ -1994,6 +2019,8 @@ export function App(): ReactElement { setEvalPreselectedSkill(skillName); void navigateToPage("evaluation"); }} + initialDiscoveryOpen={openSkillDiscoveryFromSession} + onInitialDiscoveryConsumed={() => setOpenSkillDiscoveryFromSession(false)} /> ) : null} @@ -2003,10 +2030,32 @@ export function App(): ReactElement { runtimeReviewEnabled={Boolean(appSettings?.workflowRuntimeReviewEnabled)} initialRequest={workflowInitialRequest} onInitialRequestConsumed={() => setWorkflowInitialRequest(undefined)} + onOpenSession={(sessionKey) => { + void (async () => { + const session = await window.sessionSearch.getSession(sessionKey); + if (session) await openDetail(session); + })().catch(reportSessionDetailError); + }} /> : null} {activePage === "team-chat" ? ( - + { + setPreferredTeamChatRoomId(undefined); + setPreferredTeamChatMessageId(undefined); + setPreferredTeamChatAgentId(undefined); + }} + onOpenSession={(sessionKey) => { + void (async () => { + const session = await window.sessionSearch.getSession(sessionKey); + if (session) await openDetail(session); + })().catch(reportSessionDetailError); + }} + /> ) : null} {activePage === "evaluation" ? ( @@ -2015,6 +2064,14 @@ export function App(): ReactElement { enabled={Boolean(appSettings?.evalEnabled)} preselectedSkill={evalPreselectedSkill} onPreselectedConsumed={() => setEvalPreselectedSkill(null)} + initialRunId={preferredEvaluationRunId} + initialCaseId={preferredEvaluationCaseId} + initialEvaluatorId={preferredEvaluationEvaluatorId} + onInitialRunConsumed={() => { + setPreferredEvaluationRunId(undefined); + setPreferredEvaluationCaseId(undefined); + setPreferredEvaluationEvaluatorId(undefined); + }} onOpenSettings={() => { setSettingsInitialSection("eval"); setSettingsOpen(true); @@ -2030,7 +2087,20 @@ export function App(): ReactElement { ) : null} {activePage === "runtimes" ? ( - + { + setPreferredRuntimeChannelId(undefined); + setPreferredRuntimeAgentId(undefined); + }} + initialAgentId={preferredRuntimeAgentId} + onInitialAgentConsumed={() => { + setPreferredRuntimeAgentId(undefined); + setPreferredRuntimeChannelId(undefined); + }} + onNavigationGuardChange={setPageNavigationGuard} + /> ) : null} {activePage === "mcp" ? : null} @@ -2146,6 +2216,56 @@ 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, + ...(invocation.ownerReference.runId ? { runId: invocation.ownerReference.runId } : {}), + ...(invocation.ownerReference.nodeId ? { nodeId: invocation.ownerReference.nodeId } : {}), + } : undefined); + return; + } + if (invocation.surface === "team_chat") { + const roomId = invocation.ownerReference.roomId; + setPreferredTeamChatRoomId(roomId); + setPreferredTeamChatMessageId(roomId ? invocation.ownerReference.messageId : undefined); + setPreferredTeamChatAgentId(roomId ? invocation.ownerReference.agentId : undefined); + void navigateToPage("team-chat"); + return; + } + 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; + } + 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}`, () => window.sessionSearch.revealSession(session.sessionKey), 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..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 @@ -35,12 +35,20 @@ export function reconcileEditableAgentsAfterChannelSave( 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, 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 +71,24 @@ 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]); + + 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 4e8da9ac5..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 @@ -13,6 +13,10 @@ const api = vi.hoisted(() => ({ saveWorkflowDefinition: vi.fn(), })); +const sessionSearch = vi.hoisted(() => ({ + resolveRuntimeInvocationSession: 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,53 @@ 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.resolveRuntimeInvocationSession.mockResolvedValue({ + status: "found", + session: { 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.resolveRuntimeInvocationSession).toHaveBeenCalledWith({ + surface: "workflow", + ownerReference: { + workflowId: "workflow-1", + runId: "run-1", + nodeId: "inspect-code", + }, + }); + 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..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 @@ -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"; @@ -24,7 +25,7 @@ import { reduceWorkflowRunStream, workflowRunStreamKey, type WorkflowRunStreamSt type EditorMode = "definition" | "run"; export type WorkflowInitialRequest = - | { workflowId: string } + | { workflowId: string; runId?: string; nodeId?: string } | { createNew: true }; const valueTypes: WorkflowValueType[] = ["text", "number", "boolean", "file", "object", "list"]; @@ -341,12 +342,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(); @@ -389,6 +392,7 @@ export function WorkflowFeaturePage({ setSelectedId(nextId); const next = merged.find((item) => item.id === nextId); if (next) setDraft(structuredClone(next)); + return snapshot; }, [api, definitions, newDraftIds, selectedId]); const createNewWorkflow = (): void => { @@ -405,8 +409,16 @@ export function WorkflowFeaturePage({ useEffect(() => { void (async () => { try { - await load(initialRequest && "workflowId" in initialRequest ? initialRequest.workflowId : undefined); + const snapshot = await load(initialRequest && "workflowId" in initialRequest ? initialRequest.workflowId : undefined); if (initialRequest && "createNew" in initialRequest) createNewWorkflow(); + if (initialRequest && "workflowId" in initialRequest && initialRequest.runId) { + const requestedRun = snapshot.runs.find((run) => run.id === initialRequest.runId); + if (requestedRun) { + setSelectedRunId(requestedRun.id); + setMode("run"); + setSelectedNodeId(initialRequest.nodeId); + } + } } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); } finally { @@ -574,6 +586,27 @@ 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 resolution = await window.sessionSearch.resolveRuntimeInvocationSession({ + surface: "workflow", + ownerReference: { + workflowId: draft.id, + runId: selectedRun.id, + ...(selectedNodeId ? { nodeId: selectedNodeId } : {}), + }, + }); + if (resolution.status !== "found") { + setError(runtimeSessionUnavailableMessage(resolution, { en: "this run", zh: "该运行" }, language)); + return; + } + onOpenSession?.(resolution.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 +620,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/eval/eval-page.tsx b/apps/main-2.0/src/renderer/src/features/eval/eval-page.tsx index 0e027a6cb..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 @@ -40,6 +40,10 @@ export function EvalPage({ onNavigationGuardChange, preselectedSkill, onPreselectedConsumed, + initialRunId, + initialCaseId, + initialEvaluatorId, + onInitialRunConsumed, }: { language: LanguageMode; enabled: boolean; @@ -48,6 +52,10 @@ export function EvalPage({ onNavigationGuardChange?: (guard: (() => Promise) | null) => void; preselectedSkill?: string | null; onPreselectedConsumed?: () => void; + initialRunId?: string; + initialCaseId?: string; + initialEvaluatorId?: string; + onInitialRunConsumed?: () => void; }): ReactElement { const l = (en: string, zh: string) => localize(language, en, zh); const [tab, setTab] = useState("skills"); @@ -160,6 +168,10 @@ export function EvalPage({ } }, [preselectedSkill, onPreselectedConsumed]); + useEffect(() => { + if (initialRunId) setTab("runs"); + }, [initialRunId]); + return (
@@ -201,7 +213,14 @@ 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..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, @@ -141,6 +145,35 @@ 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", + 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 () => { harness.listExperiments.mockResolvedValue([ experiment(), @@ -221,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 cf58e7d1f..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 @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { ReactElement } from "react"; 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 { @@ -44,9 +46,17 @@ import { 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); const [runs, setRuns] = useState(null); @@ -58,6 +68,21 @@ export function EvalRunsPage({ const [run, setRun] = useState(null); 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; + requestedRunIdRef.current = initialRunId; + setSelectedRunId(initialRunId); + onInitialRunConsumed?.(); + }, [initialRunId, onInitialRunConsumed]); + + useEffect(() => { + requestedCaseIdRef.current = initialCaseId; + requestedEvaluatorIdRef.current = initialEvaluatorId; + }, [initialCaseId, initialEvaluatorId]); const reload = useCallback(async () => { setError(null); @@ -86,7 +111,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 +166,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 { @@ -264,6 +292,8 @@ export function EvalRunsPage({ experiment={experiments?.find((item) => item.id === run.experimentId)} evaluators={evaluators ?? []} onOpenSession={onOpenSession} + focusedCaseId={requestedCaseIdRef.current} + focusedEvaluatorId={requestedEvaluatorIdRef.current} /> )}
@@ -278,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( @@ -295,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 ( <>
@@ -361,6 +418,7 @@ function RunGraph({ /> ) : null} {run.error ?

{run.error}

: null} + {sessionFeedback ?

{sessionFeedback}

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

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

  • +
  • {l(`Case ${index + 1}`, `用例 ${index + 1}`)} @@ -400,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: "该评分运行" }, + )} + /> ))} ) : ( @@ -630,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)} @@ -650,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 815779170..ec72e6340 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,29 @@ 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( { onCopyPlain={vi.fn()} onDelete={vi.fn()} onReveal={vi.fn()} - readOnly sessionFamily={{ parent: null, children: [], truncated: false }} + onOpenInvocationOwner={onOpenInvocationOwner} />, ); }); + expect(container.querySelector(".runtime-invocation-history")?.textContent) + .toContain("Created by AgentRecall"); + expect(container.querySelector(".runtime-invocation-history button")).toBeNull(); + const actionButtons = [...container.querySelectorAll(".detail-actions > .detail-action-group > button")]; + const sourceButton = actionButtons.at(-1); + expect(sourceButton?.textContent).toContain("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..b0b042d54 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 @@ -1,10 +1,11 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { ReactElement } from "react"; -import { ArrowRightLeft, ChevronDown, ChevronUp, CloudUpload, Container, Copy, Download, Edit3, Eye, EyeOff, FolderOpen, Laptop, Paperclip, Play, Search, Server, Sparkles, Star, Tag, Terminal as TerminalIcon, Trash2, X } from "lucide-react"; +import { ArrowRightLeft, ChevronDown, ChevronUp, CloudUpload, Container, Copy, CornerUpLeft, Download, Edit3, Eye, EyeOff, FolderOpen, Laptop, Paperclip, Play, Search, Server, Sparkles, Star, Tag, Terminal as TerminalIcon, Trash2, X } from "lucide-react"; 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,90 @@ function conversationRoleEmptyLabel(filter: Exclude(); + for (const invocation of invocations) { + if (Object.keys(invocation.ownerReference).length === 0) continue; + const ownerKey = `${invocation.surface}:${JSON.stringify(invocation.ownerReference)}`; + if (seenOwnerReferences.has(ownerKey)) continue; + seenOwnerReferences.add(ownerKey); + actions.push(invocation); + } + return actions; +} + +function invocationSurfaceLabel(surface: string, language: LanguageMode): string { + const labels: Record = { + 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]); +} + +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)) + || (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", "打开工作流"); + 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, @@ -176,6 +261,7 @@ export function DetailPanel({ onOpenFamilySession, sessionFamilyLoadFailed = false, onRetrySessionFamily, + onOpenInvocationOwner, }: { session: SessionSearchResult; turns: SessionTurnSummary[] | null; @@ -225,6 +311,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"; @@ -248,6 +335,8 @@ export function DetailPanel({ const [roleFilter, setRoleFilter] = useState("all"); const [showTools, setShowTools] = useState(readInitialToolEventsVisibility); const [exportMarkdownMenuOpen, setExportMarkdownMenuOpen] = useState(false); + const [showAllInvocationOwners, setShowAllInvocationOwners] = useState(false); + const invocationOwnerActions = distinctInvocationOwnerActions(session.runtimeInvocations ?? []); const timelineItems = useMemo(() => conversationTimeline(messages, traceEvents), [messages, traceEvents]); const visibleTimelineItems = useMemo( () => filterConversationTimeline(timelineItems, roleFilter, showTools), @@ -273,6 +362,7 @@ export function DetailPanel({ }, [exportMarkdownMenuOpen]); useEffect(() => setExportMarkdownMenuOpen(false), [session.sessionKey]); + useEffect(() => setShowAllInvocationOwners(false), [session.sessionKey]); const roleFilterEmpty = !loading && messages.length > 0 && roleFilter !== "all" @@ -530,6 +620,32 @@ 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", + )} +
    + ))} +
    +
    + ) : null} {!readOnly ?
    {canResume ? ( @@ -618,6 +734,31 @@ export function DetailPanel({
    ) : null} + {onOpenInvocationOwner && invocationOwnerActions.length > 0 ? ( +
    + {(showAllInvocationOwners + ? invocationOwnerActions + : invocationOwnerActions.slice(0, INVOCATION_OWNER_ACTION_LIMIT)) + .map((invocation) => ( + + ))} + {invocationOwnerActions.length > INVOCATION_OWNER_ACTION_LIMIT ? ( + + ) : null} +
    + ) : null}
    : null} {session.aiSummary ? (
    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/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..5925d6bc3 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,6 +59,39 @@ 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 originButtons = [...container.querySelectorAll(".session-origin-filter > button, .session-origin-agentrecall > button")]; + expect(originButtons.map((button) => button.textContent)).toEqual([ + "Regular (3)", + "AgentRecall calls (2)", + "All (5)", + ]); + expect(originButtons[1]?.querySelector("svg")).not.toBeNull(); + expect(container.querySelector(".session-origin-agentrecall-menu")).toBeNull(); + await act(async () => originButtons[1]?.click()); + expect(actions.setOrigin).toHaveBeenCalledWith("agentrecall"); + expect(actions.setInvocationSurface).toHaveBeenCalledWith("all"); + await act(async () => root.render( + , + )); + const invocationMenu = container.querySelector(".session-origin-agentrecall-menu"); + expect(invocationMenu).not.toBeNull(); + expect([...invocationMenu?.querySelectorAll("button > span") ?? []].map((label) => label.textContent)).toEqual([ + "All", + "workflow", + "eval", + "chat", + "agent", + "skill", + "system", + ]); + expect(container.querySelector(".agentrecall-session-group")).toBeNull(); + const evaluationSurface = [...container.querySelectorAll(".session-origin-agentrecall-menu button")] + .find((button) => button.textContent?.includes("eval")); + await act(async () => evaluationSurface?.click()); + expect(actions.setInvocationSurface).toHaveBeenCalledWith("evaluation"); + expect(container.querySelector(".session-origin-agentrecall-menu")).toBeNull(); + 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()); @@ -75,10 +110,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()); @@ -90,7 +140,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()); @@ -104,6 +154,28 @@ describe("SessionsPage search tools", () => { }); expect(touchSavedSearch).toHaveBeenCalledWith(7); }); + + it("keeps the all option localized while showing invocation types with English labels", async () => { + const actions = createActions(); + const model = { ...createModel(), language: "zh" as const, origin: "agentrecall" as const }; + await act(async () => root.render()); + + const trigger = container.querySelector(".session-origin-agentrecall-trigger"); + expect(trigger?.textContent).toContain("AgentRecall 调用 (2)"); + expect(container.querySelector(".session-origin-agentrecall-menu")).toBeNull(); + await act(async () => trigger?.click()); + + expect([...container.querySelectorAll(".session-origin-agentrecall-menu button > span")] + .map((label) => label.textContent)).toEqual([ + "全部", + "workflow", + "eval", + "chat", + "agent", + "skill", + "system", + ]); + }); }); function createModel(): SessionsPageModel { @@ -111,6 +183,18 @@ function createModel(): SessionsPageModel { language: "en", indexStatus: null, 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"], @@ -134,7 +218,7 @@ function createModel(): SessionsPageModel { aiAssistantOpen: false, remoteSessionsOpen: false, selected: null, - sessions: [], + sessions: [session("ordinary", false), session("agentrecall", true)], currentPage: 1, totalPages: 1, liveSessionKeys: new Set(), @@ -144,6 +228,46 @@ function createModel(): SessionsPageModel { }; } +function session(sessionKey: string, createdByAgentRecall: boolean): SessionsPageModel["sessions"][number] { + return { + sessionKey, + rawId: sessionKey, + source: "codex-cli", + projectPath: "/workspace", + filePath: `/fixtures/${sessionKey}.jsonl`, + originalTitle: sessionKey, + firstQuestion: sessionKey, + timestamp: 1, + fileMtimeMs: 1, + fileSize: 1, + prUrl: null, + prNumber: null, + environmentId: "local", + environmentKind: "local", + environmentLabel: "Local", + tokenUsage: { + inputTokens: 0, + outputTokens: 0, + cachedInputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + }, + customTitle: null, + displayTitle: sessionKey, + favorited: false, + hidden: false, + tags: [], + matchSnippet: null, + lastOpenedAt: null, + lastResumedAt: null, + lastActivityAt: 1, + messageCount: 0, + aiSummary: null, + aiSummaryStale: false, + createdByAgentRecall, + }; +} + function createActions(): SessionsPageActions { return { refresh: vi.fn(), @@ -156,6 +280,8 @@ function createActions(): SessionsPageActions { toggleProjectTag: vi.fn(), 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 a5e1faeb7..77c8ea64e 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 @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { MouseEvent as ReactMouseEvent, ReactElement, @@ -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, @@ -102,6 +103,10 @@ export interface SessionsPageModel { collapsedProjectGroups: Set; expandedTreeProjects: Set; 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; @@ -135,6 +140,8 @@ export interface SessionsPageActions { toggleProjectTag(project: ProjectSummary, tagName: string): void; 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; @@ -173,6 +180,8 @@ export function SessionsPage({ const [savedSearchesOpen, setSavedSearchesOpen] = useState(false); const [savedSearches, setSavedSearches] = useState([]); const [groupMode, setGroupMode] = useState("flat"); + const [invocationMenuOpen, setInvocationMenuOpen] = useState(false); + const invocationMenuRef = useRef(null); const l = (en: string, zh: string): string => model.language === "zh" ? zh : en; const queryBuilderState = useMemo(() => ({ source: model.source === "all" ? undefined : model.source, @@ -194,6 +203,28 @@ export function SessionsPage({ if (savedSearchesOpen) void loadSavedSearches(); }, [loadSavedSearches, savedSearchesOpen]); + useEffect(() => { + if (!invocationMenuOpen) return; + const closeOnPointerDown = (event: PointerEvent): void => { + if (event.target instanceof Node && !invocationMenuRef.current?.contains(event.target)) { + setInvocationMenuOpen(false); + } + }; + const closeOnEscape = (event: KeyboardEvent): void => { + if (event.key === "Escape") setInvocationMenuOpen(false); + }; + document.addEventListener("pointerdown", closeOnPointerDown); + document.addEventListener("keydown", closeOnEscape); + return () => { + document.removeEventListener("pointerdown", closeOnPointerDown); + document.removeEventListener("keydown", closeOnEscape); + }; + }, [invocationMenuOpen]); + + useEffect(() => { + if (model.origin !== "agentrecall") setInvocationMenuOpen(false); + }, [model.origin]); + function applyQueryBuilder(state: QueryBuilderState): void { actions.setSource(state.source ?? "all"); actions.setTag(state.tag); @@ -204,7 +235,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); } @@ -212,6 +248,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)) { @@ -237,6 +275,30 @@ export function SessionsPage({ }); } + function renderSessionResults(sessions: SessionSearchResult[]): ReactElement { + return 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} + />; + } + return (
    @@ -470,6 +532,81 @@ export function SessionsPage({
    +
    + +
    + + {invocationMenuOpen ? ( +
    + + {AGENT_RECALL_INVOCATION_SURFACES.map((surface) => ( + + ))} +
    + ) : null} +
    + +
    {model.bulkSelectionActive ? 0 && model.sessions.every((session) => model.bulkSelectedKeys.has(session.sessionKey))} @@ -501,27 +638,7 @@ export function SessionsPage({
    - 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} - /> + {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 95c798a39..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 }))); + 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 b801c1c7d..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,6 +50,8 @@ export function useSessionCatalog({ }) { const [query, setQuery] = useState(""); const [source, setSource] = 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(); @@ -57,6 +68,8 @@ export function useSessionCatalog({ page: 1, }); 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); @@ -74,6 +87,8 @@ export function useSessionCatalog({ JSON.stringify([ query, source, + origin, + invocationSurface, environmentId, tag ?? "", projectPath ?? "", @@ -88,6 +103,8 @@ export function useSessionCatalog({ [ query, source, + origin, + invocationSurface, environmentId, tag, projectPath, @@ -121,6 +138,8 @@ export function useSessionCatalog({ const options: SearchOptions = { query, source, + origin, + invocationSurface, tag, projectPath: searchScope.projectPath, environmentId: searchScope.environmentId, @@ -134,7 +153,13 @@ 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 }, + 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)); @@ -147,6 +172,8 @@ export function useSessionCatalog({ setResults(page.sessions); 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) @@ -157,6 +184,8 @@ export function useSessionCatalog({ }, [ query, source, + origin, + invocationSurface, environmentId, tag, projectPath, @@ -183,6 +212,8 @@ export function useSessionCatalog({ const page = await window.sessionSearch.searchSessionPage({ query, source, + origin, + invocationSurface, tag, projectPath: searchScope.projectPath, environmentId: searchScope.environmentId, @@ -196,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, 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); @@ -324,6 +355,12 @@ export function useSessionCatalog({ setQuery, source, setSource, + origin, + setOrigin, + originCounts, + invocationSurface, + setInvocationSurface, + invocationSurfaceCounts, environmentId, setEnvironmentId, tag, 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 1528ecaf6..7d8252ec1 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 resolveRuntimeInvocationSession = 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; + resolveRuntimeInvocationSession = vi.fn(async () => ({ status: "not_recorded" as const })); Object.defineProperty(window, "sessionSearch", { configurable: true, - value: { teamChat }, + value: { teamChat, resolveRuntimeInvocationSession }, }); vi.spyOn(window, "confirm").mockReturnValue(true); }); @@ -139,6 +141,79 @@ describe("TeamChatPage rooms", () => { }); }); + it("opens the latest Session recorded for the active room", async () => { + fixture.setRooms([roomFixture("room-alpha", "Alpha")]); + resolveRuntimeInvocationSession.mockResolvedValue({ + status: "found", + session: { 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(resolveRuntimeInvocationSession).toHaveBeenCalledWith({ + surface: "team_chat", + role: "member", + ownerReference: { roomId: "room-alpha" }, + }); + 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", + }]); + resolveRuntimeInvocationSession.mockResolvedValue({ + status: "found", + session: { 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(resolveRuntimeInvocationSession).toHaveBeenCalledWith({ + surface: "team_chat", + role: "member", + ownerReference: { + roomId: "room-alpha", + messageId: "human-message", + agentId: "member-1", + }, + }); + expect(onOpenSession).toHaveBeenCalledWith("session-message"); + }); + it("clears deleted room details after switching rooms during a pending delete", async () => { fixture.setRooms([ roomFixture("room-alpha", "Alpha"), @@ -343,6 +418,91 @@ 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("focuses the exact agent response when multiple members reply to one message", async () => { + fixture.setRooms([roomFixture("room-alpha", "Alpha", { + name: "Alpha", + workDir: "/workspace/project", + members: [ + { configuredAgentId: "builder-profile", displayName: "Builder" }, + { configuredAgentId: "reviewer-profile", displayName: "Reviewer" }, + ], + })]); + fixture.setRoomMessages("room-alpha", [ + messageFixture("human-message", "room-alpha", 1, "Please review this"), + { + ...messageFixture("builder-response", "room-alpha", 2, "Builder response"), + senderType: "agent", + senderAgentId: "member-1", + senderName: "Builder", + sourceMessageId: "human-message", + }, + { + ...messageFixture("reviewer-response", "room-alpha", 3, "Reviewer response"), + senderType: "agent", + senderAgentId: "member-2", + senderName: "Reviewer", + sourceMessageId: "human-message", + }, + ]); + const onPreferredConsumed = vi.fn(); + + await act(async () => root.render( + , + )); + + await vi.waitFor(() => { + const target = container.querySelector( + '[data-message-id="human-message"][data-agent-id="member-2"]', + ); + 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 ade1c553b..5f285f3f9 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; @@ -161,9 +162,17 @@ export function TeamChatRoomTitle({ export function TeamChatPage({ language, preferredRoomId, + preferredMessageId, + preferredAgentId, + onPreferredConsumed, + onOpenSession, }: { language: LanguageMode; preferredRoomId?: string; + preferredMessageId?: string; + preferredAgentId?: string; + onPreferredConsumed?: () => void; + onOpenSession?: (sessionKey: string) => void; }): ReactElement { const l = useCallback((en: string, zh: string) => localize(language, en, zh), [language]); const api = useMemo(() => window.sessionSearch.teamChat, []); @@ -216,8 +225,79 @@ export function TeamChatPage({ const roomSelectButtonRefs = useRef(new Map()); const composerRef = useRef(null); const transcriptEndRef = useRef(null); + const focusedMessageTargetRef = useRef(undefined); const skipNextAutoScrollRef = useRef(false); + useEffect(() => { + focusedMessageTargetRef.current = undefined; + }, [preferredAgentId, 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 resolution = await window.sessionSearch.resolveRuntimeInvocationSession({ + surface: "team_chat", + role: "member", + ownerReference: { roomId: activeRoom.id }, + }); + if (resolution.status !== "found") { + setContextFeedback(runtimeSessionUnavailableMessage( + resolution, + { en: "this room's latest reply", zh: "该工作室最近一次回复" }, + language, + )); + return; + } + onOpenSession?.(resolution.session.sessionKey); + } catch (error) { + setContextFeedback(errorMessage(error)); + } + }; + + const openMessageSession = async (message: TeamChatMessage): Promise => { + const messageId = message.sourceMessageId ?? message.id; + try { + const resolution = await window.sessionSearch.resolveRuntimeInvocationSession({ + surface: "team_chat", + role: "member", + ownerReference: { + roomId: message.roomId, + messageId, + ...(message.senderAgentId ? { agentId: message.senderAgentId } : {}), + }, + }); + if (resolution.status !== "found") { + setContextFeedback(runtimeSessionUnavailableMessage( + resolution, + { en: "this message", zh: "该消息" }, + language, + )); + return; + } + onOpenSession?.(resolution.session.sessionKey); + } catch (error) { + setContextFeedback(errorMessage(error)); + } + }; + const isCurrentRoomScope = useCallback((roomId: string, epoch: number): boolean => selectedRoomIdRef.current === roomId && roomEpochRef.current === epoch, []); @@ -543,6 +623,55 @@ export function TeamChatPage({ } }, [activeRoom?.id, api, isCurrentRoomScope, nextBefore, setContextFeedback]); + useEffect(() => { + if (!preferredMessageId) return; + const preferredTarget = JSON.stringify([preferredMessageId, preferredAgentId]); + if (focusedMessageTargetRef.current === preferredTarget) return; + const target = [...document.querySelectorAll("[data-message-id]")] + .find((element) => ( + element.dataset.messageId === preferredMessageId + && (!preferredAgentId || element.dataset.agentId === preferredAgentId) + )); + if (target) { + focusedMessageTargetRef.current = preferredTarget; + 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 + ) { + focusedMessageTargetRef.current = preferredTarget; + onPreferredConsumed?.(); + } + }, [ + activeRoom?.id, + loadEarlierMessages, + loadingEarlier, + loadingMessages, + messages, + nextBefore, + onPreferredConsumed, + preferredAgentId, + preferredMessageId, + preferredRoomId, + selectedRoomId, + ]); + const sendMessage = useCallback(async (): Promise => { const content = composer.trim(); const roomId = selectedRoomIdRef.current; @@ -1012,6 +1141,14 @@ export function TeamChatPage({ {activeRoom.workDir || l("No working directory", "未设置工作目录")}
    + + ) : null}
    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", "用量")}
    +