From 746d257f0560d0c2f4e5e8a1171b9bad152fee64 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Jul 2026 06:43:12 +0100 Subject: [PATCH] feat: contract sessions-family tool outputs (#110424) * feat(agents): contract sessions-family tool outputs * refactor(agents): remove unused fast-mode exports --- docs/concepts/session-tool.md | 6 +- docs/tools/code-mode.md | 5 +- src/agents/fast-mode.test.ts | 4 +- .../openclaw-tools.session-status.test.ts | 40 ++- src/agents/openclaw-tools.sessions.test.ts | 77 +++--- src/agents/tools/session-status-tool.ts | 127 +++++++++- src/agents/tools/sessions-announce-target.ts | 4 +- src/agents/tools/sessions-helpers.ts | 28 ++- .../tools/sessions-history-tool.test.ts | 17 ++ src/agents/tools/sessions-history-tool.ts | 27 ++ src/agents/tools/sessions-list-tool.test.ts | 175 +++++++------ src/agents/tools/sessions-list-tool.ts | 237 ++++++++---------- .../tools/sessions-send-tool.a2a.test.ts | 8 +- src/agents/tools/sessions-send-tool.ts | 58 ++++- src/agents/tools/sessions.test.ts | 152 +---------- src/shared/fast-mode.ts | 8 +- 16 files changed, 544 insertions(+), 429 deletions(-) diff --git a/docs/concepts/session-tool.md b/docs/concepts/session-tool.md index 249f7958a1f7..94f192dd697a 100644 --- a/docs/concepts/session-tool.md +++ b/docs/concepts/session-tool.md @@ -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: ` 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. diff --git a/docs/tools/code-mode.md b/docs/tools/code-mode.md index ed3a990ee5df..acb42aead2cc 100644 --- a/docs/tools/code-mode.md +++ b/docs/tools/code-mode.md @@ -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`, diff --git a/src/agents/fast-mode.test.ts b/src/agents/fast-mode.test.ts index d5abc4bce577..6c1b192cb37b 100644 --- a/src/agents/fast-mode.test.ts +++ b/src/agents/fast-mode.test.ts @@ -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", () => { diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts index 7e87fe01d1dc..200a9d0fcbae 100644 --- a/src/agents/openclaw-tools.session-status.test.ts +++ b/src/agents/openclaw-tools.session-status.test.ts @@ -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; 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"'); }); diff --git a/src/agents/openclaw-tools.sessions.test.ts b/src/agents/openclaw-tools.sessions.test.ts index 27d67b6c7043..6b7019196c89 100644 --- a/src/agents/openclaw-tools.sessions.test.ts +++ b/src/agents/openclaw-tools.sessions.test.ts @@ -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>; }; 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); diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index 6155f84db7b5..1f0c2ccfe1c9 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -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; + +function compactSessionStateEventPayload( + payload: Record | 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; }; @@ -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; 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 diff --git a/src/agents/tools/sessions-announce-target.ts b/src/agents/tools/sessions-announce-target.ts index 17231ce1cc65..8a225aff955f 100644 --- a/src/agents/tools/sessions-announce-target.ts +++ b/src/agents/tools/sessions-announce-target.ts @@ -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 }>({ + const list = await callGatewayLazy<{ sessions: Array }>({ method: "sessions.list", params: { includeGlobal: true, diff --git a/src/agents/tools/sessions-helpers.ts b/src/agents/tools/sessions-helpers.ts index 984feefed15e..b0eea9b4c363 100644 --- a/src/agents/tools/sessions-helpers.ts +++ b/src/agents/tools/sessions-helpers.ts @@ -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; diff --git a/src/agents/tools/sessions-history-tool.test.ts b/src/agents/tools/sessions-history-tool.test.ts index 08c85dc9a5d5..dee000c9ce72 100644 --- a/src/agents/tools/sessions-history-tool.test.ts +++ b/src/agents/tools/sessions-history-tool.test.ts @@ -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[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; 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. diff --git a/src/agents/tools/sessions-history-tool.ts b/src/agents/tools/sessions-history-tool.ts index 373650ca64de..60c8a51107c2 100644 --- a/src/agents/tools/sessions-history-tool.ts +++ b/src/agents/tools/sessions-history-tool.ts @@ -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; const gatewayCall = opts?.callGateway ?? callGateway; diff --git a/src/agents/tools/sessions-list-tool.test.ts b/src/agents/tools/sessions-list-tool.test.ts index 714c168c353f..155f1a152b12 100644 --- a/src/agents/tools/sessions-list-tool.test.ts +++ b/src/agents/tools/sessions-list-tool.test.ts @@ -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; contextTokens?: number; derivedTitle?: string; displayName?: string; label?: string; lastMessagePreview?: string; messages?: Array; 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([ diff --git a/src/agents/tools/sessions-list-tool.ts b/src/agents/tools/sessions-list-tool.ts index 954dd136f6b2..f9b1ea9d99d4 100644 --- a/src/agents/tools/sessions-list-tool.ts +++ b/src/agents/tools/sessions-list-tool.ts @@ -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; 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; path: string }>({ + const list = await gatewayCall<{ sessions: Array; 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 && diff --git a/src/agents/tools/sessions-send-tool.a2a.test.ts b/src/agents/tools/sessions-send-tool.a2a.test.ts index 7d9fc205d4bf..ebc404d3b0d5 100644 --- a/src/agents/tools/sessions-send-tool.a2a.test.ts +++ b/src/agents/tools/sessions-send-tool.a2a.test.ts @@ -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]; diff --git a/src/agents/tools/sessions-send-tool.ts b/src/agents/tools/sessions-send-tool.ts index f799320b9561..f322b09ec680 100644 --- a/src/agents/tools/sessions-send-tool.ts +++ b/src/agents/tools/sessions-send-tool.ts @@ -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, }); }, diff --git a/src/agents/tools/sessions.test.ts b/src/agents/tools/sessions.test.ts index 46cbb8ed6945..2d012594c888 100644 --- a/src/agents/tools/sessions.test.ts +++ b/src/agents/tools/sessions.test.ts @@ -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 (threadId ? `${kind}:${id}:thread:${threadId}` : `${kind}:${id}`); -type SessionsListResult = Awaited< - ReturnType["execute"]> ->; - function requireRecord(value: unknown, label: string): Record { 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( - name: string, - run: (stateDir: string) => Promise, -): Promise { - 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(); diff --git a/src/shared/fast-mode.ts b/src/shared/fast-mode.ts index 7876fbceb8f7..2b5494d346ea 100644 --- a/src/shared/fast-mode.ts +++ b/src/shared/fast-mode.ts @@ -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":