mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
feat(agents): agent-controlled session status, attention, and TTL in the sessions tool (#111583)
* feat(agents): agent-controlled session status, attention, and TTL in the sessions tool * feat(protocol): sessions.patch agent status fields and curated attention icon ids * chore(protocol): regenerate bindings for agent-status patch fields, unexport internal cap * chore(sessions): unexport internal agent-status TTL default
This commit is contained in:
committed by
GitHub
parent
25d6f13a1f
commit
bc76bb8933
@@ -6184,6 +6184,9 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
public let label: AnyCodable?
|
||||
public let category: AnyCodable?
|
||||
public let icon: AnyCodable?
|
||||
public let statusnote: AnyCodable?
|
||||
public let attention: AnyCodable?
|
||||
public let ttlminutes: Int?
|
||||
public let archived: Bool?
|
||||
public let pinned: Bool?
|
||||
public let unread: Bool?
|
||||
@@ -6216,6 +6219,9 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
label: AnyCodable? = nil,
|
||||
category: AnyCodable? = nil,
|
||||
icon: AnyCodable? = nil,
|
||||
statusnote: AnyCodable? = nil,
|
||||
attention: AnyCodable? = nil,
|
||||
ttlminutes: Int? = nil,
|
||||
archived: Bool? = nil,
|
||||
pinned: Bool? = nil,
|
||||
unread: Bool? = nil,
|
||||
@@ -6247,6 +6253,9 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
self.label = label
|
||||
self.category = category
|
||||
self.icon = icon
|
||||
self.statusnote = statusnote
|
||||
self.attention = attention
|
||||
self.ttlminutes = ttlminutes
|
||||
self.archived = archived
|
||||
self.pinned = pinned
|
||||
self.unread = unread
|
||||
@@ -6280,6 +6289,9 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
case label
|
||||
case category
|
||||
case icon
|
||||
case statusnote = "statusNote"
|
||||
case attention
|
||||
case ttlminutes = "ttlMinutes"
|
||||
case archived
|
||||
case pinned
|
||||
case unread
|
||||
|
||||
@@ -192,7 +192,7 @@ and [Linux](/platforms/linux) desktop apps, the
|
||||
|
||||
## Sidebar navigation
|
||||
|
||||
The sidebar organizes everything around the agent. The identity row at the top is the active agent; below it, the **Pages** section starts with **Home** — the agent's rolling main session, badged with its unread or running state — followed by the pinned destinations (**Usage**, **Automations**, and **Plugins** by default). The customize control on the Pages header opens a menu with every other destination, including plugin-provided tabs, plus **Edit pinned items**; right-clicking the navigation area opens the pin editor directly. The session list below splits into zones: **Threads** for the agent's chat sessions (the main session stays behind Home; sessions it spawned appear here as top-level threads, and named threads show without a type prefix), **Groups** for group and room conversations, and **Coding** for sessions bound to a managed worktree or exec node (rows show a `repo ⎇ branch` line plus the node host), ACP-backed harness sessions, and the Codex/Claude CLI catalogs. Coding starts collapsed on first run and remembers your choice; its collapsed header keeps the true count and shows a running indicator while contained sessions work. Custom groups (the session `category`) and **Pinned** rows sit above Threads, and assigning a session to a custom group always wins over the automatic zone classification. The Threads header holds the sort control (Created or Last updated, plus a Group by toggle) and the **+** that opens the New session page. Opening a session moves the selection highlight without reordering rows. Parent sessions with recent child runs show a disclosure and child count; expand it to inspect nested child sessions, live or terminal status, and runtime without leaving the sidebar. Selecting a child opens its chat and automatically reveals its ancestor path. Child rows stay outside root grouping, pinning, dragging, multi-select, and pagination; collapsed zones do not consume the visible page budget. Sessions with new activity since they were last read show an unread dot, and opening one marks it read. Cloud-worker lifecycle states use a globe badge; local and reclaimed sessions omit a placement badge because local execution is the default. Each root session row has a context menu (kebab button or right-click) with Pin/Unpin, Mark as unread/read, Rename, Fork, Move to group (including New group and Remove from group), Archive, and Delete; touch layouts keep the direct pin and menu controls visible. Cmd/Ctrl-click toggles root rows into a multi-select and Shift-click extends it across the visible order; opening the menu on a selected row then offers batch actions (Mark N as unread/read, Move N to group, Archive N, Delete N) that apply to every selected session, with a single confirmation for batch delete. Drag a root session onto **Pinned** to pin it, or onto a custom group to move it. Custom group headers can be collapsed, expanded, or dragged to reorder them; group names and their order live in the gateway (`sessions.groups.*`), so they follow you across browsers, while collapsed state stays in the browser profile. Group headers also have a menu (kebab button or right-click) with Rename group, New group, and Delete group; renaming or deleting a group updates every member session server-side, including archived ones, and deleting a group keeps its sessions and moves them back to Threads.
|
||||
The sidebar organizes everything around the agent. The identity row at the top is the active agent; below it, the **Pages** section starts with **Home** — the agent's rolling main session, badged with its unread or running state — followed by the pinned destinations (**Usage**, **Automations**, and **Plugins** by default). The customize control on the Pages header opens a menu with every other destination, including plugin-provided tabs, plus **Edit pinned items**; right-clicking the navigation area opens the pin editor directly. The session list below splits into zones: **Threads** for the agent's chat sessions (the main session stays behind Home; sessions it spawned appear here as top-level threads, and named threads show without a type prefix), **Groups** for group and room conversations, and **Coding** for sessions bound to a managed worktree or exec node (rows show a `repo ⎇ branch` line plus the node host), ACP-backed harness sessions, and the Codex/Claude CLI catalogs. Coding starts collapsed on first run and remembers your choice; its collapsed header keeps the true count and shows a running indicator while contained sessions work. Custom groups (the session `category`) and **Pinned** rows sit above Threads, and assigning a session to a custom group always wins over the automatic zone classification. The Threads header holds the sort control (Created or Last updated, plus a Group by toggle) and the **+** that opens the New session page. Opening a session moves the selection highlight without reordering rows. Parent sessions with recent child runs show a disclosure and child count; expand it to inspect nested child sessions, live or terminal status, and runtime without leaving the sidebar. Selecting a child opens its chat and automatically reveals its ancestor path. Child rows stay outside root grouping, pinning, dragging, multi-select, and pagination; collapsed zones do not consume the visible page budget. Sessions with new activity since they were last read show an unread dot, and opening one marks it read. An agent can also publish a short expiring status line and optionally request attention with a curated amber icon; that declaration clears when you open the session, send the next message, clear it explicitly, or its TTL expires. Cloud-worker lifecycle states use a globe badge; local and reclaimed sessions omit a placement badge because local execution is the default. Each root session row has a context menu (kebab button or right-click) with Pin/Unpin, Mark as unread/read, Rename, Fork, Move to group (including New group and Remove from group), Archive, and Delete; touch layouts keep the direct pin and menu controls visible. Cmd/Ctrl-click toggles root rows into a multi-select and Shift-click extends it across the visible order; opening the menu on a selected row then offers batch actions (Mark N as unread/read, Move N to group, Archive N, Delete N) that apply to every selected session, with a single confirmation for batch delete. Drag a root session onto **Pinned** to pin it, or onto a custom group to move it. Custom group headers can be collapsed, expanded, or dragged to reorder them; group names and their order live in the gateway (`sessions.groups.*`), so they follow you across browsers, while collapsed state stays in the browser profile. Group headers also have a menu (kebab button or right-click) with Rename group, New group, and Delete group; renaming or deleting a group updates every member session server-side, including archived ones, and deleting a group keeps its sessions and moves them back to Threads.
|
||||
|
||||
## New session page
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Gateway Protocol schema module defines protocol validation shapes.
|
||||
import type { Static } from "typebox";
|
||||
import { Type } from "typebox";
|
||||
import { SESSION_AGENT_ATTENTION_ICON_IDS } from "../session-icon.js";
|
||||
import { closedObject } from "./closed-object.js";
|
||||
import { ErrorShapeSchema } from "./frames.js";
|
||||
import { PluginJsonValueSchema } from "./plugins.js";
|
||||
@@ -369,6 +370,15 @@ export const SessionsPatchParamsSchema = closedObject({
|
||||
description: "Sidebar icon: one emoji, name:<id>, or svg:<svg ...>...</svg>.",
|
||||
}),
|
||||
),
|
||||
statusNote: Type.Optional(
|
||||
Type.Union([Type.String({ maxLength: 120 }), Type.Null()], {
|
||||
description: "Short expiring sidebar status note; null clears it and any declared attention.",
|
||||
}),
|
||||
),
|
||||
attention: Type.Optional(
|
||||
Type.Union([Type.String({ enum: [...SESSION_AGENT_ATTENTION_ICON_IDS] }), Type.Null()]),
|
||||
),
|
||||
ttlMinutes: Type.Optional(Type.Integer({ minimum: 1, maximum: 120 })),
|
||||
archived: Type.Optional(Type.Boolean()),
|
||||
pinned: Type.Optional(Type.Boolean()),
|
||||
unread: Type.Optional(
|
||||
|
||||
@@ -3,6 +3,23 @@ export type SessionIcon =
|
||||
| { kind: "emoji"; emoji: string }
|
||||
| { kind: "svg"; svg: string };
|
||||
|
||||
export const SESSION_AGENT_ATTENTION_ICON_IDS = [
|
||||
"hand",
|
||||
"key",
|
||||
"alert",
|
||||
"flag",
|
||||
"lock",
|
||||
"hourglass",
|
||||
] as const;
|
||||
|
||||
export type SessionAgentAttentionIconId = (typeof SESSION_AGENT_ATTENTION_ICON_IDS)[number];
|
||||
|
||||
export type SessionAgentStatus = {
|
||||
note: string;
|
||||
expiresAt: number;
|
||||
attention?: SessionAgentAttentionIconId;
|
||||
};
|
||||
|
||||
export type SessionIconNormalizationResult =
|
||||
| { ok: true; value: string }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
@@ -130,6 +130,7 @@ export async function prepareEmbeddedSessionState(params: {
|
||||
updatedAt: now,
|
||||
sessionStartedAt: entry.sessionStartedAt ?? now,
|
||||
lastInteractionAt: now,
|
||||
agentStatus: undefined,
|
||||
};
|
||||
applyVerboseOverride(next, params.verboseOverride);
|
||||
sessionEntry = await persistSessionEntry({
|
||||
|
||||
@@ -23,6 +23,27 @@ describe("sessions tool", () => {
|
||||
expect(GATEWAY_OWNER_ONLY_CORE_TOOLS).toContain("sessions");
|
||||
});
|
||||
|
||||
it("advertises the full model-visible sidebar presence contract", () => {
|
||||
const tool = createSessionsTool({ agentSessionKey: "agent:main:main", callGateway: vi.fn() });
|
||||
expect(tool.parameters).toMatchObject({
|
||||
type: "object",
|
||||
properties: {
|
||||
action: {
|
||||
type: "string",
|
||||
enum: ["patch", "group_list", "group_set", "group_rename", "group_delete"],
|
||||
},
|
||||
label: { type: "string", description: expect.stringContaining("Empty string clears") },
|
||||
statusNote: { type: "string", maxLength: 120 },
|
||||
attention: {
|
||||
type: "string",
|
||||
enum: ["clear", "hand", "key", "alert", "flag", "lock", "hourglass"],
|
||||
},
|
||||
ttlMinutes: { type: "integer", minimum: 1, maximum: 120 },
|
||||
archived: { type: "boolean", description: expect.stringContaining("without deleting") },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("patches its session, then reverts a failed agent-selected model", async () => {
|
||||
await withTempDir({ prefix: "openclaw-sessions-tool-" }, async (dir) => {
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
@@ -386,6 +407,40 @@ describe("sessions tool", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("patches and clears title, status, attention, and archive state", async () => {
|
||||
const callGateway = vi.fn(async () => ({ ok: true }));
|
||||
const tool = createSessionsTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
config: {},
|
||||
callGateway: callGateway as never,
|
||||
});
|
||||
|
||||
await tool.execute("declare", {
|
||||
action: "patch",
|
||||
label: "Waiting on staging",
|
||||
statusNote: "Blocked: need the staging password",
|
||||
attention: "key",
|
||||
ttlMinutes: 45,
|
||||
archived: true,
|
||||
});
|
||||
await tool.execute("clear", { action: "patch", label: "", attention: "clear" });
|
||||
|
||||
expect(callGateway.mock.calls).toEqual([
|
||||
[
|
||||
"sessions.patch",
|
||||
{
|
||||
key: "agent:main:main",
|
||||
label: "Waiting on staging",
|
||||
statusNote: "Blocked: need the staging password",
|
||||
attention: "key",
|
||||
ttlMinutes: 45,
|
||||
archived: true,
|
||||
},
|
||||
],
|
||||
["sessions.patch", { key: "agent:main:main", label: null, attention: null }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects an empty patch", async () => {
|
||||
const callGateway = vi.fn();
|
||||
const tool = createSessionsTool({
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/** Session self-service tool. */
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { Type } from "typebox";
|
||||
import { SESSION_AGENT_ATTENTION_ICON_IDS } from "../../../packages/gateway-protocol/src/session-icon.js";
|
||||
import { getRuntimeConfig } from "../../config/config.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { withAgentSessionModelPatchOrigin } from "../../gateway/session-model-patch-origin.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
||||
import { stringEnum } from "../schema/typebox.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
import { jsonResult, readStringParam, ToolAuthorizationError, ToolInputError } from "./common.js";
|
||||
import {
|
||||
@@ -26,22 +28,41 @@ const GROUP_NAMES_MAX_ITEMS = 200;
|
||||
|
||||
const SessionsToolSchema = Type.Object(
|
||||
{
|
||||
action: Type.Union(
|
||||
ACTIONS.map((action) => Type.Literal(action)),
|
||||
{
|
||||
description: "Action",
|
||||
},
|
||||
),
|
||||
action: stringEnum(ACTIONS, { description: "Action" }),
|
||||
sessionKey: Type.Optional(Type.String({ description: "Target session. Default: current" })),
|
||||
label: Type.Optional(Type.String({ description: "Session label" })),
|
||||
label: Type.Optional(
|
||||
Type.String({ description: "Sidebar title override. Empty string clears it." }),
|
||||
),
|
||||
icon: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Sidebar icon: an emoji, name:<curated-id>, or svg:<svg …> you draw yourself (tiny, sanitized). Empty string removes it.",
|
||||
}),
|
||||
),
|
||||
statusNote: Type.Optional(
|
||||
Type.String({
|
||||
maxLength: 120,
|
||||
description:
|
||||
"Short sidebar status line. Empty string clears it and declared attention. Clears automatically when the user reads or replies, or when its TTL expires.",
|
||||
}),
|
||||
),
|
||||
attention: Type.Optional(
|
||||
stringEnum(["clear", ...SESSION_AGENT_ATTENTION_ICON_IDS] as const, {
|
||||
description:
|
||||
"Request user attention with a curated icon; requires an active statusNote. 'clear' clears both attention and statusNote.",
|
||||
}),
|
||||
),
|
||||
ttlMinutes: Type.Optional(
|
||||
Type.Integer({
|
||||
minimum: 1,
|
||||
maximum: 120,
|
||||
description: "Status/attention lifetime in minutes. Default 30; maximum 120.",
|
||||
}),
|
||||
),
|
||||
pinned: Type.Optional(Type.Boolean({ description: "Pin session" })),
|
||||
archived: Type.Optional(Type.Boolean({ description: "Archive session" })),
|
||||
archived: Type.Optional(
|
||||
Type.Boolean({ description: "True archives without deleting; false restores the session." }),
|
||||
),
|
||||
model: Type.Optional(Type.String({ description: "Model override" })),
|
||||
thinkingLevel: Type.Optional(Type.String({ description: "Thinking override" })),
|
||||
names: Type.Optional(Type.Array(Type.String(), { description: "Ordered group names" })),
|
||||
@@ -70,6 +91,17 @@ function readBoolean(params: Record<string, unknown>, key: string): boolean | un
|
||||
return value;
|
||||
}
|
||||
|
||||
function readInteger(params: Record<string, unknown>, key: string): number | undefined {
|
||||
const value = params[key];
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Number.isInteger(value)) {
|
||||
throw new ToolInputError(`${key} must be an integer`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function readClearableString(params: Record<string, unknown>, key: string): string | null {
|
||||
const value = params[key];
|
||||
if (value === null) {
|
||||
@@ -183,10 +215,22 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool
|
||||
);
|
||||
const patch = {
|
||||
key,
|
||||
...(params.label !== undefined
|
||||
? { label: readStringParam(params, "label", { required: true }) }
|
||||
: {}),
|
||||
...(params.label !== undefined ? { label: readClearableString(params, "label") } : {}),
|
||||
...(params.icon !== undefined ? { icon: readClearableString(params, "icon") } : {}),
|
||||
...(params.statusNote !== undefined
|
||||
? { statusNote: readClearableString(params, "statusNote") }
|
||||
: {}),
|
||||
...(params.attention !== undefined
|
||||
? {
|
||||
attention:
|
||||
readStringParam(params, "attention", { required: true }) === "clear"
|
||||
? null
|
||||
: readStringParam(params, "attention", { required: true }),
|
||||
}
|
||||
: {}),
|
||||
...(params.ttlMinutes !== undefined
|
||||
? { ttlMinutes: readInteger(params, "ttlMinutes") }
|
||||
: {}),
|
||||
...(params.pinned !== undefined ? { pinned: readBoolean(params, "pinned") } : {}),
|
||||
...(params.archived !== undefined ? { archived: readBoolean(params, "archived") } : {}),
|
||||
...(params.model !== undefined
|
||||
|
||||
@@ -216,6 +216,7 @@ export function initFastReplySessionState(params: {
|
||||
updatedAt: now,
|
||||
sessionStartedAt: resetTriggered ? now : (existingEntry?.sessionStartedAt ?? now),
|
||||
lastInteractionAt: now,
|
||||
agentStatus: undefined,
|
||||
thinkingLevel: resetTriggered ? existingEntry?.thinkingLevel : existingEntry?.thinkingLevel,
|
||||
verboseLevel: resetTriggered ? existingEntry?.verboseLevel : existingEntry?.verboseLevel,
|
||||
reasoningLevel: resetTriggered ? existingEntry?.reasoningLevel : existingEntry?.reasoningLevel,
|
||||
|
||||
@@ -898,6 +898,7 @@ async function initSessionStateAttemptLocked(
|
||||
? now
|
||||
: (baseEntry?.sessionStartedAt ?? lifecycleTimestamps.sessionStartedAt),
|
||||
lastInteractionAt: isSystemEvent ? baseEntry?.lastInteractionAt : now,
|
||||
agentStatus: isSystemEvent ? baseEntry?.agentStatus : undefined,
|
||||
systemSent,
|
||||
abortedLastRun: recoveredTerminalEntry ? undefined : abortedLastRun,
|
||||
// Persist previously stored thinking/verbose levels when present.
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
SessionAcpMeta,
|
||||
} from "@openclaw/acp-core/types";
|
||||
import { normalizeOptionalString, type FastMode } from "@openclaw/normalization-core/string-coerce";
|
||||
import type { SessionAgentStatus } from "../../../packages/gateway-protocol/src/session-icon.js";
|
||||
import type { ChatType } from "../../channels/chat-type.js";
|
||||
import type { ChannelId } from "../../channels/plugins/channel-id.types.js";
|
||||
import type { ChannelRouteRef } from "../../plugin-sdk/channel-route.js";
|
||||
@@ -265,6 +266,8 @@ export type SessionEntry = SessionRestartRecoveryState &
|
||||
icon?: string;
|
||||
/** Timestamp (ms) when an operator client last marked the session read. */
|
||||
lastReadAt?: number;
|
||||
/** Agent-declared sidebar presence; projection drops it after expiresAt. */
|
||||
agentStatus?: SessionAgentStatus;
|
||||
/** Timestamp (ms) when an operator explicitly marked the session unread; cleared on read. */
|
||||
markedUnreadAt?: number;
|
||||
/** Timestamp (ms) of the latest completed agent run; metadata patches do not update it. */
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveSessionResetPolicy, type SessionEntry } from "../../config/sessions.js";
|
||||
import { buildAgentSessionPatch } from "./agent-session-patch.js";
|
||||
|
||||
function buildPatch(touchInteraction: boolean) {
|
||||
const now = 1_000;
|
||||
const entry: SessionEntry = {
|
||||
sessionId: "session",
|
||||
updatedAt: now,
|
||||
agentStatus: { note: "Need a password", attention: "key", expiresAt: now + 60_000 },
|
||||
};
|
||||
return buildAgentSessionPatch({
|
||||
freshEntry: entry,
|
||||
initialEntry: entry,
|
||||
cfg: {},
|
||||
sessionAgentId: "main",
|
||||
canonicalSessionKey: "agent:main:main",
|
||||
storePath: "/tmp/openclaw-agent-status-test.json",
|
||||
normalizedSpawned: {},
|
||||
requestDeliveryHint: undefined,
|
||||
expectedExistingSessionId: entry.sessionId,
|
||||
hasRestoredCronContinuation: false,
|
||||
resetPolicy: resolveSessionResetPolicy({ resetType: "direct" }),
|
||||
now,
|
||||
isSystemGatewayRun: true,
|
||||
visibleRequest: true,
|
||||
fallbackSessionId: "fallback",
|
||||
touchInteraction,
|
||||
failedSessionTranscriptMissing: () => false,
|
||||
}).patch;
|
||||
}
|
||||
|
||||
describe("agent session patch", () => {
|
||||
it("clears agent status at the next human interaction boundary", () => {
|
||||
const patch = buildPatch(true);
|
||||
expect(Object.hasOwn(patch, "agentStatus")).toBe(true);
|
||||
expect(patch.agentStatus).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not clear agent status for lifecycle-only patches", () => {
|
||||
expect(Object.hasOwn(buildPatch(false), "agentStatus")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -209,7 +209,14 @@ export function buildAgentSessionPatch(params: {
|
||||
sessionId: patchSessionId,
|
||||
updatedAt: params.now,
|
||||
...(freshIsNewSession && !freshSessionRotatedSinceLoad ? { sessionStartedAt: params.now } : {}),
|
||||
...(params.touchInteraction ? { lastInteractionAt: params.now } : {}),
|
||||
...(params.touchInteraction
|
||||
? {
|
||||
lastInteractionAt: params.now,
|
||||
// Clear at human-turn admission, before the model may declare a new
|
||||
// status. Later lifecycle writes must not erase a same-turn declaration.
|
||||
agentStatus: undefined,
|
||||
}
|
||||
: {}),
|
||||
...automaticRecoveryClearPatch,
|
||||
...(effectiveDeliveryFields.route ? { route: effectiveDeliveryFields.route } : {}),
|
||||
...(effectiveDeliveryFields.deliveryContext
|
||||
|
||||
@@ -41,6 +41,7 @@ export function buildGatewaySessionEventFields(params: {
|
||||
icon: sessionRow.icon ?? null,
|
||||
unread: sessionRow.unread ?? false,
|
||||
lastReadAt: sessionRow.lastReadAt,
|
||||
agentStatus: sessionRow.agentStatus ?? null,
|
||||
lastActivityAt: sessionRow.lastActivityAt,
|
||||
spawnedBy: sessionRow.spawnedBy,
|
||||
swarmGroupId: sessionRow.swarmGroupId,
|
||||
|
||||
@@ -225,6 +225,25 @@ describe("gateway session utils", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("projects only unexpired agent status", () => {
|
||||
const entry = {
|
||||
sessionId: "session",
|
||||
updatedAt: 1,
|
||||
agentStatus: { note: "Need a key", attention: "key", expiresAt: 1_001 },
|
||||
} satisfies SessionEntry;
|
||||
const params = {
|
||||
cfg: createModelDefaultsConfig({ primary: "openai/gpt-5.4" }),
|
||||
storePath: "",
|
||||
store: {},
|
||||
key: "main",
|
||||
entry,
|
||||
};
|
||||
expect(buildGatewaySessionRow({ ...params, now: 1_000 }).agentStatus).toEqual(
|
||||
entry.agentStatus,
|
||||
);
|
||||
expect(buildGatewaySessionRow({ ...params, now: 1_001 }).agentStatus).toBeUndefined();
|
||||
});
|
||||
|
||||
test("session lists apply a bounded default and expose truncation metadata", async () => {
|
||||
const cfg = createModelDefaultsConfig({ primary: "openai/gpt-5.4" });
|
||||
const store = Object.fromEntries(
|
||||
|
||||
@@ -101,6 +101,7 @@ import {
|
||||
normalizeMainKey,
|
||||
parseAgentSessionKey,
|
||||
} from "../routing/session-key.js";
|
||||
import { resolveActiveSessionAgentStatus } from "../sessions/session-agent-status.js";
|
||||
import { isAcpSessionKey, isCronRunSessionKey } from "../sessions/session-key-utils.js";
|
||||
import { resolveNonNegativeNumber } from "../shared/number-coercion.js";
|
||||
import { truncateUtf16Safe } from "../utils.js";
|
||||
@@ -1926,6 +1927,7 @@ export function buildGatewaySessionRow(params: {
|
||||
const { cfg, storePath, store, key, entry } = params;
|
||||
const lightweight = params.lightweightListRow === true;
|
||||
const now = params.now ?? Date.now();
|
||||
const agentStatus = resolveActiveSessionAgentStatus(entry?.agentStatus, now);
|
||||
const updatedAt = entry?.updatedAt ?? null;
|
||||
const parsed = parseGroupKey(key);
|
||||
const channel = entry?.channel ?? parsed?.channel;
|
||||
@@ -2238,6 +2240,7 @@ export function buildGatewaySessionRow(params: {
|
||||
icon: entry?.icon,
|
||||
unread: deriveSessionUnread(entry),
|
||||
lastReadAt: entry?.lastReadAt,
|
||||
agentStatus,
|
||||
lastInteractionAt: entry?.lastInteractionAt,
|
||||
lastActivityAt: entry?.lastActivityAt,
|
||||
sessionId: entry?.sessionId,
|
||||
|
||||
@@ -80,6 +80,7 @@ export type GatewaySessionRow = {
|
||||
icon?: string;
|
||||
unread?: boolean;
|
||||
lastReadAt?: number;
|
||||
agentStatus?: SessionEntry["agentStatus"];
|
||||
/** Last real user/channel interaction; background work does not advance it. */
|
||||
lastInteractionAt?: number;
|
||||
lastActivityAt?: number;
|
||||
|
||||
@@ -306,7 +306,11 @@ describe("gateway sessions patch", () => {
|
||||
});
|
||||
|
||||
test("marks archived sessions unread and clears the marker when read", async () => {
|
||||
const store = mainStoreEntry({ archivedAt: 10, lastReadAt: 20 });
|
||||
const store = mainStoreEntry({
|
||||
archivedAt: 10,
|
||||
lastReadAt: 20,
|
||||
agentStatus: { note: "Waiting", attention: "hand", expiresAt: Date.now() + 60_000 },
|
||||
});
|
||||
const unread = expectPatchOk(
|
||||
await runPatch({ store, patch: { key: MAIN_SESSION_KEY, unread: true } }),
|
||||
);
|
||||
@@ -321,6 +325,47 @@ describe("gateway sessions patch", () => {
|
||||
expect(read.lastReadAt).toEqual(expect.any(Number));
|
||||
expect(read.lastReadAt).toBeGreaterThanOrEqual(unread.markedUnreadAt ?? 0);
|
||||
expect(read.markedUnreadAt).toBeUndefined();
|
||||
expect(read.agentStatus).toBeUndefined();
|
||||
});
|
||||
|
||||
test("stores sanitized agent status with attention and a bounded TTL", async () => {
|
||||
const before = Date.now();
|
||||
const entry = expectPatchOk(
|
||||
await runPatch({
|
||||
store: mainStoreEntry({}),
|
||||
patch: {
|
||||
key: MAIN_SESSION_KEY,
|
||||
statusNote: " Blocked:\n need the staging password ",
|
||||
attention: "key",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(entry.agentStatus).toMatchObject({
|
||||
note: "Blocked: need the staging password",
|
||||
attention: "key",
|
||||
});
|
||||
expect(entry.agentStatus?.expiresAt).toBeGreaterThanOrEqual(before + 30 * 60_000);
|
||||
expect(entry.agentStatus?.expiresAt).toBeLessThanOrEqual(Date.now() + 30 * 60_000);
|
||||
|
||||
expectPatchError(
|
||||
await runPatch({
|
||||
store: mainStoreEntry({}),
|
||||
patch: { key: MAIN_SESSION_KEY, statusNote: "Waiting", ttlMinutes: 121 },
|
||||
}),
|
||||
"use 1-120",
|
||||
);
|
||||
});
|
||||
|
||||
test("clears the whole agent status explicitly", async () => {
|
||||
const entry = expectPatchOk(
|
||||
await runPatch({
|
||||
store: mainStoreEntry({
|
||||
agentStatus: { note: "Waiting", attention: "flag", expiresAt: Date.now() + 60_000 },
|
||||
}),
|
||||
patch: { key: MAIN_SESSION_KEY, attention: null },
|
||||
}),
|
||||
);
|
||||
expect(entry.agentStatus).toBeUndefined();
|
||||
});
|
||||
|
||||
test("persists thinkingLevel=off (does not clear)", async () => {
|
||||
|
||||
@@ -61,6 +61,13 @@ import {
|
||||
MODEL_SELECTION_LOCKED_MESSAGE,
|
||||
} from "../sessions/model-overrides.js";
|
||||
import { normalizeSendPolicy } from "../sessions/send-policy.js";
|
||||
import {
|
||||
isSessionAgentAttentionIconId,
|
||||
resolveActiveSessionAgentStatus,
|
||||
sanitizeSessionAgentStatusNote,
|
||||
sessionAgentStatusExpiresAt,
|
||||
SESSION_AGENT_STATUS_MAX_TTL_MINUTES,
|
||||
} from "../sessions/session-agent-status.js";
|
||||
import { parseSessionLabel, SESSION_LABEL_MAX_LENGTH } from "../sessions/session-label.js";
|
||||
import {
|
||||
isAgentSessionModelPatchOrigin,
|
||||
@@ -412,6 +419,44 @@ export async function projectSessionsPatchEntry(params: {
|
||||
}
|
||||
}
|
||||
|
||||
if ("statusNote" in patch || "attention" in patch || "ttlMinutes" in patch) {
|
||||
const rawNote = patch.statusNote;
|
||||
const rawAttention = patch.attention;
|
||||
const ttlMinutes = patch.ttlMinutes;
|
||||
if (
|
||||
ttlMinutes !== undefined &&
|
||||
(!Number.isInteger(ttlMinutes) ||
|
||||
ttlMinutes < 1 ||
|
||||
ttlMinutes > SESSION_AGENT_STATUS_MAX_TTL_MINUTES)
|
||||
) {
|
||||
return invalid(`invalid ttlMinutes (use 1-${SESSION_AGENT_STATUS_MAX_TTL_MINUTES})`);
|
||||
}
|
||||
if (rawNote === null || rawAttention === null) {
|
||||
if (
|
||||
(rawNote !== undefined && rawNote !== null) ||
|
||||
(rawAttention !== undefined && rawAttention !== null)
|
||||
) {
|
||||
return invalid("cannot clear and set agent status in the same patch");
|
||||
}
|
||||
delete next.agentStatus;
|
||||
} else {
|
||||
const current = resolveActiveSessionAgentStatus(next.agentStatus, now);
|
||||
const note = rawNote === undefined ? current?.note : sanitizeSessionAgentStatusNote(rawNote);
|
||||
if (!note) {
|
||||
return invalid("statusNote required before setting attention or ttlMinutes");
|
||||
}
|
||||
if (rawAttention !== undefined && !isSessionAgentAttentionIconId(rawAttention)) {
|
||||
return invalid("invalid attention icon");
|
||||
}
|
||||
const attention = rawAttention ?? current?.attention;
|
||||
next.agentStatus = {
|
||||
note,
|
||||
expiresAt: sessionAgentStatusExpiresAt(now, ttlMinutes),
|
||||
...(attention ? { attention } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if ("archived" in patch) {
|
||||
if (patch.archived === true) {
|
||||
// Archived sessions leave the active quick-access set in the same write.
|
||||
@@ -439,6 +484,7 @@ export async function projectSessionsPatchEntry(params: {
|
||||
} else {
|
||||
next.lastReadAt = now;
|
||||
delete next.markedUnreadAt;
|
||||
delete next.agentStatus;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [
|
||||
"pinnedAt",
|
||||
"icon",
|
||||
"lastReadAt",
|
||||
"agentStatus",
|
||||
"markedUnreadAt",
|
||||
"lastActivityAt",
|
||||
"sessionFile",
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import {
|
||||
SESSION_AGENT_ATTENTION_ICON_IDS,
|
||||
type SessionAgentAttentionIconId,
|
||||
type SessionAgentStatus,
|
||||
} from "../../packages/gateway-protocol/src/session-icon.js";
|
||||
import { sanitizeUserFacingText } from "../agents/embedded-agent-helpers/sanitize-user-facing-text.js";
|
||||
|
||||
const SESSION_AGENT_STATUS_NOTE_MAX_CHARS = 120;
|
||||
const SESSION_AGENT_STATUS_DEFAULT_TTL_MINUTES = 30;
|
||||
export const SESSION_AGENT_STATUS_MAX_TTL_MINUTES = 120;
|
||||
|
||||
const ATTENTION_ICON_IDS = new Set<string>(SESSION_AGENT_ATTENTION_ICON_IDS);
|
||||
|
||||
export function isSessionAgentAttentionIconId(
|
||||
value: unknown,
|
||||
): value is SessionAgentAttentionIconId {
|
||||
return typeof value === "string" && ATTENTION_ICON_IDS.has(value);
|
||||
}
|
||||
|
||||
export function sanitizeSessionAgentStatusNote(value: string): string {
|
||||
const normalized = sanitizeUserFacingText(value, { errorContext: true })
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return truncateUtf16Safe(normalized, SESSION_AGENT_STATUS_NOTE_MAX_CHARS).trimEnd();
|
||||
}
|
||||
|
||||
export function resolveActiveSessionAgentStatus(
|
||||
status: SessionAgentStatus | undefined,
|
||||
now: number,
|
||||
): SessionAgentStatus | undefined {
|
||||
if (
|
||||
!status ||
|
||||
!status.note.trim() ||
|
||||
!Number.isFinite(status.expiresAt) ||
|
||||
status.expiresAt <= now
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (status.attention !== undefined && !isSessionAgentAttentionIconId(status.attention)) {
|
||||
return undefined;
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
export function sessionAgentStatusExpiresAt(now: number, ttlMinutes?: number): number {
|
||||
const ttl = ttlMinutes ?? SESSION_AGENT_STATUS_DEFAULT_TTL_MINUTES;
|
||||
return now + ttl * 60_000;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export type UpdateAvailable = import("../../../src/infra/update-startup.js").UpdateAvailable;
|
||||
import type { FastMode } from "@openclaw/normalization-core/string-coerce";
|
||||
import type { SessionAgentStatus } from "../../../packages/gateway-protocol/src/session-icon.js";
|
||||
import type { SessionGoal } from "../../../src/config/sessions/types.js";
|
||||
import type { CronJobBase } from "../../../src/cron/types-shared.js";
|
||||
import type { ConfigUiHints } from "../../../src/shared/config-ui-hints-types.js";
|
||||
@@ -507,6 +508,7 @@ export type GatewaySessionRow = {
|
||||
updatedAt: number | null;
|
||||
unread?: boolean;
|
||||
lastReadAt?: number;
|
||||
agentStatus?: SessionAgentStatus;
|
||||
lastActivityAt?: number;
|
||||
archived?: boolean;
|
||||
archivedAt?: number;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SessionAgentStatus } from "../../../packages/gateway-protocol/src/session-icon.js";
|
||||
import type { GatewayBrowserClient } from "../api/gateway.ts";
|
||||
import type { GatewaySessionRow } from "../api/types.ts";
|
||||
import type { RouteId } from "../app-route-paths.ts";
|
||||
@@ -28,6 +29,8 @@ export abstract class AppSidebarSessionAttentionElement extends AppSidebarSessio
|
||||
private attentionGateway: ApplicationContext<RouteId>["gateway"] | null = null;
|
||||
private attentionGatewayClient: GatewayBrowserClient | null = null;
|
||||
private attentionGatewayConnected = false;
|
||||
private agentStatusExpiryTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
|
||||
private agentStatusExpiryAt: number | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -54,6 +57,11 @@ export abstract class AppSidebarSessionAttentionElement extends AppSidebarSessio
|
||||
this.attentionGateway = null;
|
||||
this.attentionGatewayClient = null;
|
||||
this.attentionGatewayConnected = false;
|
||||
if (this.agentStatusExpiryTimer) {
|
||||
globalThis.clearTimeout(this.agentStatusExpiryTimer);
|
||||
this.agentStatusExpiryTimer = null;
|
||||
this.agentStatusExpiryAt = null;
|
||||
}
|
||||
disposeQuestionPromptState(this.questionPromptState);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
@@ -97,6 +105,10 @@ export abstract class AppSidebarSessionAttentionElement extends AppSidebarSessio
|
||||
if (knownAttention) {
|
||||
return knownAttention.attention;
|
||||
}
|
||||
const agentStatus = this.resolveSessionAgentStatus(row);
|
||||
if (agentStatus?.attention) {
|
||||
return { kind: "agent", note: agentStatus.note, icon: agentStatus.attention };
|
||||
}
|
||||
if (row.status !== "failed" && row.status !== "timeout") {
|
||||
return SIDEBAR_SESSION_NO_ATTENTION;
|
||||
}
|
||||
@@ -112,6 +124,35 @@ export abstract class AppSidebarSessionAttentionElement extends AppSidebarSessio
|
||||
return { kind: "error", reason };
|
||||
}
|
||||
|
||||
protected resolveSessionAgentStatus(row: GatewaySessionRow): SessionAgentStatus | undefined {
|
||||
const status = row.agentStatus;
|
||||
if (!status || status.expiresAt <= Date.now() || !status.note.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
this.scheduleAgentStatusExpiry(status.expiresAt);
|
||||
return status;
|
||||
}
|
||||
|
||||
private scheduleAgentStatusExpiry(expiresAt: number): void {
|
||||
// The gateway owns expiry; this timer only invalidates an otherwise-idle
|
||||
// sidebar so it stops rendering the declaration at the server timestamp.
|
||||
if (this.agentStatusExpiryAt !== null && this.agentStatusExpiryAt <= expiresAt) {
|
||||
return;
|
||||
}
|
||||
if (this.agentStatusExpiryTimer) {
|
||||
globalThis.clearTimeout(this.agentStatusExpiryTimer);
|
||||
}
|
||||
this.agentStatusExpiryAt = expiresAt;
|
||||
this.agentStatusExpiryTimer = globalThis.setTimeout(
|
||||
() => {
|
||||
this.agentStatusExpiryTimer = null;
|
||||
this.agentStatusExpiryAt = null;
|
||||
this.requestUpdate();
|
||||
},
|
||||
Math.max(0, expiresAt - Date.now() + 1),
|
||||
);
|
||||
}
|
||||
|
||||
protected knownSessionAttention(): readonly SidebarKnownSessionAttention[] {
|
||||
const questions = listQuestionPrompts(this.questionPromptState).flatMap((prompt) =>
|
||||
prompt.status === "pending" && prompt.sessionKey !== undefined
|
||||
|
||||
@@ -192,6 +192,7 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
|
||||
hasAutomation: row.hasAutomation === true,
|
||||
unread: row.unread === true,
|
||||
attention: this.resolveSessionAttention(row),
|
||||
agentStatusNote: this.resolveSessionAgentStatus(row)?.note,
|
||||
spawnedBy: row.spawnedBy,
|
||||
status: row.status,
|
||||
startedAt: row.startedAt,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SessionAgentAttentionIconId } from "../../../packages/gateway-protocol/src/session-icon.js";
|
||||
import type { GatewayBrowserClient } from "../api/gateway.ts";
|
||||
import type { SessionRunStatus } from "../api/types.ts";
|
||||
import type { RouteId } from "../app-route-paths.ts";
|
||||
@@ -18,6 +19,7 @@ export type SidebarSessionAttention =
|
||||
| { kind: "none" }
|
||||
| { kind: "question" }
|
||||
| { kind: "approval" }
|
||||
| { kind: "agent"; note: string; icon: SessionAgentAttentionIconId }
|
||||
| { kind: "error"; reason: string };
|
||||
|
||||
/** Client-owned attention that can name a session before its row is loaded. */
|
||||
@@ -32,6 +34,8 @@ export function sidebarSessionAttentionPriority(attention: SidebarSessionAttenti
|
||||
switch (attention.kind) {
|
||||
case "question":
|
||||
case "approval":
|
||||
return 3;
|
||||
case "agent":
|
||||
return 2;
|
||||
case "error":
|
||||
return 1;
|
||||
@@ -68,6 +72,7 @@ export type SidebarRecentSession = {
|
||||
hasAutomation: boolean;
|
||||
unread: boolean;
|
||||
attention: SidebarSessionAttention;
|
||||
agentStatusNote?: string;
|
||||
spawnedBy?: string;
|
||||
status?: SessionRunStatus;
|
||||
startedAt?: number;
|
||||
|
||||
@@ -258,6 +258,26 @@ export const icons = {
|
||||
<path d="m15.5 7.5 3 3L22 7l-3-3" />
|
||||
</svg>
|
||||
`,
|
||||
flag: html`
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M5 22V4" />
|
||||
<path d="M5 4c5-4 9 4 14 0v11c-5 4-9-4-14 0" />
|
||||
</svg>
|
||||
`,
|
||||
lock: html`
|
||||
<svg viewBox="0 0 24 24">
|
||||
<rect width="18" height="11" x="3" y="11" rx="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
`,
|
||||
hourglass: html`
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M5 22h14" />
|
||||
<path d="M5 2h14" />
|
||||
<path d="M17 22v-4.2a4 4 0 0 0-1.2-2.8L12 11l-3.8 4A4 4 0 0 0 7 17.8V22" />
|
||||
<path d="M7 2v4.2A4 4 0 0 0 8.2 9l3.8 4 3.8-4A4 4 0 0 0 17 6.2V2" />
|
||||
</svg>
|
||||
`,
|
||||
layoutComfortable: html`
|
||||
<svg viewBox="0 0 24 24">
|
||||
<rect width="16" height="5" x="4" y="4" rx="1.5" />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { html, nothing } from "lit";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import type { SidebarRecentSession, SidebarSessionAttention } from "./app-sidebar-session-types.ts";
|
||||
import { icons } from "./icons.ts";
|
||||
import { resolveSessionAttentionIcon } from "./session-icon-registry.ts";
|
||||
|
||||
export function renderSessionAttentionIcon(attention: SidebarSessionAttention) {
|
||||
if (attention.kind === "none") {
|
||||
@@ -12,7 +13,9 @@ export function renderSessionAttentionIcon(attention: SidebarSessionAttention) {
|
||||
? icons.hand
|
||||
: attention.kind === "approval"
|
||||
? icons.key
|
||||
: icons.alertTriangle;
|
||||
: attention.kind === "agent"
|
||||
? resolveSessionAttentionIcon(attention.icon)
|
||||
: icons.alertTriangle;
|
||||
return html`<span
|
||||
class="sidebar-session-attention__icon sidebar-session-attention__icon--${attention.kind}"
|
||||
data-session-attention=${attention.kind}
|
||||
@@ -29,6 +32,8 @@ export function sessionAttentionSubtitle(attention: SidebarSessionAttention): st
|
||||
return t("sessionsView.waitingForApproval");
|
||||
case "error":
|
||||
return t("sessionsView.runFailedReason", { reason: attention.reason });
|
||||
case "agent":
|
||||
return attention.note;
|
||||
case "none":
|
||||
return undefined;
|
||||
default:
|
||||
|
||||
@@ -5,6 +5,7 @@ import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
import {
|
||||
normalizeSessionIconInput,
|
||||
parseSessionIcon,
|
||||
type SessionAgentAttentionIconId,
|
||||
} from "../../../packages/gateway-protocol/src/session-icon.js";
|
||||
import { icons } from "./icons.ts";
|
||||
|
||||
@@ -21,6 +22,12 @@ const SESSION_ICON_REGISTRY = {
|
||||
globe: icons.globe,
|
||||
sun: icons.sun,
|
||||
moon: icons.moon,
|
||||
hand: icons.hand,
|
||||
key: icons.key,
|
||||
alert: icons.alertTriangle,
|
||||
flag: icons.flag,
|
||||
lock: icons.lock,
|
||||
hourglass: icons.hourglass,
|
||||
} as const satisfies Record<string, TemplateResult>;
|
||||
|
||||
type CuratedSessionIconId = keyof typeof SESSION_ICON_REGISTRY;
|
||||
@@ -55,3 +62,7 @@ export function resolveSessionIcon(icon: string | undefined): TemplateResult {
|
||||
}
|
||||
return renderNamedIcon(icons.messageSquare);
|
||||
}
|
||||
|
||||
export function resolveSessionAttentionIcon(icon: SessionAgentAttentionIconId): TemplateResult {
|
||||
return SESSION_ICON_REGISTRY[icon];
|
||||
}
|
||||
|
||||
@@ -18,15 +18,20 @@ export function resolveSidebarSessionSubtitle(params: {
|
||||
}): SidebarSessionSubtitle {
|
||||
const { session } = params;
|
||||
const attention = sessionAttentionSubtitle(session.attention);
|
||||
// Agent-declared status (sessions tool) outranks live narration: it is an
|
||||
// explicit message to the user, not ambient activity.
|
||||
const agentStatus = session.agentStatusNote || undefined;
|
||||
const running = session.hasActiveRun || session.status === "running";
|
||||
const narration =
|
||||
attention || !params.sidebarLiveActivity || !running ? undefined : params.narrationLine;
|
||||
attention || agentStatus || !params.sidebarLiveActivity || !running
|
||||
? undefined
|
||||
: params.narrationLine;
|
||||
const workSubtitle = params.hasDisplay
|
||||
? params.displaySubtitle
|
||||
: session.subtitle && session.workSession && session.subtitle !== session.label
|
||||
? session.subtitle
|
||||
: undefined;
|
||||
return { subtitle: attention ?? narration ?? workSubtitle, narration };
|
||||
return { subtitle: attention ?? agentStatus ?? narration ?? workSubtitle, narration };
|
||||
}
|
||||
|
||||
export function renderSidebarSessionSubtitle(value: SidebarSessionSubtitle) {
|
||||
|
||||
@@ -380,6 +380,9 @@ export function reconcileSessionChanged(
|
||||
if (rowFields.lastRunError === null) {
|
||||
delete row.lastRunError;
|
||||
}
|
||||
if (rowFields.agentStatus === null) {
|
||||
delete row.agentStatus;
|
||||
}
|
||||
const next = reconcileSessionHistory(result, row, undefined, {
|
||||
...options,
|
||||
selectedGlobalAgentId,
|
||||
|
||||
@@ -29,4 +29,27 @@ describe("chat pane read markers", () => {
|
||||
{ agentId: "main" },
|
||||
);
|
||||
});
|
||||
|
||||
it("marks an active agent status read even without other unread state", () => {
|
||||
const patch = vi.fn().mockResolvedValue(null);
|
||||
const { pane } = createTestChatPane({
|
||||
client: {} as GatewayBrowserClient,
|
||||
sessions: { patch } as unknown as SessionCapability,
|
||||
});
|
||||
|
||||
pane.markSessionRead({
|
||||
key: "agent:main:current",
|
||||
kind: "direct",
|
||||
label: "Waiting",
|
||||
updatedAt: 20,
|
||||
unread: false,
|
||||
agentStatus: { note: "Need the staging password", expiresAt: Date.now() + 60_000 },
|
||||
});
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"agent:main:current",
|
||||
{ unread: false },
|
||||
{ agentId: "main" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -767,8 +767,12 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
const unreadFailure =
|
||||
(row.status === "failed" || row.status === "timeout") &&
|
||||
(row.lastReadAt == null || failureAt > row.lastReadAt);
|
||||
const agentStatusActive = Boolean(row.agentStatus && row.agentStatus.expiresAt > Date.now());
|
||||
if (
|
||||
!this.unreadPatchGuard.shouldPatch(state.sessionKey, row.unread === true || unreadFailure)
|
||||
!this.unreadPatchGuard.shouldPatch(
|
||||
state.sessionKey,
|
||||
row.unread === true || unreadFailure || agentStatusActive,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -315,8 +315,8 @@ function findNearestAssistantMessageIndex(
|
||||
if (index < currentTurnStart || index >= currentTurnEnd || item.kind !== "message") {
|
||||
return null;
|
||||
}
|
||||
const message = item.message as Record<string, unknown>;
|
||||
const role = typeof message.role === "string" ? message.role.toLowerCase() : "";
|
||||
const message = asRecord(item.message);
|
||||
const role = typeof message?.role === "string" ? message.role.toLowerCase() : "";
|
||||
if (role !== "assistant") {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3700,6 +3700,10 @@ wa-dropdown.sidebar-session-sort-menu::part(menu) {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.sidebar-session-attention__icon--agent {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.sidebar-session-attention__icon--error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,20 @@ function failedRow(
|
||||
};
|
||||
}
|
||||
|
||||
function agentAttentionRow(
|
||||
key = sessionKey,
|
||||
overrides: Partial<GatewaySessionRow> = {},
|
||||
): GatewaySessionRow {
|
||||
return failedRow(key, {
|
||||
agentStatus: {
|
||||
note: "Blocked: need the staging password",
|
||||
attention: "key",
|
||||
expiresAt: Date.now() + 60_000,
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("AppSidebar session attention", () => {
|
||||
it("shows question attention ahead of a run error and clears it on resolution", async () => {
|
||||
const client = {
|
||||
@@ -51,7 +65,7 @@ describe("AppSidebar session attention", () => {
|
||||
} as unknown as GatewayBrowserClient;
|
||||
const gatewayHarness = createGatewayHarness(client);
|
||||
const sessionsHarness = createSessionsHarness("main", [sessionKey]);
|
||||
setRows(sessionsHarness, [failedRow()]);
|
||||
setRows(sessionsHarness, [agentAttentionRow()]);
|
||||
const { sidebar } = await mountSidebar(gatewayHarness.gateway, sessionsHarness.sessions);
|
||||
|
||||
gatewayHarness.publishEvent("question.requested", {
|
||||
@@ -82,7 +96,65 @@ describe("AppSidebar session attention", () => {
|
||||
});
|
||||
await sidebar.updateComplete;
|
||||
expect(sidebar.querySelector('[data-session-attention="question"]')).toBeNull();
|
||||
expect(sidebar.querySelector('[data-session-attention="error"]')).not.toBeNull();
|
||||
expect(sidebar.querySelector('[data-session-attention="agent"]')).not.toBeNull();
|
||||
expect(sidebar.textContent).toContain("Blocked: need the staging password");
|
||||
});
|
||||
|
||||
it("shows agent-declared attention ahead of a run error", async () => {
|
||||
const sessionsHarness = createSessionsHarness("main", [sessionKey]);
|
||||
setRows(sessionsHarness, [agentAttentionRow()]);
|
||||
const { sidebar } = await mountSidebar(
|
||||
createGateway({} as GatewayBrowserClient),
|
||||
sessionsHarness.sessions,
|
||||
);
|
||||
|
||||
expect(sidebar.querySelector('[data-session-attention="agent"]')).not.toBeNull();
|
||||
expect(sidebar.textContent).toContain("Blocked: need the staging password");
|
||||
expect(sidebar.textContent).not.toContain("Run failed:");
|
||||
});
|
||||
|
||||
it("shows an unflagged agent status note in the subtitle slot", async () => {
|
||||
const sessionsHarness = createSessionsHarness("main", [sessionKey]);
|
||||
setRows(sessionsHarness, [
|
||||
{
|
||||
key: sessionKey,
|
||||
kind: "direct",
|
||||
label: "Deploy",
|
||||
updatedAt: 2,
|
||||
agentStatus: { note: "Deploying to staging", expiresAt: Date.now() + 60_000 },
|
||||
},
|
||||
]);
|
||||
const { sidebar } = await mountSidebar(
|
||||
createGateway({} as GatewayBrowserClient),
|
||||
sessionsHarness.sessions,
|
||||
);
|
||||
|
||||
expect(sidebar.textContent).toContain("Deploying to staging");
|
||||
expect(sidebar.querySelector('[data-session-attention="agent"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render an expired agent declaration", async () => {
|
||||
const sessionsHarness = createSessionsHarness("main", [sessionKey]);
|
||||
setRows(sessionsHarness, [
|
||||
{
|
||||
key: sessionKey,
|
||||
kind: "direct",
|
||||
label: "Quiet session",
|
||||
updatedAt: 2,
|
||||
agentStatus: {
|
||||
note: "Expired blocker",
|
||||
attention: "hourglass",
|
||||
expiresAt: Date.now() - 1,
|
||||
},
|
||||
},
|
||||
]);
|
||||
const { sidebar } = await mountSidebar(
|
||||
createGateway({} as GatewayBrowserClient),
|
||||
sessionsHarness.sessions,
|
||||
);
|
||||
|
||||
expect(sidebar.querySelector('[data-session-attention="agent"]')).toBeNull();
|
||||
expect(sidebar.textContent).not.toContain("Expired blocker");
|
||||
});
|
||||
|
||||
it("shows approval attention ahead of a run error", async () => {
|
||||
@@ -153,13 +225,13 @@ describe("AppSidebar session attention", () => {
|
||||
expect(sidebar.textContent).toContain("Run failed: Provider credits exhausted");
|
||||
});
|
||||
|
||||
it("marks a collapsed section that contains an attention session", async () => {
|
||||
it("marks a collapsed section that contains agent-declared attention", async () => {
|
||||
localStorage.setItem(
|
||||
"openclaw:sidebar:sessions:collapsed-sections",
|
||||
JSON.stringify(["ungrouped"]),
|
||||
);
|
||||
const sessionsHarness = createSessionsHarness("main", [sessionKey]);
|
||||
setRows(sessionsHarness, [failedRow()]);
|
||||
setRows(sessionsHarness, [agentAttentionRow()]);
|
||||
const { sidebar } = await mountSidebar(
|
||||
createGateway({} as GatewayBrowserClient),
|
||||
sessionsHarness.sessions,
|
||||
@@ -272,7 +344,7 @@ describe("AppSidebar session attention", () => {
|
||||
defaults: { modelProvider: null, model: null, contextTokens: null },
|
||||
sessions: childKeys.map((key, index) =>
|
||||
index === 5
|
||||
? { ...failedRow(key), spawnedBy: parentKey, label: "Attention child" }
|
||||
? { ...agentAttentionRow(key), spawnedBy: parentKey, label: "Attention child" }
|
||||
: {
|
||||
key,
|
||||
spawnedBy: parentKey,
|
||||
@@ -299,7 +371,7 @@ describe("AppSidebar session attention", () => {
|
||||
sidebar.querySelector<HTMLButtonElement>(`[data-child-session-toggle="${parentKey}"]`)?.click();
|
||||
await waitForFast(() => {
|
||||
expect(
|
||||
sidebar.querySelector(`[data-session-key="${parentKey}"] [data-session-attention="error"]`),
|
||||
sidebar.querySelector(`[data-session-key="${parentKey}"] [data-session-attention="agent"]`),
|
||||
).not.toBeNull();
|
||||
expect(sidebar.querySelector(`[data-session-key="${childKeys[5]}"]`)).not.toBeNull();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user