262 lines
7.7 KiB
TypeScript
262 lines
7.7 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import type { ToolClient } from "../src/clients/tool-client";
|
|
import {
|
|
getSessionConversationDetail,
|
|
listSessionConversations,
|
|
} from "../src/runtime/session-conversations";
|
|
import type { SessionsHistoryRequest, SessionsHistoryResponse } from "../src/contracts/openclaw-tools";
|
|
import type { ReadModelSnapshot } from "../src/types";
|
|
|
|
class FakeToolClient implements ToolClient {
|
|
constructor(private readonly historyBySession = new Map<string, SessionsHistoryResponse>()) {}
|
|
|
|
async sessionsList() {
|
|
return { sessions: [] };
|
|
}
|
|
|
|
async sessionStatus() {
|
|
return { rawText: "" };
|
|
}
|
|
|
|
async sessionsHistory(request: SessionsHistoryRequest): Promise<SessionsHistoryResponse> {
|
|
return this.historyBySession.get(request.sessionKey) ?? { rawText: "" };
|
|
}
|
|
|
|
async cronList() {
|
|
return { jobs: [] };
|
|
}
|
|
|
|
async approvalsGet() {
|
|
return { rawText: "" };
|
|
}
|
|
|
|
async approvalsApprove() {
|
|
return { ok: false, action: "approve" as const, approvalId: "n/a", rawText: "" };
|
|
}
|
|
|
|
async approvalsReject() {
|
|
return { ok: false, action: "reject" as const, approvalId: "n/a", rawText: "" };
|
|
}
|
|
}
|
|
|
|
function makeSnapshot(sessionKeys: Array<{ sessionKey: string; agentId?: string; state?: "idle" | "running" | "blocked" | "waiting_approval" | "error"; lastMessageAt?: string }>): ReadModelSnapshot {
|
|
return {
|
|
sessions: sessionKeys.map((item) => ({
|
|
sessionKey: item.sessionKey,
|
|
agentId: item.agentId,
|
|
state: item.state ?? "idle",
|
|
lastMessageAt: item.lastMessageAt,
|
|
})),
|
|
statuses: [],
|
|
cronJobs: [],
|
|
approvals: [],
|
|
projects: { projects: [], updatedAt: "2026-03-07T10:00:00.000Z" },
|
|
projectSummaries: [],
|
|
tasks: { tasks: [], agentBudgets: [], updatedAt: "2026-03-07T10:00:00.000Z" },
|
|
tasksSummary: {
|
|
projects: 0,
|
|
tasks: 0,
|
|
todo: 0,
|
|
inProgress: 0,
|
|
blocked: 0,
|
|
done: 0,
|
|
owners: 0,
|
|
artifacts: 0,
|
|
},
|
|
budgetSummary: { total: 0, ok: 0, warn: 0, over: 0, evaluations: [] },
|
|
generatedAt: "2026-03-07T10:00:00.000Z",
|
|
};
|
|
}
|
|
|
|
test("run child session infers execution chain from session key when history is unavailable", async () => {
|
|
const snapshot = makeSnapshot([
|
|
{
|
|
sessionKey: "agent:main:cron:job-1:run:child-1",
|
|
agentId: "main",
|
|
state: "running",
|
|
lastMessageAt: "2026-03-07T10:01:00.000Z",
|
|
},
|
|
]);
|
|
|
|
const result = await listSessionConversations({
|
|
snapshot,
|
|
client: new FakeToolClient(),
|
|
filters: {},
|
|
page: 1,
|
|
pageSize: 10,
|
|
historyLimit: 10,
|
|
});
|
|
|
|
assert.equal(result.items.length, 1);
|
|
assert.equal(result.items[0].executionChain?.source, "session_key");
|
|
assert.equal(result.items[0].executionChain?.parentSessionKey, "agent:main:cron:job-1");
|
|
assert.equal(result.items[0].executionChain?.childSessionKey, "agent:main:cron:job-1:run:child-1");
|
|
assert.equal(result.items[0].executionChain?.accepted, true);
|
|
assert.equal(result.items[0].executionChain?.spawned, true);
|
|
assert.equal(result.items[0].executionChain?.stage, "running");
|
|
});
|
|
|
|
test("history execution events are parsed and preferred over session-key inference", async () => {
|
|
const sessionKey = "agent:main:cron:job-2";
|
|
const childSessionKey = "agent:main:cron:job-2:run:child-2";
|
|
const snapshot = makeSnapshot([
|
|
{
|
|
sessionKey,
|
|
agentId: "main",
|
|
state: "idle",
|
|
lastMessageAt: "2026-03-07T10:02:00.000Z",
|
|
},
|
|
]);
|
|
const client = new FakeToolClient(
|
|
new Map([
|
|
[
|
|
sessionKey,
|
|
{
|
|
json: {
|
|
history: [
|
|
{
|
|
event: "accepted",
|
|
timestamp: "2026-03-07T10:02:10.000Z",
|
|
message: "accepted parent session",
|
|
},
|
|
{
|
|
event: "sessions_spawn",
|
|
timestamp: "2026-03-07T10:02:20.000Z",
|
|
parentSessionKey: sessionKey,
|
|
childSessionKey,
|
|
message: "spawned child session",
|
|
},
|
|
],
|
|
},
|
|
rawText: "",
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
|
|
const detail = await getSessionConversationDetail({
|
|
snapshot,
|
|
client,
|
|
sessionKey,
|
|
historyLimit: 20,
|
|
});
|
|
|
|
assert(detail);
|
|
assert.equal(detail.executionChain?.source, "history");
|
|
assert.equal(detail.executionChain?.accepted, true);
|
|
assert.equal(detail.executionChain?.spawned, true);
|
|
assert.equal(detail.executionChain?.parentSessionKey, sessionKey);
|
|
assert.equal(detail.executionChain?.childSessionKey, childSessionKey);
|
|
assert.equal(detail.executionChain?.acceptedAt, "2026-03-07T10:02:10.000Z");
|
|
assert.equal(detail.executionChain?.spawnedAt, "2026-03-07T10:02:20.000Z");
|
|
assert.deepEqual(detail.history.map((item) => item.kind), ["accepted", "spawn"]);
|
|
});
|
|
|
|
test("raw text history fallback still extracts accepted and spawn events", async () => {
|
|
const sessionKey = "agent:main:cron:job-3";
|
|
const childSessionKey = "agent:main:cron:job-3:run:child-3";
|
|
const snapshot = makeSnapshot([
|
|
{
|
|
sessionKey,
|
|
agentId: "main",
|
|
state: "idle",
|
|
},
|
|
]);
|
|
const client = new FakeToolClient(
|
|
new Map([
|
|
[
|
|
sessionKey,
|
|
{
|
|
rawText: [
|
|
"accepted request for timed job",
|
|
`sessions_spawn parent=${sessionKey} child=${childSessionKey}`,
|
|
].join("\n"),
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
|
|
const detail = await getSessionConversationDetail({
|
|
snapshot,
|
|
client,
|
|
sessionKey,
|
|
historyLimit: 20,
|
|
});
|
|
|
|
assert(detail);
|
|
assert.deepEqual(detail.history.map((item) => item.kind), ["accepted", "spawn"]);
|
|
assert.equal(detail.executionChain?.source, "history");
|
|
assert.equal(detail.executionChain?.childSessionKey, childSessionKey);
|
|
});
|
|
|
|
test("nested message envelope preserves user and assistant roles", async () => {
|
|
const sessionKey = "agent:coq:main";
|
|
const snapshot = makeSnapshot([
|
|
{
|
|
sessionKey,
|
|
agentId: "coq",
|
|
state: "running",
|
|
lastMessageAt: "2026-03-09T22:56:10.656Z",
|
|
},
|
|
]);
|
|
const client = new FakeToolClient(
|
|
new Map([
|
|
[
|
|
sessionKey,
|
|
{
|
|
json: {
|
|
history: [
|
|
{
|
|
type: "message",
|
|
timestamp: "2026-03-09T22:55:46.585Z",
|
|
message: {
|
|
role: "user",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "[Mon 2026-03-09 23:55 GMT+1] 立即停止你当前的所有活动,不要继续任何 review、演示、brief、整理、输出、工具调用或后续收尾扩展。从现在起进入待命状态,不再处理任何当前任务。回复必须是:REPLY_SKIP。announce 必须是:ANNOUNCE_SKIP。",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
type: "message",
|
|
timestamp: "2026-03-09T22:55:51.944Z",
|
|
message: {
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "thinking",
|
|
thinking: "**Using direct reply only**",
|
|
},
|
|
{
|
|
type: "text",
|
|
text: "REPLY_SKIP",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
rawText: "",
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
|
|
const detail = await getSessionConversationDetail({
|
|
snapshot,
|
|
client,
|
|
sessionKey,
|
|
historyLimit: 20,
|
|
});
|
|
|
|
assert(detail);
|
|
assert.equal(detail.history.length, 2);
|
|
assert.equal(detail.history[0].role, "user");
|
|
assert.match(detail.history[0].content, /立即停止你当前的所有活动/);
|
|
assert.equal(detail.history[1].role, "assistant");
|
|
assert.match(detail.history[1].content, /REPLY_SKIP/);
|
|
});
|