mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat: contract sessions-family tool outputs (#110424)
* feat(agents): contract sessions-family tool outputs * refactor(agents): remove unused fast-mode exports
This commit is contained in:
committed by
GitHub
parent
f448701aa1
commit
746d257f05
@@ -39,7 +39,7 @@ Group, provider, sandbox, and per-agent policies can still remove those tools af
|
||||
|
||||
## Listing and reading sessions
|
||||
|
||||
`sessions_list` returns sessions with their key, agentId, kind, channel, model, token counts, and timestamps. Filter by `kinds` (array; accepted values: `main`, `group`, `cron`, `hook`, `node`, `other`), exact `label`, exact `agentId`, `search` text, or recency (`activeMinutes`). Active sessions are returned by default; pass `archived: true` to inspect archived sessions instead. Rows include `pinned` and `archived` state. Set `includeDerivedTitles`, `includeLastMessage`, or `messageLimit` (capped at 20) when you need mailbox-style triage: a visibility-scoped derived title, a last-message preview snippet, or bounded recent messages on each row. Derived titles and previews are produced only for sessions the caller can already see under the configured session tool visibility policy, so unrelated sessions stay hidden. When visibility is restricted, `sessions_list` returns optional `visibility` metadata showing the effective mode and a warning that results may be scope-limited.
|
||||
`sessions_list` returns focused discovery rows: session key, agent, kind, channel, label/title/preview fields, parent and child relationships, last update, archive/pin state, state version, model, context/total token counts, run status, and whether the last run aborted. Filter by `kinds` (array; accepted values: `main`, `group`, `cron`, `hook`, `node`, `other`), exact `label`, exact `agentId`, `search` text, or recency (`activeMinutes`). Active sessions are returned by default; pass `archived: true` to inspect archived sessions instead. Set `includeDerivedTitles`, `includeLastMessage`, or `messageLimit` (capped at 20) when you need mailbox-style triage: a visibility-scoped derived title, a last-message preview snippet, or bounded recent messages on each row. Delivery routing, internal session IDs, per-run timings/settings, cost estimates, and transcript paths are intentionally omitted; use `session_status`, conversation tools, and `sessions_history` for those owner-specific details. Derived titles and previews are produced only for sessions the caller can already see under the configured session tool visibility policy, so unrelated sessions stay hidden. When visibility is restricted, `sessions_list` returns optional `visibility` metadata showing the effective mode and a warning that results may be scope-limited.
|
||||
|
||||
`sessions_history` fetches the conversation transcript for a specific session. By default, tool results are excluded; pass `includeTools: true` to see them. Use `limit` for the newest bounded tail. Pass `offset: 0` when you need pagination metadata, then pass returned `nextOffset` values to page backward through older OpenClaw transcript windows without reading raw transcript files. Explicit offset pages do not merge external CLI fallback imports; use the default newest-tail view (no `offset`) when you need that merged display history.
|
||||
|
||||
@@ -57,7 +57,7 @@ The returned view is intentionally bounded and safety-filtered:
|
||||
- very large histories can drop older rows or replace an oversized row with `[sessions_history omitted: message too large]`
|
||||
- the tool reports summary flags such as `truncated`, `droppedMessages`, `contentTruncated`, `contentRedacted`, `bytes`, and pagination metadata
|
||||
|
||||
Both tools accept either a **session key** (like `"main"`) or a **session ID** from a previous list call.
|
||||
Use the returned **session key** (like `"main"`) with `sessions_history`, `sessions_send`, and `session_status`. Those target tools can also resolve a known session ID, but `sessions_list` does not expose internal IDs.
|
||||
|
||||
If you need the exact raw transcript, inspect the scoped SQLite transcript rows instead of treating `sessions_history` as an unfiltered dump.
|
||||
|
||||
@@ -100,6 +100,8 @@ When route metadata is available, `session_status` also includes a visible `Rout
|
||||
|
||||
OpenClaw keeps a durable signal log of material session state changes (direct human messages to watched sessions, child-run outcomes, goal changes, compaction). `sessions_list` rows and `session_status` expose the session's `stateVersion`, and `session_status` accepts `changesSince: <version>` to return the typed events after that version, with exact `historyGap` signaling when the requested version predates retained history. Watchers — spawn parents automatically, `sessions_send watch: true` explicitly — receive one coalesced stale-state notice when another actor changes a watched session.
|
||||
|
||||
State-change events omit repeated session/agent IDs and expose only model-useful payload fields (`outcome`, `channel`, or `turns`). The event summary and actor/run identifiers remain available for reconciliation.
|
||||
|
||||
See [Session state awareness](/concepts/session-state) for the full model: event kinds, watcher registration, the anti-spam notice protocol, reconciliation flow, and current limits.
|
||||
|
||||
`sessions_yield` intentionally ends the current turn so the next message can be the follow-up event you are waiting for. Use it after spawning sub-agents when you want completion results to arrive as the next message instead of building poll loops.
|
||||
|
||||
@@ -527,8 +527,9 @@ property on the returned `AnyAgentTool` object.
|
||||
|
||||
Current built-in contracts include `agents_list`, `apply_patch`,
|
||||
`conversations_list`, `conversations_send`, `conversations_turn`, `edit`,
|
||||
`openclaw`, `read`, `screen`, `sessions_search`, `spawn_task`, `terminal`,
|
||||
`web_fetch`, and `web_search`.
|
||||
`openclaw`, `read`, `screen`,
|
||||
`sessions_history`, `sessions_list`, `sessions_search`, `sessions_send`,
|
||||
`session_status`, `spawn_task`, `terminal`, `web_fetch`, and `web_search`.
|
||||
Exact passthroughs can reuse their owning protocol schema instead of
|
||||
duplicating a model-only contract. For example, the conversation tools expose
|
||||
the same Gateway result schemas used by `conversations.list`,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Verifies fast-mode precedence across session, agent, and model defaults.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { formatFastModeAutoLabel, normalizeFastModeSource } from "../shared/fast-mode.js";
|
||||
import { formatFastModeAutoLabel } from "../shared/fast-mode.js";
|
||||
import {
|
||||
formatFastModeAutoProgressText,
|
||||
formatFastModeCommandOptions,
|
||||
@@ -141,8 +141,6 @@ describe("resolveFastModeState", () => {
|
||||
fastAutoOnSeconds: 30,
|
||||
}),
|
||||
).toBe("Current fast mode: auto (30 sec) (default: model).");
|
||||
expect(normalizeFastModeSource("config")).toBe("config");
|
||||
expect(normalizeFastModeSource("bad")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses model fastAutoOnSeconds for auto cutoff across session overrides", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Verifies session status output across scoped stores, tasks, and runtime hooks.
|
||||
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { Value } from "typebox/value";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveSessionStoreEntry } from "../config/sessions/store-entry.js";
|
||||
import { mergeSessionEntry, type SessionEntry } from "../config/sessions/types.js";
|
||||
@@ -13,6 +14,7 @@ import { MODEL_SELECTION_LOCKED_MESSAGE } from "../sessions/model-overrides.js";
|
||||
import { resolvePreferredSessionKeyForSessionIdMatches } from "../sessions/session-id-resolution.js";
|
||||
import type { TaskRecord } from "../tasks/task-registry.types.js";
|
||||
import { buildTaskStatusSnapshot } from "../tasks/task-status.js";
|
||||
import { compactToolOutputHint } from "./tool-schema-hints.js";
|
||||
|
||||
const loadSessionStoreMock = vi.fn();
|
||||
const updateSessionStoreMock = vi.fn();
|
||||
@@ -546,6 +548,11 @@ describe("session_status tool", () => {
|
||||
expect(details.statusText).toContain("OpenClaw");
|
||||
expect(details.statusText).toContain("🧠 Model:");
|
||||
expect(details.statusText).not.toContain("OAuth/token status");
|
||||
expect(tool.outputSchema).toBeDefined();
|
||||
expect(Value.Check(tool.outputSchema!, result.details)).toBe(true);
|
||||
expect(compactToolOutputHint(tool.outputSchema)).toBe(
|
||||
'{ changedModel: boolean; ok: true; sessionKey: string; stateVersion: number; statusText: string; active?: { accountId?: string; channel?: string; threadId?: string | number; to?: string }; deliveryContext?: { accountId?: string; channel?: string; threadId?: string | number; to?: string }; model?: string; modelOverride?: string | null; modelProvider?: string; origin?: { accountId?: string; provider?: string; threadId?: string | number }; stateChanges?: { earliestAvailableSequence: number; events: Array<{ actorType: "human" | "agent" | "system"; kind: string; occurredAt: number; sequence: number; summary: string; actorId?: string; payload?: { channel?: string; outcome?: "error" | "timeout" | "cancelled"; turns?: number }; runId?: string }>; historyGap: boolean; truncated: boolean } }',
|
||||
);
|
||||
});
|
||||
|
||||
it("returns read-only state changes and the signal-log head", async () => {
|
||||
@@ -557,20 +564,47 @@ describe("session_status tool", () => {
|
||||
});
|
||||
getSessionStateVersionMock.mockReturnValue(12);
|
||||
listSessionStateEventsSinceMock.mockReturnValue({
|
||||
events: [{ sequence: 12, kind: "upstream_missing", summary: "upstream missing via codex" }],
|
||||
events: [
|
||||
{
|
||||
sequence: 12,
|
||||
sessionKey: "main",
|
||||
sessionId: "s1",
|
||||
agentId: "main",
|
||||
kind: "upstream_missing",
|
||||
actorType: "system",
|
||||
occurredAt: 100,
|
||||
summary: "upstream missing via codex",
|
||||
payload: { channel: "codex", catalogId: "internal-catalog", nested: { drop: true } },
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
earliestAvailableSequence: 12,
|
||||
historyGap: true,
|
||||
});
|
||||
|
||||
const result = await getSessionStatusTool().execute("call-state", { changesSince: 3 });
|
||||
const tool = getSessionStatusTool();
|
||||
const result = await tool.execute("call-state", { changesSince: 3 });
|
||||
const details = result.details as Record<string, unknown>;
|
||||
const text = (result.content?.[0] as { text?: string } | undefined)?.text ?? "";
|
||||
|
||||
expect(getSessionStateVersionMock).toHaveBeenCalledWith("main", "main");
|
||||
expect(listSessionStateEventsSinceMock).toHaveBeenCalledWith("main", "main", 3, 200);
|
||||
expect(details.stateVersion).toBe(12);
|
||||
expect(details.stateChanges).toMatchObject({ historyGap: true });
|
||||
expect(details.stateChanges).toMatchObject({
|
||||
historyGap: true,
|
||||
events: [
|
||||
{
|
||||
sequence: 12,
|
||||
kind: "upstream_missing",
|
||||
actorType: "system",
|
||||
occurredAt: 100,
|
||||
summary: "upstream missing via codex",
|
||||
payload: { channel: "codex" },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(Value.Check(tool.outputSchema!, result.details)).toBe(true);
|
||||
expect(JSON.stringify(details.stateChanges)).not.toContain("internal-catalog");
|
||||
expect(text).toContain("Session state changes:");
|
||||
expect(text).toContain('"kind": "upstream_missing"');
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Value } from "typebox/value";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChannelMessagingAdapter } from "../channels/plugins/types.public.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
@@ -40,6 +41,7 @@ import "./test-helpers/fast-openclaw-tools-sessions.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { setActiveEmbeddedRun } from "./embedded-agent-runner/runs.js";
|
||||
import { testing as embeddedRunsTesting } from "./embedded-agent-runner/runs.test-support.js";
|
||||
import { compactToolOutputHint } from "./tool-schema-hints.js";
|
||||
import { testing as agentStepTesting } from "./tools/agent-step.test-support.js";
|
||||
import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js";
|
||||
import { createSessionsListTool } from "./tools/sessions-list-tool.js";
|
||||
@@ -526,11 +528,7 @@ describe("sessions tools", () => {
|
||||
channel?: string;
|
||||
derivedTitle?: string;
|
||||
lastMessagePreview?: string;
|
||||
spawnedBy?: string;
|
||||
status?: string;
|
||||
startedAt?: number;
|
||||
runtimeMs?: number;
|
||||
estimatedCostUsd?: number;
|
||||
childSessions?: string[];
|
||||
parentSessionKey?: string;
|
||||
messages?: Array<{ role?: string }>;
|
||||
@@ -547,9 +545,6 @@ describe("sessions tools", () => {
|
||||
|
||||
const group = details.sessions?.find((s) => s.key === "discord:group:dev");
|
||||
expect(group?.status).toBe("running");
|
||||
expect(group?.startedAt).toBe(100);
|
||||
expect(group?.runtimeMs).toBe(42);
|
||||
expect(group?.estimatedCostUsd).toBe(0.0042);
|
||||
expect(group?.childSessions).toEqual(["agent:main:subagent:worker"]);
|
||||
expect(group?.derivedTitle).toBe("Dev room");
|
||||
expect(group?.lastMessagePreview).toBe("Need review on the patch");
|
||||
@@ -558,7 +553,7 @@ describe("sessions tools", () => {
|
||||
expect(dashboardChild?.parentSessionKey).toBe("agent:main:main");
|
||||
|
||||
const subagentWorker = details.sessions?.find((s) => s.key === "agent:main:subagent:worker");
|
||||
expect(subagentWorker?.spawnedBy).toBe("agent:main:main");
|
||||
expect(subagentWorker?.parentSessionKey).toBe("agent:main:main");
|
||||
|
||||
const cronOnly = await tool.execute("call2", { kinds: ["cron"] });
|
||||
const cronDetails = cronOnly.details as {
|
||||
@@ -648,42 +643,11 @@ describe("sessions tools", () => {
|
||||
agentId: "main",
|
||||
kind: "other",
|
||||
channel: "unknown",
|
||||
origin: undefined,
|
||||
spawnedBy: undefined,
|
||||
archived: false,
|
||||
archivedAt: undefined,
|
||||
pinned: false,
|
||||
pinnedAt: undefined,
|
||||
label: undefined,
|
||||
displayName: undefined,
|
||||
derivedTitle: "Visible project kickoff",
|
||||
lastMessagePreview: "Visible latest reply",
|
||||
parentSessionKey: undefined,
|
||||
deliveryContext: undefined,
|
||||
updatedAt: 20,
|
||||
sessionId: "visible",
|
||||
model: undefined,
|
||||
contextTokens: undefined,
|
||||
totalTokens: undefined,
|
||||
estimatedCostUsd: undefined,
|
||||
status: undefined,
|
||||
startedAt: undefined,
|
||||
endedAt: undefined,
|
||||
runtimeMs: undefined,
|
||||
childSessions: undefined,
|
||||
thinkingLevel: undefined,
|
||||
fastMode: undefined,
|
||||
verboseLevel: undefined,
|
||||
reasoningLevel: undefined,
|
||||
elevatedLevel: undefined,
|
||||
responseUsage: undefined,
|
||||
systemSent: undefined,
|
||||
abortedLastRun: undefined,
|
||||
sendPolicy: undefined,
|
||||
lastChannel: undefined,
|
||||
lastTo: undefined,
|
||||
lastAccountId: undefined,
|
||||
transcriptPath: path.join(fs.realpathSync(tmpDir), "visible.jsonl"),
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(details.sessions)).not.toContain("Hidden");
|
||||
@@ -692,7 +656,7 @@ describe("sessions tools", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("sessions_list resolves transcriptPath from agent state dir for multi-store listings", async () => {
|
||||
it("sessions_list omits transcript paths from model-facing rows", async () => {
|
||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string };
|
||||
if (request.method === "sessions.list") {
|
||||
@@ -718,17 +682,11 @@ describe("sessions tools", () => {
|
||||
|
||||
const result = await tool.execute("call2b", {});
|
||||
const details = result.details as {
|
||||
sessions?: Array<{
|
||||
key?: string;
|
||||
transcriptPath?: string;
|
||||
}>;
|
||||
sessions?: Array<Record<string, unknown>>;
|
||||
};
|
||||
const main = details.sessions?.find((session) => session.key === "main");
|
||||
expect(typeof main?.transcriptPath).toBe("string");
|
||||
expect(main?.transcriptPath).not.toContain("(multiple)");
|
||||
expect(main?.transcriptPath).toContain(
|
||||
path.join("agents", "main", "sessions", "sess-main.jsonl"),
|
||||
);
|
||||
expect(main).not.toHaveProperty("transcriptPath");
|
||||
expect(main).not.toHaveProperty("sessionId");
|
||||
});
|
||||
|
||||
it("sessions_history filters tool messages by default", async () => {
|
||||
@@ -1151,6 +1109,27 @@ describe("sessions tools", () => {
|
||||
expect(waitedDetails.delivery?.status).toBe("pending");
|
||||
expect(waitedDetails.delivery?.mode).toBe("announce");
|
||||
expect(typeof (waited.details as { runId?: string }).runId).toBe("string");
|
||||
expect(tool.outputSchema).toBeDefined();
|
||||
expect(Value.Check(tool.outputSchema!, fire.details)).toBe(true);
|
||||
expect(Value.Check(tool.outputSchema!, waited.details)).toBe(true);
|
||||
expect(
|
||||
Value.Check(tool.outputSchema!, {
|
||||
runId: "run-error",
|
||||
status: "forbidden",
|
||||
error: "hidden",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
Value.Check(tool.outputSchema!, {
|
||||
runId: "run-error",
|
||||
status: "error",
|
||||
error: "failed",
|
||||
extra: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(compactToolOutputHint(tool.outputSchema)).toBe(
|
||||
'{ error: string; runId: string; status: "error" | "forbidden"; sentBeforeError?: true; sessionKey?: string; watched?: boolean } | { delivery: { mode: "announce"; status: "pending" | "skipped" }; runId: string; sessionKey: string; status: "accepted"; watched?: boolean } | { error: string; runId: string; sentBeforeError: true; sessionKey: string; status: "timeout"; delivery?: { mode: "announce"; status: "pending" | "skipped" }; watched?: boolean } | { delivery: { mode: "announce"; status: "pending" | "skipped" }; runId: string; sessionKey: string; status: "ok"; reply?: string; watched?: boolean }',
|
||||
);
|
||||
await waitForCalls(() => agentCallCount, 6);
|
||||
await waitForCalls(() => waitCallCount, 6);
|
||||
await waitForCalls(() => historyCallCount, 7);
|
||||
|
||||
@@ -87,6 +87,123 @@ const SessionStatusToolSchema = Type.Object({
|
||||
changesSince: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
});
|
||||
|
||||
const SessionStatusOriginSchema = Type.Object(
|
||||
{
|
||||
provider: Type.Optional(Type.String()),
|
||||
accountId: Type.Optional(Type.String()),
|
||||
threadId: Type.Optional(Type.Union([Type.String(), Type.Number()])),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const SessionStatusDeliveryContextSchema = Type.Object(
|
||||
{
|
||||
channel: Type.Optional(Type.String()),
|
||||
to: Type.Optional(Type.String()),
|
||||
accountId: Type.Optional(Type.String()),
|
||||
threadId: Type.Optional(Type.Union([Type.String(), Type.Number()])),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const SessionStatusStateEventPayloadSchema = Type.Object(
|
||||
{
|
||||
outcome: Type.Optional(
|
||||
Type.Union([Type.Literal("error"), Type.Literal("timeout"), Type.Literal("cancelled")]),
|
||||
),
|
||||
channel: Type.Optional(Type.String()),
|
||||
turns: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const SessionStatusStateEventSchema = Type.Object(
|
||||
{
|
||||
sequence: Type.Integer(),
|
||||
kind: Type.String(),
|
||||
actorType: Type.Union([Type.Literal("human"), Type.Literal("agent"), Type.Literal("system")]),
|
||||
occurredAt: Type.Number(),
|
||||
summary: Type.String(),
|
||||
actorId: Type.Optional(Type.String()),
|
||||
runId: Type.Optional(Type.String()),
|
||||
payload: Type.Optional(SessionStatusStateEventPayloadSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const SessionStatusOutputSchema = Type.Object(
|
||||
{
|
||||
ok: Type.Literal(true),
|
||||
sessionKey: Type.String(),
|
||||
changedModel: Type.Boolean(),
|
||||
stateVersion: Type.Integer(),
|
||||
statusText: Type.String(),
|
||||
stateChanges: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
events: Type.Array(SessionStatusStateEventSchema),
|
||||
truncated: Type.Boolean(),
|
||||
earliestAvailableSequence: Type.Integer(),
|
||||
historyGap: Type.Boolean(),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
model: Type.Optional(Type.String()),
|
||||
modelProvider: Type.Optional(Type.String()),
|
||||
modelOverride: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
origin: Type.Optional(SessionStatusOriginSchema),
|
||||
active: Type.Optional(SessionStatusDeliveryContextSchema),
|
||||
deliveryContext: Type.Optional(SessionStatusDeliveryContextSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
type SessionStatusStateChanges = ReturnType<typeof listSessionStateEventsSince>;
|
||||
|
||||
function compactSessionStateEventPayload(
|
||||
payload: Record<string, unknown> | undefined,
|
||||
): { outcome?: "error" | "timeout" | "cancelled"; channel?: string; turns?: number } | undefined {
|
||||
if (!payload) {
|
||||
return undefined;
|
||||
}
|
||||
const outcome =
|
||||
payload.outcome === "error" || payload.outcome === "timeout" || payload.outcome === "cancelled"
|
||||
? payload.outcome
|
||||
: undefined;
|
||||
const channel = readStringValue(payload.channel);
|
||||
const turns =
|
||||
typeof payload.turns === "number" && Number.isSafeInteger(payload.turns) && payload.turns > 0
|
||||
? payload.turns
|
||||
: undefined;
|
||||
return outcome || channel || turns !== undefined
|
||||
? {
|
||||
...(outcome ? { outcome } : {}),
|
||||
...(channel ? { channel } : {}),
|
||||
...(turns !== undefined ? { turns } : {}),
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function compactSessionStateChanges(stateChanges: SessionStatusStateChanges) {
|
||||
return {
|
||||
...stateChanges,
|
||||
events: stateChanges.events.map((event) => {
|
||||
const payload = compactSessionStateEventPayload(event.payload);
|
||||
return {
|
||||
sequence: event.sequence,
|
||||
kind: event.kind,
|
||||
actorType: event.actorType,
|
||||
occurredAt: event.occurredAt,
|
||||
summary: event.summary,
|
||||
...(event.actorId ? { actorId: event.actorId } : {}),
|
||||
...(event.runId ? { runId: event.runId } : {}),
|
||||
...(payload ? { payload } : {}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
type CommandsStatusRuntimeModule = {
|
||||
buildStatusText: (params: BuildStatusTextParams) => Promise<string>;
|
||||
};
|
||||
@@ -412,6 +529,7 @@ export function createSessionStatusTool(opts?: {
|
||||
displaySummary: SESSION_STATUS_TOOL_DISPLAY_SUMMARY,
|
||||
description: describeSessionStatusTool(),
|
||||
parameters: SessionStatusToolSchema,
|
||||
outputSchema: SessionStatusOutputSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
const changesSince = readNonNegativeIntegerParam(params, "changesSince");
|
||||
@@ -887,13 +1005,18 @@ export function createSessionStatusTool(opts?: {
|
||||
});
|
||||
const routeContextText = formatSessionStatusRouteContext(routeDetails);
|
||||
const stateVersion = getSessionStateVersion(resolved.key, agentId);
|
||||
const stateChanges =
|
||||
const rawStateChanges =
|
||||
changesSince !== undefined
|
||||
? listSessionStateEventsSince(resolved.key, agentId, changesSince, 200)
|
||||
: undefined;
|
||||
const stateChanges = rawStateChanges
|
||||
? compactSessionStateChanges(rawStateChanges)
|
||||
: undefined;
|
||||
const extraBlocks = [
|
||||
routeContextText,
|
||||
stateChanges ? formatSessionStateChanges({ stateVersion, stateChanges }) : undefined,
|
||||
rawStateChanges
|
||||
? formatSessionStateChanges({ stateVersion, stateChanges: rawStateChanges })
|
||||
: undefined,
|
||||
].filter((block): block is string => Boolean(block));
|
||||
const visibleStatusText =
|
||||
extraBlocks.length > 0
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/ind
|
||||
import type { CallGatewayOptions } from "../../gateway/call.js";
|
||||
import { parseThreadSessionSuffix } from "../../sessions/session-key-utils.js";
|
||||
import { deliveryContextFromSession } from "../../utils/delivery-context.shared.js";
|
||||
import type { SessionListRow } from "./sessions-helpers.js";
|
||||
import type { GatewaySessionListRow } from "./sessions-helpers.js";
|
||||
import type { AnnounceTarget } from "./sessions-send-helpers.js";
|
||||
import { resolveAnnounceTargetFromKey } from "./sessions-send-helpers.js";
|
||||
|
||||
@@ -38,7 +38,7 @@ export async function resolveAnnounceTarget(params: {
|
||||
}
|
||||
|
||||
try {
|
||||
const list = await callGatewayLazy<{ sessions: Array<SessionListRow> }>({
|
||||
const list = await callGatewayLazy<{ sessions: Array<GatewaySessionListRow> }>({
|
||||
method: "sessions.list",
|
||||
params: {
|
||||
includeGlobal: true,
|
||||
|
||||
@@ -40,8 +40,8 @@ type SessionListDeliveryContext = {
|
||||
/** Compact run status shown by session tools. */
|
||||
export type SessionRunStatus = "running" | "done" | "failed" | "killed" | "timeout";
|
||||
|
||||
/** Normalized session row returned by session list-style tools. */
|
||||
export type SessionListRow = {
|
||||
/** Full Gateway session row consumed by session orchestration internals. */
|
||||
export type GatewaySessionListRow = {
|
||||
key: string;
|
||||
agentId?: string;
|
||||
kind: SessionKind;
|
||||
@@ -93,6 +93,30 @@ export type SessionListRow = {
|
||||
messages?: unknown[];
|
||||
};
|
||||
|
||||
/** Focused model-facing row returned by sessions_list. */
|
||||
export type SessionListRow = {
|
||||
key: string;
|
||||
agentId: string;
|
||||
kind: SessionKind;
|
||||
channel: string;
|
||||
label?: string;
|
||||
displayName?: string;
|
||||
derivedTitle?: string;
|
||||
lastMessagePreview?: string;
|
||||
parentSessionKey?: string;
|
||||
updatedAt?: number;
|
||||
archived: boolean;
|
||||
pinned: boolean;
|
||||
stateVersion?: number;
|
||||
model?: string;
|
||||
contextTokens?: number;
|
||||
totalTokens?: number;
|
||||
status?: SessionRunStatus;
|
||||
abortedLastRun?: boolean;
|
||||
childSessions?: string[];
|
||||
messages?: unknown[];
|
||||
};
|
||||
|
||||
/** Resolves config plus sandbox visibility context for a session tool call. */
|
||||
export function resolveSessionToolContext(opts?: {
|
||||
agentSessionKey?: string;
|
||||
|
||||
@@ -4,9 +4,11 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { Value } from "typebox/value";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { callGateway as gatewayCall } from "../../gateway/call.js";
|
||||
import { deleteTestEnvValue, setTestEnvValue } from "../../test-utils/env.js";
|
||||
import { compactToolOutputHint } from "../tool-schema-hints.js";
|
||||
|
||||
type CallGatewayRequest = Parameters<typeof gatewayCall>[0];
|
||||
type HistoryMessage = {
|
||||
@@ -94,6 +96,21 @@ describe("sessions_history redaction", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("declares complete success and closed error contracts", async () => {
|
||||
const tool = createHistoryToolWithMessage("hello");
|
||||
const result = await tool.execute("contract", { sessionKey: "main" });
|
||||
|
||||
expect(tool.outputSchema).toBeDefined();
|
||||
expect(Value.Check(tool.outputSchema!, result.details)).toBe(true);
|
||||
expect(Value.Check(tool.outputSchema!, { status: "error", error: "missing" })).toBe(true);
|
||||
expect(
|
||||
Value.Check(tool.outputSchema!, { status: "forbidden", error: "hidden", extra: true }),
|
||||
).toBe(false);
|
||||
expect(compactToolOutputHint(tool.outputSchema)).toBe(
|
||||
'{ bytes: number; contentRedacted: boolean; contentTruncated: boolean; droppedMessages: boolean; messages: Array<unknown>; sessionKey: string; truncated: boolean; hasMore?: boolean; nextOffset?: number; offset?: number; totalMessages?: number } | { error: string; status: "error" | "forbidden" }',
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts recalled session text even when log redaction is disabled", async () => {
|
||||
// Recalled transcript content is model-visible, so it is always redacted
|
||||
// even when normal logging redaction is configured off.
|
||||
|
||||
@@ -45,6 +45,32 @@ const SessionsHistoryToolSchema = Type.Object({
|
||||
includeTools: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
const SessionsHistoryOutputSchema = Type.Union([
|
||||
Type.Object(
|
||||
{
|
||||
sessionKey: Type.String(),
|
||||
messages: Type.Array(Type.Unknown()),
|
||||
truncated: Type.Boolean(),
|
||||
droppedMessages: Type.Boolean(),
|
||||
contentTruncated: Type.Boolean(),
|
||||
contentRedacted: Type.Boolean(),
|
||||
bytes: Type.Number(),
|
||||
offset: Type.Optional(Type.Number()),
|
||||
nextOffset: Type.Optional(Type.Number()),
|
||||
hasMore: Type.Optional(Type.Boolean()),
|
||||
totalMessages: Type.Optional(Type.Number()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
Type.Object(
|
||||
{
|
||||
status: Type.Union([Type.Literal("error"), Type.Literal("forbidden")]),
|
||||
error: Type.String(),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
]);
|
||||
|
||||
const SESSIONS_HISTORY_MAX_BYTES = 80 * 1024;
|
||||
const SESSIONS_HISTORY_TEXT_MAX_CHARS = 4000;
|
||||
type GatewayCaller = typeof callGateway;
|
||||
@@ -357,6 +383,7 @@ export function createSessionsHistoryTool(opts?: {
|
||||
displaySummary: SESSIONS_HISTORY_TOOL_DISPLAY_SUMMARY,
|
||||
description: describeSessionsHistoryTool(),
|
||||
parameters: SessionsHistoryToolSchema,
|
||||
outputSchema: SessionsHistoryOutputSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
const gatewayCall = opts?.callGateway ?? callGateway;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// sessions_list tool tests cover session metadata projection, visibility
|
||||
// helpers, and numeric argument validation.
|
||||
import { Value } from "typebox/value";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { compactToolOutputHint } from "../tool-schema-hints.js";
|
||||
import { createSessionsListTool } from "./sessions-list-tool.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -46,26 +48,10 @@ vi.mock("./sessions-helpers.js", async (importActual) => {
|
||||
type SessionsListDetails = {
|
||||
sessions?: Array<{
|
||||
channel?: string;
|
||||
deliveryContext?: {
|
||||
accountId?: string;
|
||||
channel?: string;
|
||||
threadId?: string | number;
|
||||
to?: string;
|
||||
};
|
||||
elevatedLevel?: string;
|
||||
effectiveFastMode?: boolean | "auto";
|
||||
effectiveFastModeSource?: "session" | "agent" | "config" | "default";
|
||||
fastMode?: boolean | "auto";
|
||||
fastAutoOnSeconds?: number;
|
||||
archived?: boolean;
|
||||
archivedAt?: number;
|
||||
pinned?: boolean;
|
||||
pinnedAt?: number;
|
||||
stateVersion?: number;
|
||||
reasoningLevel?: string;
|
||||
responseUsage?: string;
|
||||
thinkingLevel?: string;
|
||||
verboseLevel?: string;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
};
|
||||
|
||||
@@ -112,9 +98,72 @@ describe("sessions-list-tool", () => {
|
||||
expect(getSessionsListDetails(result).sessions?.[1]?.stateVersion).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps deliveryContext.threadId in sessions_list results", async () => {
|
||||
// Thread/topic ids are required for channel-specific follow-up routing, so
|
||||
// list results must preserve both string and numeric variants.
|
||||
it("declares a complete focused row contract", async () => {
|
||||
mocks.gatewayCall.mockResolvedValue({
|
||||
path: "/tmp/sessions.json",
|
||||
sessions: [
|
||||
{
|
||||
key: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
kind: "other",
|
||||
channel: "discord",
|
||||
label: "worker",
|
||||
displayName: "Worker",
|
||||
derivedTitle: "Investigate queue",
|
||||
lastMessagePreview: "done",
|
||||
spawnedBy: "agent:main:main",
|
||||
updatedAt: 100,
|
||||
archived: false,
|
||||
pinned: true,
|
||||
model: "openai/gpt-5.4-mini",
|
||||
contextTokens: 20_000,
|
||||
totalTokens: 1_200,
|
||||
status: "done",
|
||||
abortedLastRun: false,
|
||||
childSessions: ["agent:main:subagent:grandchild"],
|
||||
},
|
||||
],
|
||||
});
|
||||
mocks.getSessionStateVersions.mockReturnValue({
|
||||
main: { "agent:main:subagent:child": 4 },
|
||||
});
|
||||
const tool = createSessionsListTool({ config: {} as never });
|
||||
const result = await tool.execute("contract", {});
|
||||
|
||||
expect(tool.outputSchema).toBeDefined();
|
||||
expect(Value.Check(tool.outputSchema!, result.details)).toBe(true);
|
||||
expect(compactToolOutputHint(tool.outputSchema)).toBe(
|
||||
'{ count: number; sessions: Array<{ agentId: string; archived: boolean; channel: string; key: string; kind: "main" | "group" | "cron" | "hook" | "node" | "other"; pinned: boolean; abortedLastRun?: boolean; childSessions?: Array<string>; contextTokens?: number; derivedTitle?: string; displayName?: string; label?: string; lastMessagePreview?: string; messages?: Array<unknown>; model?: string; parentSessionKey?: string; stateVersion?: number; status?: "running" | "done" | "failed" | "killed" | "timeout"; totalTokens?: number; updatedAt?: number }>; visibility?: { mode: "self" | "tree" | "agent"; restricted: true; warning: string } }',
|
||||
);
|
||||
expect(result.details).toEqual({
|
||||
count: 1,
|
||||
sessions: [
|
||||
{
|
||||
key: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
kind: "other",
|
||||
channel: "discord",
|
||||
archived: false,
|
||||
pinned: true,
|
||||
label: "worker",
|
||||
displayName: "Worker",
|
||||
derivedTitle: "Investigate queue",
|
||||
lastMessagePreview: "done",
|
||||
parentSessionKey: "agent:main:main",
|
||||
updatedAt: 100,
|
||||
stateVersion: 4,
|
||||
model: "openai/gpt-5.4-mini",
|
||||
contextTokens: 20_000,
|
||||
totalTokens: 1_200,
|
||||
status: "done",
|
||||
abortedLastRun: false,
|
||||
childSessions: ["agent:main:subagent:grandchild"],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps channel discovery but omits delivery routing metadata", async () => {
|
||||
mocks.gatewayCall.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string };
|
||||
if (request.method === "sessions.list") {
|
||||
@@ -153,55 +202,30 @@ describe("sessions-list-tool", () => {
|
||||
const result = await tool.execute("call-1", {});
|
||||
const details = getSessionsListDetails(result);
|
||||
|
||||
expect(details.sessions?.[0]?.deliveryContext).toEqual({
|
||||
channel: "discord",
|
||||
to: "discord:child",
|
||||
accountId: "acct-1",
|
||||
threadId: "thread-1",
|
||||
});
|
||||
expect(Object.hasOwn(details.sessions?.[0] ?? {}, "effectiveFastMode")).toBe(false);
|
||||
expect(details.sessions?.[1]?.deliveryContext).toEqual({
|
||||
channel: "telegram",
|
||||
to: "telegram:topic",
|
||||
accountId: "acct-2",
|
||||
threadId: 271,
|
||||
});
|
||||
expect(details.sessions?.map((session) => session.channel)).toEqual(["discord", "telegram"]);
|
||||
expect(details.sessions?.every((session) => !Object.hasOwn(session, "deliveryContext"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps numeric deliveryContext.threadId in sessions_list results", async () => {
|
||||
mocks.gatewayCall.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string };
|
||||
if (request.method === "sessions.list") {
|
||||
return {
|
||||
path: "/tmp/sessions.json",
|
||||
sessions: [
|
||||
{
|
||||
key: "agent:main:telegram:group:-100123:topic:99",
|
||||
kind: "group",
|
||||
sessionId: "sess-telegram-topic",
|
||||
deliveryContext: {
|
||||
channel: "telegram",
|
||||
to: "-100123",
|
||||
accountId: "acct-1",
|
||||
threadId: 99,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
it("prefers the explicit parent key over the legacy spawner", async () => {
|
||||
mocks.gatewayCall.mockResolvedValue({
|
||||
path: "/tmp/sessions.json",
|
||||
sessions: [
|
||||
{
|
||||
key: "agent:main:subagent:child",
|
||||
kind: "other",
|
||||
parentSessionKey: "agent:main:subagent:parent",
|
||||
spawnedBy: "agent:main:main",
|
||||
},
|
||||
],
|
||||
});
|
||||
const tool = createSessionsListTool({ config: {} as never });
|
||||
|
||||
const result = await tool.execute("call-2", {});
|
||||
const details = getSessionsListDetails(result);
|
||||
const result = await createSessionsListTool({ config: {} as never }).execute("lineage", {});
|
||||
|
||||
expect(details.sessions?.[0]?.deliveryContext).toEqual({
|
||||
channel: "telegram",
|
||||
to: "-100123",
|
||||
accountId: "acct-1",
|
||||
threadId: 99,
|
||||
});
|
||||
expect(getSessionsListDetails(result).sessions?.[0]?.parentSessionKey).toBe(
|
||||
"agent:main:subagent:parent",
|
||||
);
|
||||
});
|
||||
|
||||
it("derives channels only from structurally valid group session keys", async () => {
|
||||
@@ -255,7 +279,7 @@ describe("sessions-list-tool", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps live session setting metadata in sessions_list results", async () => {
|
||||
it("omits detailed runtime settings from discovery rows", async () => {
|
||||
mocks.gatewayCall.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string };
|
||||
if (request.method === "sessions.list") {
|
||||
@@ -287,18 +311,17 @@ describe("sessions-list-tool", () => {
|
||||
const details = getSessionsListDetails(result);
|
||||
|
||||
const session = details.sessions?.[0];
|
||||
expect(session?.thinkingLevel).toBe("high");
|
||||
expect(session?.fastMode).toBe("auto");
|
||||
expect(session?.effectiveFastMode).toBe("auto");
|
||||
expect(session?.effectiveFastModeSource).toBe("config");
|
||||
expect(session?.fastAutoOnSeconds).toBe(30);
|
||||
expect(session?.verboseLevel).toBe("on");
|
||||
expect(session?.reasoningLevel).toBe("deep");
|
||||
expect(session?.elevatedLevel).toBe("on");
|
||||
expect(session?.responseUsage).toBe("full");
|
||||
expect(session).toEqual({
|
||||
key: "main",
|
||||
agentId: "main",
|
||||
kind: "main",
|
||||
channel: "unknown",
|
||||
archived: false,
|
||||
pinned: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("requests archived sessions and keeps management metadata", async () => {
|
||||
it("requests archived sessions and keeps management state", async () => {
|
||||
mocks.gatewayCall.mockResolvedValue({
|
||||
path: "/tmp/sessions.json",
|
||||
sessions: [
|
||||
@@ -323,9 +346,9 @@ describe("sessions-list-tool", () => {
|
||||
);
|
||||
expect(getSessionsListDetails(result).sessions?.[0]).toMatchObject({
|
||||
archived: true,
|
||||
archivedAt: 20,
|
||||
pinned: false,
|
||||
});
|
||||
expect(getSessionsListDetails(result).sessions?.[0]).not.toHaveProperty("archivedAt");
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -3,20 +3,13 @@
|
||||
*
|
||||
* Lists visible sessions and optionally hydrates titles, last messages, and transcript-derived metadata.
|
||||
*/
|
||||
import path from "node:path";
|
||||
import {
|
||||
normalizeFastMode,
|
||||
normalizeOptionalLowercaseString,
|
||||
readStringValue,
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import pMap from "p-map";
|
||||
import { Type } from "typebox";
|
||||
import { getRuntimeConfig } from "../../config/config.js";
|
||||
import {
|
||||
resolveSessionFilePath,
|
||||
resolveSessionFilePathOptions,
|
||||
resolveStorePath,
|
||||
} from "../../config/sessions.js";
|
||||
import type { SessionEntry } from "../../config/sessions/types.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { callGateway } from "../../gateway/call.js";
|
||||
@@ -24,7 +17,6 @@ import { readSessionTitleFieldsFromTranscriptAsync } from "../../gateway/session
|
||||
import { deriveSessionTitle } from "../../gateway/session-utils.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
||||
import { getSessionStateVersions } from "../../sessions/session-state-events.js";
|
||||
import { normalizeFastModeAutoOnSeconds, normalizeFastModeSource } from "../../shared/fast-mode.js";
|
||||
import { deliveryContextFromSession } from "../../utils/delivery-context.shared.js";
|
||||
import {
|
||||
optionalNonNegativeIntegerSchema,
|
||||
@@ -52,6 +44,7 @@ import {
|
||||
resolveEffectiveSessionToolsVisibility,
|
||||
resolveInternalSessionKey,
|
||||
resolveSandboxedSessionToolContext,
|
||||
type GatewaySessionListRow,
|
||||
type SessionListRow,
|
||||
type SessionRunStatus,
|
||||
} from "./sessions-helpers.js";
|
||||
@@ -69,6 +62,65 @@ const SessionsListToolSchema = Type.Object({
|
||||
includeLastMessage: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
const SessionListRowOutputSchema = Type.Object(
|
||||
{
|
||||
key: Type.String(),
|
||||
agentId: Type.String(),
|
||||
kind: Type.Union([
|
||||
Type.Literal("main"),
|
||||
Type.Literal("group"),
|
||||
Type.Literal("cron"),
|
||||
Type.Literal("hook"),
|
||||
Type.Literal("node"),
|
||||
Type.Literal("other"),
|
||||
]),
|
||||
channel: Type.String(),
|
||||
archived: Type.Boolean(),
|
||||
pinned: Type.Boolean(),
|
||||
label: Type.Optional(Type.String()),
|
||||
displayName: Type.Optional(Type.String()),
|
||||
derivedTitle: Type.Optional(Type.String()),
|
||||
lastMessagePreview: Type.Optional(Type.String()),
|
||||
parentSessionKey: Type.Optional(Type.String()),
|
||||
updatedAt: Type.Optional(Type.Number()),
|
||||
stateVersion: Type.Optional(Type.Number()),
|
||||
model: Type.Optional(Type.String()),
|
||||
contextTokens: Type.Optional(Type.Number()),
|
||||
totalTokens: Type.Optional(Type.Number()),
|
||||
status: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Literal("running"),
|
||||
Type.Literal("done"),
|
||||
Type.Literal("failed"),
|
||||
Type.Literal("killed"),
|
||||
Type.Literal("timeout"),
|
||||
]),
|
||||
),
|
||||
abortedLastRun: Type.Optional(Type.Boolean()),
|
||||
childSessions: Type.Optional(Type.Array(Type.String())),
|
||||
messages: Type.Optional(Type.Array(Type.Unknown())),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const SessionsListOutputSchema = Type.Object(
|
||||
{
|
||||
count: Type.Number(),
|
||||
sessions: Type.Array(SessionListRowOutputSchema),
|
||||
visibility: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
mode: Type.Union([Type.Literal("self"), Type.Literal("tree"), Type.Literal("agent")]),
|
||||
restricted: Type.Literal(true),
|
||||
warning: Type.String(),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
type GatewayCaller = typeof callGateway;
|
||||
|
||||
const SESSIONS_LIST_TRANSCRIPT_FIELD_ROWS = 100;
|
||||
@@ -96,6 +148,7 @@ export function createSessionsListTool(opts?: {
|
||||
displaySummary: SESSIONS_LIST_TOOL_DISPLAY_SUMMARY,
|
||||
description: describeSessionsListTool(),
|
||||
parameters: SessionsListToolSchema,
|
||||
outputSchema: SessionsListOutputSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
const cfg = opts?.config ?? getRuntimeConfig();
|
||||
@@ -133,7 +186,7 @@ export function createSessionsListTool(opts?: {
|
||||
const a2aPolicy = createAgentToAgentPolicy(cfg);
|
||||
const hydrateTranscriptFieldsAfterFiltering = includeDerivedTitles || includeLastMessage;
|
||||
|
||||
const list = await gatewayCall<{ sessions: Array<SessionListRow>; path: string }>({
|
||||
const list = await gatewayCall<{ sessions: Array<GatewaySessionListRow>; path: string }>({
|
||||
method: "sessions.list",
|
||||
params: {
|
||||
limit,
|
||||
@@ -237,16 +290,7 @@ export function createSessionsListTool(opts?: {
|
||||
typeof entryOrigin?.provider === "string" ? entryOrigin.provider : undefined;
|
||||
const deliveryContext = deliveryContextFromSession(entry);
|
||||
const deliveryChannel = readStringValue(deliveryContext?.channel);
|
||||
const deliveryTo = readStringValue(deliveryContext?.to);
|
||||
const deliveryAccountId = readStringValue(deliveryContext?.accountId);
|
||||
const deliveryThreadId =
|
||||
typeof deliveryContext?.threadId === "string" ||
|
||||
(typeof deliveryContext?.threadId === "number" &&
|
||||
Number.isFinite(deliveryContext.threadId))
|
||||
? deliveryContext.threadId
|
||||
: undefined;
|
||||
const lastChannel = deliveryChannel ?? readStringValue(entry.lastChannel);
|
||||
const lastAccountId = deliveryAccountId ?? readStringValue(entry.lastAccountId);
|
||||
const derivedChannel = deriveChannel({
|
||||
key,
|
||||
kind,
|
||||
@@ -258,128 +302,67 @@ export function createSessionsListTool(opts?: {
|
||||
const sessionFileRaw = (entry as { sessionFile?: unknown }).sessionFile;
|
||||
const sessionFile = readStringValue(sessionFileRaw);
|
||||
const resolvedAgentId = resolveAgentIdFromSessionKey(key);
|
||||
let transcriptPath: string | undefined;
|
||||
if (sessionId) {
|
||||
try {
|
||||
const trimmedStorePath = storePath?.trim();
|
||||
let effectiveStorePath: string | undefined;
|
||||
if (trimmedStorePath && trimmedStorePath !== "(multiple)") {
|
||||
if (trimmedStorePath.includes("{agentId}") || trimmedStorePath.startsWith("~")) {
|
||||
effectiveStorePath = resolveStorePath(trimmedStorePath, {
|
||||
agentId: resolvedAgentId,
|
||||
});
|
||||
} else if (path.isAbsolute(trimmedStorePath)) {
|
||||
effectiveStorePath = trimmedStorePath;
|
||||
}
|
||||
}
|
||||
const filePathOpts = resolveSessionFilePathOptions({
|
||||
agentId: resolvedAgentId,
|
||||
storePath: effectiveStorePath,
|
||||
});
|
||||
transcriptPath = resolveSessionFilePath(
|
||||
sessionId,
|
||||
sessionFile ? { sessionFile } : undefined,
|
||||
filePathOpts,
|
||||
);
|
||||
} catch {
|
||||
transcriptPath = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveFastMode = normalizeFastMode(entry.effectiveFastMode);
|
||||
const effectiveFastModeSource = normalizeFastModeSource(entry.effectiveFastModeSource);
|
||||
const fastAutoOnSeconds = normalizeFastModeAutoOnSeconds(entry.fastAutoOnSeconds);
|
||||
// Version lookup keys on the store-owning agent (gateway row agentId), not the
|
||||
// key-derived agent: bare "global" keys parse to the default agent id.
|
||||
const stateVersionAgentId =
|
||||
typeof entry.agentId === "string" && entry.agentId ? entry.agentId : resolvedAgentId;
|
||||
const stateVersion = stateVersions[stateVersionAgentId]?.[key];
|
||||
const rowLabel = readStringValue(entry.label);
|
||||
const displayName = readStringValue(entry.displayName);
|
||||
const derivedTitle = readStringValue(entry.derivedTitle);
|
||||
const lastMessagePreview = readStringValue(entry.lastMessagePreview);
|
||||
const parentSessionKeyRaw =
|
||||
typeof entry.parentSessionKey === "string"
|
||||
? entry.parentSessionKey
|
||||
: typeof entry.spawnedBy === "string"
|
||||
? entry.spawnedBy
|
||||
: undefined;
|
||||
const parentSessionKey = parentSessionKeyRaw
|
||||
? resolveDisplaySessionKey({
|
||||
key: parentSessionKeyRaw,
|
||||
alias,
|
||||
mainKey,
|
||||
})
|
||||
: undefined;
|
||||
const updatedAt = typeof entry.updatedAt === "number" ? entry.updatedAt : undefined;
|
||||
const model = readStringValue(entry.model);
|
||||
const contextTokens =
|
||||
typeof entry.contextTokens === "number" ? entry.contextTokens : undefined;
|
||||
const totalTokens = typeof entry.totalTokens === "number" ? entry.totalTokens : undefined;
|
||||
const status = readSessionRunStatus(entry.status);
|
||||
const abortedLastRun =
|
||||
typeof entry.abortedLastRun === "boolean" ? entry.abortedLastRun : undefined;
|
||||
const childSessions = Array.isArray(entry.childSessions)
|
||||
? entry.childSessions
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
.map((value) =>
|
||||
resolveDisplaySessionKey({
|
||||
key: value,
|
||||
alias,
|
||||
mainKey,
|
||||
}),
|
||||
)
|
||||
: undefined;
|
||||
const row: SessionListRow = {
|
||||
key: displayKey,
|
||||
agentId: resolvedAgentId,
|
||||
kind,
|
||||
channel: derivedChannel,
|
||||
origin:
|
||||
originChannel ||
|
||||
(typeof entryOrigin?.accountId === "string" ? entryOrigin.accountId : undefined)
|
||||
? {
|
||||
provider: originChannel,
|
||||
accountId: readStringValue(entryOrigin?.accountId),
|
||||
}
|
||||
: undefined,
|
||||
spawnedBy:
|
||||
typeof entry.spawnedBy === "string"
|
||||
? resolveDisplaySessionKey({
|
||||
key: entry.spawnedBy,
|
||||
alias,
|
||||
mainKey,
|
||||
})
|
||||
: undefined,
|
||||
label: readStringValue(entry.label),
|
||||
displayName: readStringValue(entry.displayName),
|
||||
derivedTitle: readStringValue(entry.derivedTitle),
|
||||
lastMessagePreview: readStringValue(entry.lastMessagePreview),
|
||||
parentSessionKey:
|
||||
typeof entry.parentSessionKey === "string"
|
||||
? resolveDisplaySessionKey({
|
||||
key: entry.parentSessionKey,
|
||||
alias,
|
||||
mainKey,
|
||||
})
|
||||
: undefined,
|
||||
deliveryContext:
|
||||
deliveryChannel || deliveryTo || deliveryAccountId || deliveryThreadId
|
||||
? {
|
||||
channel: deliveryChannel,
|
||||
to: deliveryTo,
|
||||
accountId: deliveryAccountId,
|
||||
threadId: deliveryThreadId,
|
||||
}
|
||||
: undefined,
|
||||
updatedAt: typeof entry.updatedAt === "number" ? entry.updatedAt : undefined,
|
||||
archived: entry.archived === true,
|
||||
archivedAt: typeof entry.archivedAt === "number" ? entry.archivedAt : undefined,
|
||||
pinned: entry.pinned === true,
|
||||
pinnedAt: typeof entry.pinnedAt === "number" ? entry.pinnedAt : undefined,
|
||||
sessionId,
|
||||
...(rowLabel ? { label: rowLabel } : {}),
|
||||
...(displayName ? { displayName } : {}),
|
||||
...(derivedTitle ? { derivedTitle } : {}),
|
||||
...(lastMessagePreview ? { lastMessagePreview } : {}),
|
||||
...(parentSessionKey ? { parentSessionKey } : {}),
|
||||
...(updatedAt !== undefined ? { updatedAt } : {}),
|
||||
...(stateVersion ? { stateVersion } : {}),
|
||||
model: readStringValue(entry.model),
|
||||
contextTokens: typeof entry.contextTokens === "number" ? entry.contextTokens : undefined,
|
||||
totalTokens: typeof entry.totalTokens === "number" ? entry.totalTokens : undefined,
|
||||
estimatedCostUsd:
|
||||
typeof entry.estimatedCostUsd === "number" ? entry.estimatedCostUsd : undefined,
|
||||
status: readSessionRunStatus(entry.status),
|
||||
startedAt: typeof entry.startedAt === "number" ? entry.startedAt : undefined,
|
||||
endedAt: typeof entry.endedAt === "number" ? entry.endedAt : undefined,
|
||||
runtimeMs: typeof entry.runtimeMs === "number" ? entry.runtimeMs : undefined,
|
||||
childSessions: Array.isArray(entry.childSessions)
|
||||
? entry.childSessions
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
.map((value) =>
|
||||
resolveDisplaySessionKey({
|
||||
key: value,
|
||||
alias,
|
||||
mainKey,
|
||||
}),
|
||||
)
|
||||
: undefined,
|
||||
thinkingLevel: readStringValue(entry.thinkingLevel),
|
||||
fastMode: normalizeFastMode(entry.fastMode),
|
||||
...(effectiveFastMode !== undefined ? { effectiveFastMode } : {}),
|
||||
...(effectiveFastModeSource !== undefined ? { effectiveFastModeSource } : {}),
|
||||
...(fastAutoOnSeconds !== undefined ? { fastAutoOnSeconds } : {}),
|
||||
verboseLevel: readStringValue(entry.verboseLevel),
|
||||
reasoningLevel: readStringValue(entry.reasoningLevel),
|
||||
elevatedLevel: readStringValue(entry.elevatedLevel),
|
||||
responseUsage: readStringValue(entry.responseUsage),
|
||||
systemSent: typeof entry.systemSent === "boolean" ? entry.systemSent : undefined,
|
||||
abortedLastRun:
|
||||
typeof entry.abortedLastRun === "boolean" ? entry.abortedLastRun : undefined,
|
||||
sendPolicy: readStringValue(entry.sendPolicy),
|
||||
lastChannel,
|
||||
lastTo: deliveryTo ?? readStringValue(entry.lastTo),
|
||||
lastAccountId,
|
||||
transcriptPath,
|
||||
...(model ? { model } : {}),
|
||||
...(contextTokens !== undefined ? { contextTokens } : {}),
|
||||
...(totalTokens !== undefined ? { totalTokens } : {}),
|
||||
...(status ? { status } : {}),
|
||||
...(abortedLastRun !== undefined ? { abortedLastRun } : {}),
|
||||
...(childSessions ? { childSessions } : {}),
|
||||
};
|
||||
if (
|
||||
sessionId &&
|
||||
|
||||
@@ -6,7 +6,7 @@ import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { createSessionConversationTestRegistry } from "../../test-utils/session-conversation-registry.js";
|
||||
import { readLatestAssistantReplySnapshot, waitForAgentRun } from "../run-wait.js";
|
||||
import { runAgentStep } from "./agent-step.js";
|
||||
import type { SessionListRow } from "./sessions-helpers.js";
|
||||
import type { GatewaySessionListRow } from "./sessions-helpers.js";
|
||||
import { runSessionsSendA2AFlow } from "./sessions-send-tool.a2a.js";
|
||||
import { testing } from "./sessions-send-tool.a2a.test-support.js";
|
||||
|
||||
@@ -45,7 +45,7 @@ function firstMockArg(
|
||||
|
||||
describe("runSessionsSendA2AFlow announce delivery", () => {
|
||||
let gatewayCalls: CallGatewayOptions[];
|
||||
let sessionListRows: SessionListRow[];
|
||||
let sessionListRows: GatewaySessionListRow[];
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(createSessionConversationTestRegistry());
|
||||
@@ -296,7 +296,7 @@ describe("runSessionsSendA2AFlow announce delivery", () => {
|
||||
to: "channel:target-room",
|
||||
accountId: "thinker",
|
||||
},
|
||||
} satisfies SessionListRow,
|
||||
} satisfies GatewaySessionListRow,
|
||||
},
|
||||
{
|
||||
source: "lastAccountId",
|
||||
@@ -308,7 +308,7 @@ describe("runSessionsSendA2AFlow announce delivery", () => {
|
||||
lastChannel: "discord",
|
||||
lastTo: "channel:target-room",
|
||||
lastAccountId: "scout",
|
||||
} satisfies SessionListRow,
|
||||
} satisfies GatewaySessionListRow,
|
||||
},
|
||||
])("uses Discord session $source for announce accountId", async ({ accountId, session }) => {
|
||||
sessionListRows = [session];
|
||||
|
||||
@@ -70,6 +70,61 @@ const SessionsSendToolSchema = Type.Object({
|
||||
watch: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
const SessionsSendDeliverySchema = Type.Object(
|
||||
{
|
||||
status: Type.Union([Type.Literal("pending"), Type.Literal("skipped")]),
|
||||
mode: Type.Literal("announce"),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const SessionsSendOutputSchema = Type.Union([
|
||||
Type.Object(
|
||||
{
|
||||
runId: Type.String(),
|
||||
status: Type.Union([Type.Literal("error"), Type.Literal("forbidden")]),
|
||||
error: Type.String(),
|
||||
sessionKey: Type.Optional(Type.String()),
|
||||
sentBeforeError: Type.Optional(Type.Literal(true)),
|
||||
watched: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
Type.Object(
|
||||
{
|
||||
runId: Type.String(),
|
||||
status: Type.Literal("accepted"),
|
||||
sessionKey: Type.String(),
|
||||
delivery: SessionsSendDeliverySchema,
|
||||
watched: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
Type.Object(
|
||||
{
|
||||
runId: Type.String(),
|
||||
status: Type.Literal("timeout"),
|
||||
error: Type.String(),
|
||||
sentBeforeError: Type.Literal(true),
|
||||
sessionKey: Type.String(),
|
||||
delivery: Type.Optional(SessionsSendDeliverySchema),
|
||||
watched: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
Type.Object(
|
||||
{
|
||||
runId: Type.String(),
|
||||
status: Type.Literal("ok"),
|
||||
sessionKey: Type.String(),
|
||||
delivery: SessionsSendDeliverySchema,
|
||||
reply: Type.Optional(Type.String()),
|
||||
watched: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
]);
|
||||
|
||||
type GatewayCaller = typeof callGateway;
|
||||
const SESSIONS_SEND_REPLY_HISTORY_LIMIT = 50;
|
||||
const SESSIONS_SEND_MESSAGE_ALIASES = ["SendMessage", "content", "text"] as const;
|
||||
@@ -358,6 +413,7 @@ export function createSessionsSendTool(opts?: {
|
||||
displaySummary: SESSIONS_SEND_TOOL_DISPLAY_SUMMARY,
|
||||
description: describeSessionsSendTool(),
|
||||
parameters: SessionsSendToolSchema,
|
||||
outputSchema: SessionsSendOutputSchema,
|
||||
prepareArguments: normalizeSessionsSendArguments,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const params = normalizeSessionsSendArguments(args);
|
||||
@@ -813,9 +869,9 @@ export function createSessionsSendTool(opts?: {
|
||||
return jsonResult({
|
||||
runId,
|
||||
status: "ok",
|
||||
reply,
|
||||
sessionKey: displayKey,
|
||||
delivery,
|
||||
...(typeof reply === "string" ? { reply } : {}),
|
||||
...watchField,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// Sessions tool tests cover list/send helpers, transcript path reporting,
|
||||
// announce-target resolution, and assistant-visible text sanitization.
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
// Sessions tool tests cover list/send helpers, announce-target resolution,
|
||||
// and assistant-visible text sanitization.
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -9,7 +7,6 @@ import type { ChannelMessagingAdapter } from "../../channels/plugins/types.publi
|
||||
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../../config/io.js";
|
||||
import { parseSessionThreadInfo } from "../../config/sessions/thread-info.js";
|
||||
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import { withEnvAsync } from "../../test-utils/env.js";
|
||||
import { extractAssistantText, sanitizeTextContent } from "./chat-history-text.js";
|
||||
|
||||
const callGatewayMock = vi.fn();
|
||||
@@ -93,10 +90,6 @@ const resolveSessionTargetStub: NonNullable<ChannelMessagingAdapter["resolveSess
|
||||
threadId,
|
||||
}) => (threadId ? `${kind}:${id}:thread:${threadId}` : `${kind}:${id}`);
|
||||
|
||||
type SessionsListResult = Awaited<
|
||||
ReturnType<ReturnType<typeof import("./sessions-list-tool.js").createSessionsListTool>["execute"]>
|
||||
>;
|
||||
|
||||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`expected ${label}`);
|
||||
@@ -289,32 +282,6 @@ async function executeFireAndForgetA2AFrom(requesterSessionKey: string) {
|
||||
return flowParams;
|
||||
}
|
||||
|
||||
function getFirstListedSession(result: SessionsListResult) {
|
||||
const details = result.details as
|
||||
| { sessions?: Array<{ key?: string; transcriptPath?: string }> }
|
||||
| undefined;
|
||||
return details?.sessions?.[0];
|
||||
}
|
||||
|
||||
function expectWorkerTranscriptPath(
|
||||
result: SessionsListResult,
|
||||
params: { containsPath: string; sessionId: string },
|
||||
) {
|
||||
const session = getFirstListedSession(result);
|
||||
expect(session?.key).toBe("agent:worker:main");
|
||||
const transcriptPath = session?.transcriptPath ?? "";
|
||||
expect(path.normalize(transcriptPath)).toContain(path.normalize(params.containsPath));
|
||||
expect(transcriptPath).toMatch(new RegExp(`${params.sessionId}\\.jsonl$`));
|
||||
}
|
||||
|
||||
async function withStubbedStateDir<T>(
|
||||
name: string,
|
||||
run: (stateDir: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const stateDir = path.join(os.tmpdir(), name);
|
||||
return await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => await run(stateDir));
|
||||
}
|
||||
|
||||
describe("sanitizeTextContent", () => {
|
||||
it("strips minimax tool call XML and downgraded markers", () => {
|
||||
// Session recall should not replay provider/tool markup as assistant text.
|
||||
@@ -636,7 +603,7 @@ describe("sessions_list gating", () => {
|
||||
expect(details.count).toBe(1);
|
||||
const session = requireSessions(details)[0];
|
||||
expect(session?.key).toBe("agent:codex:acp:child-1");
|
||||
expect(session?.spawnedBy).toBe(MAIN_AGENT_SESSION_KEY);
|
||||
expect(session?.parentSessionKey).toBe(MAIN_AGENT_SESSION_KEY);
|
||||
expect(callGatewayMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -709,119 +676,6 @@ describe("sessions_list gating", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessions_list transcriptPath resolution", () => {
|
||||
beforeEach(() => {
|
||||
callGatewayMock.mockClear();
|
||||
loadConfigMock.mockReturnValue({
|
||||
session: { scope: "per-sender", mainKey: "main" },
|
||||
tools: {
|
||||
agentToAgent: { enabled: true },
|
||||
sessions: { visibility: "all" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves cross-agent transcript paths from agent defaults when gateway store path is relative", async () => {
|
||||
await withStubbedStateDir("openclaw-state-relative", async () => {
|
||||
callGatewayMock.mockResolvedValueOnce({
|
||||
path: "agents/main/sessions/sessions.json",
|
||||
sessions: [
|
||||
{
|
||||
key: "agent:worker:main",
|
||||
kind: "direct",
|
||||
sessionId: "sess-worker",
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await executeMainSessionsList();
|
||||
expectWorkerTranscriptPath(result, {
|
||||
containsPath: path.join("agents", "worker", "sessions"),
|
||||
sessionId: "sess-worker",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves transcriptPath even when sessions.list does not return a store path", async () => {
|
||||
await withStubbedStateDir("openclaw-state-no-path", async () => {
|
||||
callGatewayMock.mockResolvedValueOnce({
|
||||
sessions: [
|
||||
{
|
||||
key: "agent:worker:main",
|
||||
kind: "direct",
|
||||
sessionId: "sess-worker-no-path",
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await executeMainSessionsList();
|
||||
expectWorkerTranscriptPath(result, {
|
||||
containsPath: path.join("agents", "worker", "sessions"),
|
||||
sessionId: "sess-worker-no-path",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to agent defaults when gateway path is non-string", async () => {
|
||||
await withStubbedStateDir("openclaw-state-non-string-path", async () => {
|
||||
callGatewayMock.mockResolvedValueOnce({
|
||||
path: { raw: "agents/main/sessions/sessions.json" },
|
||||
sessions: [
|
||||
{
|
||||
key: "agent:worker:main",
|
||||
kind: "direct",
|
||||
sessionId: "sess-worker-shape",
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await executeMainSessionsList();
|
||||
expectWorkerTranscriptPath(result, {
|
||||
containsPath: path.join("agents", "worker", "sessions"),
|
||||
sessionId: "sess-worker-shape",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to agent defaults when gateway path is '(multiple)'", async () => {
|
||||
await withStubbedStateDir("openclaw-state-multiple", async (stateDir) => {
|
||||
callGatewayMock.mockResolvedValueOnce({
|
||||
path: "(multiple)",
|
||||
sessions: [
|
||||
{
|
||||
key: "agent:worker:main",
|
||||
kind: "direct",
|
||||
sessionId: "sess-worker-multiple",
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await executeMainSessionsList();
|
||||
expectWorkerTranscriptPath(result, {
|
||||
containsPath: path.join(stateDir, "agents", "worker", "sessions"),
|
||||
sessionId: "sess-worker-multiple",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves absolute {agentId} template paths per session agent", async () => {
|
||||
const templateStorePath = "/tmp/openclaw/agents/{agentId}/sessions/sessions.json";
|
||||
|
||||
callGatewayMock.mockResolvedValueOnce({
|
||||
path: templateStorePath,
|
||||
sessions: [
|
||||
{
|
||||
key: "agent:worker:main",
|
||||
kind: "direct",
|
||||
sessionId: "sess-worker-template",
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await executeMainSessionsList();
|
||||
const expectedSessionsDir = path.dirname(templateStorePath.replace("{agentId}", "worker"));
|
||||
expectWorkerTranscriptPath(result, {
|
||||
containsPath: expectedSessionsDir,
|
||||
sessionId: "sess-worker-template",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessions_list channel derivation", () => {
|
||||
beforeEach(() => {
|
||||
callGatewayMock.mockClear();
|
||||
|
||||
@@ -66,7 +66,7 @@ export function resolveFastModeModelParams(params: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function normalizeFastModeAutoOnSeconds(value: unknown): number | undefined {
|
||||
function normalizeFastModeAutoOnSeconds(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
@@ -150,12 +150,6 @@ export function formatFastModeCommandOptions(params?: { fastAutoOnSeconds?: numb
|
||||
})}, default, status`;
|
||||
}
|
||||
|
||||
export function normalizeFastModeSource(value: unknown): FastModeSource | undefined {
|
||||
return value === "session" || value === "agent" || value === "config" || value === "default"
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function formatFastModeSourceSuffix(source: FastModeSource | undefined): string {
|
||||
switch (source) {
|
||||
case "session":
|
||||
|
||||
Reference in New Issue
Block a user