Skip to content
Open
8 changes: 8 additions & 0 deletions .release-notes/fix-issue-535-runtime-sessions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# 归类 AgentRecall 发起的 Runtime 会话

<!-- release-target: v2 -->

## Bug 修复

- AgentRecall 发起且由 Runtime 返回可靠 Session 引用的会话现在可通过“普通会话”右侧的“AgentRecall 调用”切换项单独查看,并可从切换项的折叠菜单按 `workflow`、`eval`、`chat`、`agent`、`skill` 和 `system` 类型筛选,不再挤占普通会话列表;用量和项目计数也采用相同口径。
- Session 详情和 Workflow、Eval、Team Chat 业务记录现在提供精确的双向入口;Runtime 未返回 Session 引用时会保留调用记录并明确说明,不再根据目录变化、标题、路径或时间猜测归属。
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -21,11 +24,12 @@ export class HermesRunner {
constructor(private readonly options: HermesRunOptions) {}

async start(): Promise<void> {
const args = ["-z", this.options.prompt];
const args = ["chat", "--quiet", "--query", this.options.prompt];
const modelArg = runtimeModelId(this.options.modelId ?? "");
if (modelArg) {
args.push("--model", modelArg);
}
args.push("--source", "tool");

const proc = spawnCli({
executable: this.options.executable,
Expand All @@ -43,13 +47,35 @@ 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();
});
proc.stderr.on("data", (chunk: Buffer) => {
const text = chunk.toString();
stderr += text;
this.options.onStderr?.(text);
reportSessionReference();
stderr = stderr.slice(-MAX_STDERR_CHARS);
});

return await new Promise<void>((resolve, reject) => {
Expand All @@ -64,6 +90,7 @@ export class HermesRunner {
proc.once("exit", (code) => {
finish(() => {
const content = stdout.trim();
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." });
Expand Down Expand Up @@ -93,3 +120,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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand Down Expand Up @@ -61,8 +62,8 @@ export class OpenClawRunner {
async start(): Promise<void> {
const args = [
"agent",
"--session-key",
this.options.sessionKey,
"--session-id",
this.options.sessionId,
"--message",
this.options.prompt,
"--json",
Expand All @@ -82,6 +83,16 @@ export class OpenClawRunner {
this.proc = undefined;
throw new Error("OpenClaw runner failed to create stdout/stderr pipes.");
}
this.options.onEvent({
type: "runtime_conversation",
runtimeConversation: openClawRuntimeStateCodec.encodeConversation({
native: { sessionId: this.options.sessionId },
appContext: {
cwd: this.options.cwd,
...(this.options.modelId ? { modelId: this.options.modelId } : {}),
},
}),
});

let stdout = "";
let stderr = "";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -67,10 +71,10 @@ export class InteractiveSessionManager {
await run;
}

async interrupt(chatId: string): Promise<void> {
async interrupt(chatId: string, interruption?: InteractiveSessionInterruption): Promise<void> {
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<void> {
Expand Down
Original file line number Diff line number Diff line change
@@ -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" } }),
},
}),
]);
});
});
Loading