mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
feat(system-agent): agentic cached caretaker greeting with quick actions (#111615)
* feat(system-agent): agentic cached caretaker greeting with quick actions * test(gateway): pin caretaker greeting in session-ownership tests * fix(system-agent): offer agent handoff chip only when a model is available * fix(ci): typecheck narrowing, lint, and knip gates for greeting module * fix(system-agent): reject structured greeting output on every retained line * test(gateway): split caretaker welcome tests under the max-lines gate
This commit is contained in:
@@ -361,6 +361,9 @@ const config = {
|
||||
// GatewayBoardProvider and boardExists are constructed/asserted by the
|
||||
// focused Control UI provider tests, not by a separate production module.
|
||||
"ui/src/lib/board/provider.ts": ["exports"],
|
||||
// Greeting cache/fact contracts (hash, alert text, store shapes) are
|
||||
// asserted by the focused greeting unit tests, not by another prod module.
|
||||
"src/system-agent/greeting.ts": ["exports", "types"],
|
||||
},
|
||||
workspaces: {
|
||||
".": {
|
||||
|
||||
@@ -134,6 +134,8 @@ type GatewaySystemAgentSession = {
|
||||
};
|
||||
welcome: string;
|
||||
welcomeQuestion?: SystemAgentChatQuestion;
|
||||
/** Audit cursor captured with the pending caretaker welcome; cleared after delivery. */
|
||||
welcomeAuditSequence?: number;
|
||||
lastUsedAt: number;
|
||||
ownerKey: string;
|
||||
pendingApproval?: { id: string; proposalHash: string };
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
// Focused welcome-delivery tests for openclaw.chat: caretaker greeting wiring,
|
||||
// audit-cursor acknowledgement, and the onboarding template path.
|
||||
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resetCommandQueueStateForTest } from "../../process/command-queue.test-support.js";
|
||||
import { systemAgentHandlers, type SystemAgentChatSession } from "./system-agent.js";
|
||||
import type { GatewayClient, GatewayRequestContext } from "./types.js";
|
||||
|
||||
const setupInferenceMocks = vi.hoisted(() => ({ verifySetupInference: vi.fn() }));
|
||||
const transcriptStoreMocks = vi.hoisted(() => ({
|
||||
appendTranscriptReset: vi.fn(),
|
||||
appendTranscriptTurn: vi.fn(),
|
||||
readTranscriptTail: vi.fn(() => []),
|
||||
}));
|
||||
const greetingMocks = vi.hoisted(() => ({
|
||||
acknowledgeSystemAgentGreetingDelivery: vi.fn(),
|
||||
buildSystemAgentGreetingQuestion: vi.fn(),
|
||||
loadSystemAgentGreetingFacts: vi.fn(),
|
||||
resolveSystemAgentGreeting: vi.fn(),
|
||||
}));
|
||||
const onboardingWelcomeMocks = vi.hoisted(() => ({ buildOnboardingWelcome: vi.fn() }));
|
||||
|
||||
vi.mock("../../system-agent/setup-inference.js", () => ({
|
||||
verifySetupInference: setupInferenceMocks.verifySetupInference,
|
||||
}));
|
||||
vi.mock("../../system-agent/transcript-store.js", () => ({
|
||||
appendTranscriptReset: transcriptStoreMocks.appendTranscriptReset,
|
||||
appendTranscriptTurn: transcriptStoreMocks.appendTranscriptTurn,
|
||||
readTranscriptTail: transcriptStoreMocks.readTranscriptTail,
|
||||
}));
|
||||
vi.mock("../../system-agent/greeting.js", () => ({
|
||||
acknowledgeSystemAgentGreetingDelivery: greetingMocks.acknowledgeSystemAgentGreetingDelivery,
|
||||
buildSystemAgentGreetingQuestion: greetingMocks.buildSystemAgentGreetingQuestion,
|
||||
loadSystemAgentGreetingFacts: greetingMocks.loadSystemAgentGreetingFacts,
|
||||
resolveSystemAgentGreeting: greetingMocks.resolveSystemAgentGreeting,
|
||||
}));
|
||||
vi.mock("../../system-agent/onboarding-welcome.js", () => ({
|
||||
buildOnboardingWelcome: onboardingWelcomeMocks.buildOnboardingWelcome,
|
||||
}));
|
||||
|
||||
type FakeEngine = {
|
||||
handle: ReturnType<typeof vi.fn>;
|
||||
seedHistory: ReturnType<typeof vi.fn>;
|
||||
historyLength: ReturnType<typeof vi.fn>;
|
||||
historySince: ReturnType<typeof vi.fn>;
|
||||
getPendingOperatorProposal: ReturnType<typeof vi.fn>;
|
||||
resolveOperatorApproval: ReturnType<typeof vi.fn>;
|
||||
dispose: ReturnType<typeof vi.fn>;
|
||||
loadOverview: ReturnType<typeof vi.fn>;
|
||||
noteAssistantMessage: ReturnType<typeof vi.fn>;
|
||||
planGreeting: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function makeEngine(): FakeEngine {
|
||||
// Mirrors persistEngineHistory's contract: noted assistant messages appear
|
||||
// in historySince so the welcome is persisted before acknowledgement.
|
||||
const history: Array<{ role: "user" | "assistant"; text: string }> = [];
|
||||
return {
|
||||
handle: vi.fn(async () => ({ text: "did the thing", action: "none" })),
|
||||
seedHistory: vi.fn(),
|
||||
historyLength: vi.fn(() => history.length),
|
||||
historySince: vi.fn((index: number) => history.slice(index)),
|
||||
getPendingOperatorProposal: vi.fn(() => null),
|
||||
resolveOperatorApproval: vi.fn(async () => null),
|
||||
dispose: vi.fn(async () => undefined),
|
||||
loadOverview: vi.fn(async () => ({})),
|
||||
noteAssistantMessage: vi.fn((text: string) => {
|
||||
history.push({ role: "assistant", text });
|
||||
}),
|
||||
planGreeting: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
const createdEngines = vi.hoisted(() => [] as FakeEngine[]);
|
||||
|
||||
vi.mock("../../system-agent/chat-engine.js", () => ({
|
||||
SystemAgentChatEngine: function FakeSystemAgentChatEngine(this: FakeEngine) {
|
||||
const engine = makeEngine();
|
||||
createdEngines.push(engine);
|
||||
Object.assign(this, engine);
|
||||
},
|
||||
}));
|
||||
|
||||
type RespondCall = { ok: boolean; payload?: unknown; error?: unknown };
|
||||
|
||||
const defaultClient = {
|
||||
connId: "conn-test",
|
||||
connect: { device: { id: "device-test" } },
|
||||
} as GatewayClient;
|
||||
|
||||
function makeContext(sessions: Map<string, SystemAgentChatSession>): GatewayRequestContext {
|
||||
return { systemAgentSessions: sessions } as unknown as GatewayRequestContext;
|
||||
}
|
||||
|
||||
async function callChat(
|
||||
context: GatewayRequestContext,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<RespondCall> {
|
||||
const calls: RespondCall[] = [];
|
||||
const respond = (ok: boolean, payload?: unknown, error?: unknown) => {
|
||||
calls.push({ ok, payload, error });
|
||||
};
|
||||
await expectDefined(
|
||||
systemAgentHandlers["openclaw.chat"],
|
||||
'systemAgentHandlers["openclaw.chat"] test invariant',
|
||||
)({ params, respond, context, client: defaultClient } as never);
|
||||
return expectDefined(calls[0], "system-agent response");
|
||||
}
|
||||
|
||||
const quickActions = {
|
||||
id: "system-agent-quick-actions",
|
||||
header: "Quick actions",
|
||||
question: "What would you like me to do?",
|
||||
options: [
|
||||
{ label: "Show update", reply: "status" },
|
||||
{ label: "Talk to my agent", reply: "talk to agent" },
|
||||
{ label: "Review recent changes", reply: "audit" },
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
createdEngines.length = 0;
|
||||
setupInferenceMocks.verifySetupInference.mockResolvedValue({ ok: true, binding: {} });
|
||||
greetingMocks.loadSystemAgentGreetingFacts.mockReturnValue({
|
||||
updateAvailable: null,
|
||||
channelHealth: { available: true, degraded: [] },
|
||||
recentExternalEdit: false,
|
||||
auditSequence: 0,
|
||||
});
|
||||
greetingMocks.resolveSystemAgentGreeting.mockResolvedValue({
|
||||
text: "I'm OpenClaw. All systems nominal.",
|
||||
source: "model",
|
||||
});
|
||||
greetingMocks.buildSystemAgentGreetingQuestion.mockReturnValue(quickActions);
|
||||
onboardingWelcomeMocks.buildOnboardingWelcome.mockResolvedValue({
|
||||
text: "Inference is ready. Let's finish setup.",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
transcriptStoreMocks.readTranscriptTail.mockReturnValue([]);
|
||||
resetCommandQueueStateForTest();
|
||||
});
|
||||
|
||||
describe("openclaw.chat caretaker welcome", () => {
|
||||
it("returns caretaker quick actions and persists the resolved greeting", async () => {
|
||||
greetingMocks.loadSystemAgentGreetingFacts.mockReturnValueOnce({
|
||||
updateAvailable: "2026.7.20",
|
||||
channelHealth: { available: true, degraded: [] },
|
||||
recentExternalEdit: true,
|
||||
auditSequence: 42,
|
||||
});
|
||||
greetingMocks.resolveSystemAgentGreeting.mockResolvedValueOnce({
|
||||
text: "I'm healthy. An update is ready, and I noticed a manual config edit.",
|
||||
source: "model",
|
||||
});
|
||||
|
||||
const call = await callChat(makeContext(new Map()), { sessionId: "caretaker-welcome" });
|
||||
|
||||
expect(call.payload).toMatchObject({
|
||||
reply: "I'm healthy. An update is ready, and I noticed a manual config edit.",
|
||||
question: {
|
||||
header: "Quick actions",
|
||||
options: [
|
||||
{ label: "Show update", reply: "status" },
|
||||
{ label: "Talk to my agent", reply: "talk to agent" },
|
||||
{ label: "Review recent changes", reply: "audit" },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(greetingMocks.resolveSystemAgentGreeting).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ allowInference: true }),
|
||||
);
|
||||
expect(transcriptStoreMocks.appendTranscriptTurn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
role: "assistant",
|
||||
text: "I'm healthy. An update is ready, and I noticed a manual config edit.",
|
||||
}),
|
||||
);
|
||||
expect(greetingMocks.acknowledgeSystemAgentGreetingDelivery).toHaveBeenCalledWith({
|
||||
auditSequence: 42,
|
||||
});
|
||||
expect(transcriptStoreMocks.appendTranscriptTurn.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
greetingMocks.acknowledgeSystemAgentGreetingDelivery.mock.invocationCallOrder[0] ?? 0,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not plan a greeting when a fresh session is created with a message", async () => {
|
||||
const sessions = new Map<string, SystemAgentChatSession>();
|
||||
greetingMocks.resolveSystemAgentGreeting.mockResolvedValueOnce({
|
||||
text: "Hi, I'm OpenClaw — caretaker of this gateway, config, channels, and agents.",
|
||||
source: "template",
|
||||
});
|
||||
|
||||
const context = makeContext(sessions);
|
||||
const call = await callChat(context, {
|
||||
sessionId: "fresh-with-message",
|
||||
message: "status",
|
||||
});
|
||||
|
||||
expect(call.payload).toMatchObject({ reply: "did the thing", action: "none" });
|
||||
expect(greetingMocks.resolveSystemAgentGreeting).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ allowInference: false }),
|
||||
);
|
||||
expect(createdEngines[0]?.planGreeting).not.toHaveBeenCalled();
|
||||
expect(greetingMocks.acknowledgeSystemAgentGreetingDelivery).not.toHaveBeenCalled();
|
||||
|
||||
expect(sessions.get("fresh-with-message")?.welcomeAuditSequence).toBe(0);
|
||||
const welcome = await callChat(context, { sessionId: "fresh-with-message" });
|
||||
expect(welcome.payload).toMatchObject({
|
||||
reply: "Hi, I'm OpenClaw — caretaker of this gateway, config, channels, and agents.",
|
||||
});
|
||||
expect(greetingMocks.acknowledgeSystemAgentGreetingDelivery).toHaveBeenCalledWith({
|
||||
auditSequence: 0,
|
||||
});
|
||||
expect(sessions.get("fresh-with-message")?.welcomeAuditSequence).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not acknowledge audit entries when greeting delivery fails", async () => {
|
||||
const sessions = new Map<string, SystemAgentChatSession>();
|
||||
const context = makeContext(sessions);
|
||||
greetingMocks.loadSystemAgentGreetingFacts.mockReturnValueOnce({
|
||||
updateAvailable: null,
|
||||
channelHealth: { available: true, degraded: [] },
|
||||
recentExternalEdit: true,
|
||||
auditSequence: 42,
|
||||
});
|
||||
|
||||
await expect(
|
||||
expectDefined(
|
||||
systemAgentHandlers["openclaw.chat"],
|
||||
'systemAgentHandlers["openclaw.chat"] test invariant',
|
||||
)({
|
||||
params: { sessionId: "failed-delivery" },
|
||||
respond: () => {
|
||||
throw new Error("socket closed");
|
||||
},
|
||||
context,
|
||||
client: defaultClient,
|
||||
} as never),
|
||||
).rejects.toThrow("socket closed");
|
||||
|
||||
expect(transcriptStoreMocks.appendTranscriptTurn).toHaveBeenCalled();
|
||||
expect(greetingMocks.acknowledgeSystemAgentGreetingDelivery).not.toHaveBeenCalled();
|
||||
expect(sessions.get("failed-delivery")?.welcomeAuditSequence).toBe(42);
|
||||
|
||||
const retry = await callChat(context, { sessionId: "failed-delivery" });
|
||||
expect(retry.payload).toMatchObject({ reply: "I'm OpenClaw. All systems nominal." });
|
||||
expect(greetingMocks.acknowledgeSystemAgentGreetingDelivery).toHaveBeenCalledWith({
|
||||
auditSequence: 42,
|
||||
});
|
||||
expect(sessions.get("failed-delivery")?.welcomeAuditSequence).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps onboarding on its dedicated template path", async () => {
|
||||
const call = await callChat(makeContext(new Map()), {
|
||||
sessionId: "onboarding-welcome",
|
||||
welcomeVariant: "onboarding",
|
||||
});
|
||||
|
||||
expect(call.payload).toMatchObject({ reply: "Inference is ready. Let's finish setup." });
|
||||
expect(onboardingWelcomeMocks.buildOnboardingWelcome).toHaveBeenCalledOnce();
|
||||
expect(greetingMocks.loadSystemAgentGreetingFacts).not.toHaveBeenCalled();
|
||||
expect(greetingMocks.resolveSystemAgentGreeting).not.toHaveBeenCalled();
|
||||
expect(greetingMocks.acknowledgeSystemAgentGreetingDelivery).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,19 @@ vi.mock("../../system-agent/transcript-store.js", () => ({
|
||||
appendTranscriptTurn: vi.fn(),
|
||||
readTranscriptTail: vi.fn(() => []),
|
||||
}));
|
||||
// Ownership tests exercise fresh-session creation; keep the caretaker greeting
|
||||
// deterministic so identity behavior is the only variable under test.
|
||||
vi.mock("../../system-agent/greeting.js", () => ({
|
||||
acknowledgeSystemAgentGreetingDelivery: vi.fn(),
|
||||
buildSystemAgentGreetingQuestion: vi.fn(() => undefined),
|
||||
loadSystemAgentGreetingFacts: vi.fn(() => ({
|
||||
updateAvailable: null,
|
||||
channelHealth: { available: true, degraded: [] },
|
||||
recentExternalEdit: false,
|
||||
auditSequence: 0,
|
||||
})),
|
||||
resolveSystemAgentGreeting: vi.fn(async () => ({ text: "welcome text", source: "template" })),
|
||||
}));
|
||||
|
||||
type FakeEngine = {
|
||||
handle: ReturnType<typeof vi.fn>;
|
||||
|
||||
@@ -47,6 +47,14 @@ const transcriptStoreMocks = vi.hoisted(() => ({
|
||||
(limit: number) => Array<{ role: "user" | "assistant"; text: string; at: number }>
|
||||
>(() => []),
|
||||
}));
|
||||
const greetingMocks = vi.hoisted(() => ({
|
||||
acknowledgeSystemAgentGreetingDelivery: vi.fn(),
|
||||
loadSystemAgentGreetingFacts: vi.fn(),
|
||||
resolveSystemAgentGreeting: vi.fn(),
|
||||
}));
|
||||
const onboardingWelcomeMocks = vi.hoisted(() => ({
|
||||
buildOnboardingWelcome: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../system-agent/setup-inference.js", () => ({
|
||||
activateSetupInference: setupInferenceMocks.activateSetupInference,
|
||||
@@ -68,6 +76,18 @@ vi.mock("../../system-agent/transcript-store.js", () => ({
|
||||
appendTranscriptTurn: transcriptStoreMocks.appendTranscriptTurn,
|
||||
readTranscriptTail: transcriptStoreMocks.readTranscriptTail,
|
||||
}));
|
||||
vi.mock("../../system-agent/greeting.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../system-agent/greeting.js")>();
|
||||
return {
|
||||
...actual,
|
||||
acknowledgeSystemAgentGreetingDelivery: greetingMocks.acknowledgeSystemAgentGreetingDelivery,
|
||||
loadSystemAgentGreetingFacts: greetingMocks.loadSystemAgentGreetingFacts,
|
||||
resolveSystemAgentGreeting: greetingMocks.resolveSystemAgentGreeting,
|
||||
};
|
||||
});
|
||||
vi.mock("../../system-agent/onboarding-welcome.js", () => ({
|
||||
buildOnboardingWelcome: onboardingWelcomeMocks.buildOnboardingWelcome,
|
||||
}));
|
||||
|
||||
type RespondCall = {
|
||||
ok: boolean;
|
||||
@@ -190,6 +210,20 @@ beforeEach(async () => {
|
||||
transcriptStoreMocks.appendTranscriptTurn.mockReset();
|
||||
transcriptStoreMocks.appendTranscriptReset.mockReset();
|
||||
transcriptStoreMocks.readTranscriptTail.mockReset().mockReturnValue([]);
|
||||
greetingMocks.acknowledgeSystemAgentGreetingDelivery.mockReset();
|
||||
greetingMocks.loadSystemAgentGreetingFacts.mockReset().mockReturnValue({
|
||||
updateAvailable: null,
|
||||
channelHealth: { available: true, degraded: [] },
|
||||
recentExternalEdit: false,
|
||||
auditSequence: 0,
|
||||
});
|
||||
greetingMocks.resolveSystemAgentGreeting.mockReset().mockResolvedValue({
|
||||
text: "I'm OpenClaw. All systems nominal.",
|
||||
source: "model",
|
||||
});
|
||||
onboardingWelcomeMocks.buildOnboardingWelcome.mockReset().mockResolvedValue({
|
||||
text: "Inference is ready. Let's finish setup.",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -201,6 +235,10 @@ afterEach(() => {
|
||||
providerAuthChoiceMocks.applyAuthChoiceLoadedPluginProvider.mockReset();
|
||||
setupSharedMocks.readSetupConfigFileSnapshot.mockReset();
|
||||
setupSharedMocks.writeWizardConfigFile.mockReset();
|
||||
greetingMocks.loadSystemAgentGreetingFacts.mockReset();
|
||||
greetingMocks.resolveSystemAgentGreeting.mockReset();
|
||||
greetingMocks.acknowledgeSystemAgentGreetingDelivery.mockReset();
|
||||
onboardingWelcomeMocks.buildOnboardingWelcome.mockReset();
|
||||
verifiedInference = undefined;
|
||||
verifiedInferenceDeps = undefined;
|
||||
resetCommandQueueStateForTest();
|
||||
|
||||
@@ -23,11 +23,16 @@ import { CommandLane } from "../../process/lanes.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { SystemAgentChatEngine } from "../../system-agent/chat-engine.js";
|
||||
import { resolveSystemAgentDelegationKey } from "../../system-agent/delegation-session.js";
|
||||
import {
|
||||
acknowledgeSystemAgentGreetingDelivery,
|
||||
buildSystemAgentGreetingQuestion,
|
||||
loadSystemAgentGreetingFacts,
|
||||
resolveSystemAgentGreeting,
|
||||
} from "../../system-agent/greeting.js";
|
||||
import { isSystemAgentInferenceUnavailableError } from "../../system-agent/inference-error.js";
|
||||
import { buildNewAgentWelcome } from "../../system-agent/new-agent-welcome.js";
|
||||
import { buildOnboardingWelcome } from "../../system-agent/onboarding-welcome.js";
|
||||
import { describeSystemAgentPersistentOperation } from "../../system-agent/operations.js";
|
||||
import { formatSystemAgentStartupMessage } from "../../system-agent/overview.js";
|
||||
import {
|
||||
appendTranscriptReset,
|
||||
appendTranscriptTurn,
|
||||
@@ -79,6 +84,15 @@ function getSystemAgentSessionQueue(
|
||||
return queue;
|
||||
}
|
||||
|
||||
function acknowledgeDeliveredSystemAgentWelcome(session: SystemAgentChatSession): void {
|
||||
const auditSequence = session.welcomeAuditSequence;
|
||||
if (auditSequence === undefined) {
|
||||
return;
|
||||
}
|
||||
acknowledgeSystemAgentGreetingDelivery({ auditSequence });
|
||||
delete session.welcomeAuditSequence;
|
||||
}
|
||||
|
||||
async function runSystemAgentGatewayTask<T>(task: () => Promise<T>): Promise<T> {
|
||||
// Track every accepted RPC as active, never queued: restart draining snapshots
|
||||
// active ids, so a queued OpenClaw request could otherwise outlive its socket.
|
||||
@@ -521,6 +535,8 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
|
||||
await existing?.engine.dispose();
|
||||
}
|
||||
let session = sessions.get(sessionId);
|
||||
let greetingAuditSequence: number | undefined;
|
||||
const welcomeOnly = params.message === undefined || !params.message.trim();
|
||||
if (!session) {
|
||||
const inference = params.delegation
|
||||
? await import("../../system-agent/inference-fallback.js").then(
|
||||
@@ -572,7 +588,18 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
|
||||
} else if (params.welcomeVariant === "new-agent") {
|
||||
welcome = buildNewAgentWelcome({ engine });
|
||||
} else {
|
||||
welcome = formatSystemAgentStartupMessage(await engine.loadOverview());
|
||||
const overview = await engine.loadOverview();
|
||||
const facts = loadSystemAgentGreetingFacts();
|
||||
greetingAuditSequence = facts.auditSequence;
|
||||
welcome = (
|
||||
await resolveSystemAgentGreeting({
|
||||
overview,
|
||||
facts,
|
||||
planner: (plannerParams) => engine.planGreeting(plannerParams),
|
||||
allowInference: welcomeOnly,
|
||||
})
|
||||
).text;
|
||||
welcomeQuestion = buildSystemAgentGreetingQuestion(overview, facts);
|
||||
engine.noteAssistantMessage(welcome);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -592,11 +619,14 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
|
||||
engine,
|
||||
welcome,
|
||||
...(welcomeQuestion ? { welcomeQuestion } : {}),
|
||||
...(greetingAuditSequence !== undefined
|
||||
? { welcomeAuditSequence: greetingAuditSequence }
|
||||
: {}),
|
||||
lastUsedAt: Date.now(),
|
||||
ownerKey,
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
if (params.message === undefined || !params.message.trim()) {
|
||||
if (welcomeOnly) {
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
@@ -607,10 +637,12 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
acknowledgeDeliveredSystemAgentWelcome(session);
|
||||
return;
|
||||
}
|
||||
}
|
||||
session.lastUsedAt = Date.now();
|
||||
// Inline check (not `welcomeOnly`) so TS narrows params.message below.
|
||||
if (params.message === undefined || !params.message.trim()) {
|
||||
respond(
|
||||
true,
|
||||
@@ -622,6 +654,7 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
acknowledgeDeliveredSystemAgentWelcome(session);
|
||||
return;
|
||||
}
|
||||
const historyStart = session.engine.historyLength();
|
||||
|
||||
@@ -33,6 +33,14 @@ const isRestartEnabledMock = vi.fn(() => true);
|
||||
const readPackageVersionMock = vi.fn(async () => "1.0.0");
|
||||
const detectRespawnSupervisorMock = vi.fn<() => RespawnSupervisor | null>(() => null);
|
||||
const normalizeUpdateChannelMock = vi.fn((): UpdateChannel | null => null);
|
||||
const getUpdateAvailableMock = vi.fn(
|
||||
() =>
|
||||
null as {
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
channel: string;
|
||||
} | null,
|
||||
);
|
||||
const readConfigFileSnapshotMock = vi.fn<() => Promise<ConfigFileSnapshot>>();
|
||||
type ManagedServiceUpdateHandoffResult = Awaited<
|
||||
ReturnType<
|
||||
@@ -141,6 +149,10 @@ vi.mock("../../infra/update-channels.js", () => ({
|
||||
normalizeUpdateChannel: normalizeUpdateChannelMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/update-startup.js", () => ({
|
||||
getUpdateAvailable: getUpdateAvailableMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/update-runner.js", () => ({
|
||||
resolveUpdateInstallSurface: resolveUpdateInstallSurfaceMock,
|
||||
runGatewayUpdate: runGatewayUpdateMock,
|
||||
@@ -210,6 +222,8 @@ beforeEach(() => {
|
||||
readPackageVersionMock.mockResolvedValue("1.0.0");
|
||||
normalizeUpdateChannelMock.mockReset();
|
||||
normalizeUpdateChannelMock.mockReturnValue(null);
|
||||
getUpdateAvailableMock.mockReset();
|
||||
getUpdateAvailableMock.mockReturnValue(null);
|
||||
readConfigFileSnapshotMock.mockReset();
|
||||
readConfigFileSnapshotMock.mockResolvedValue({
|
||||
path: "/tmp/openclaw.json",
|
||||
@@ -1006,6 +1020,11 @@ describe("update.run post-core plugin finalize", () => {
|
||||
|
||||
describe("update.status", () => {
|
||||
it("refreshes the latest update sentinel before responding", async () => {
|
||||
getUpdateAvailableMock.mockReturnValueOnce({
|
||||
currentVersion: "1.0.0",
|
||||
latestVersion: "2.0.0",
|
||||
channel: "latest",
|
||||
});
|
||||
getLatestUpdateRestartSentinelMock.mockReturnValueOnce({
|
||||
kind: "update",
|
||||
status: "skipped",
|
||||
@@ -1036,12 +1055,19 @@ describe("update.status", () => {
|
||||
expect(respond).toHaveBeenCalledTimes(1);
|
||||
const [ok, response] = firstMockCall(respond, "update status response") as [
|
||||
boolean,
|
||||
{ sentinel?: { kind?: string; status?: string } } | undefined,
|
||||
(
|
||||
| {
|
||||
sentinel?: { kind?: string; status?: string };
|
||||
updateAvailable?: { latestVersion?: string } | null;
|
||||
}
|
||||
| undefined
|
||||
),
|
||||
];
|
||||
expect(ok).toBe(true);
|
||||
expect(refreshLatestUpdateRestartSentinelMock).toHaveBeenCalledTimes(1);
|
||||
expect(response?.sentinel?.kind).toBe("update");
|
||||
expect(response?.sentinel?.status).toBe("ok");
|
||||
expect(response?.updateAvailable?.latestVersion).toBe("2.0.0");
|
||||
});
|
||||
|
||||
it("falls back to the cached update sentinel when refresh fails", async () => {
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
type UpdateRestartSentinelMeta,
|
||||
} from "../../infra/update-restart-sentinel-payload.js";
|
||||
import { resolveUpdateInstallSurface, runGatewayUpdate } from "../../infra/update-runner.js";
|
||||
import { getUpdateAvailable } from "../../infra/update-startup.js";
|
||||
import { formatControlPlaneActor, resolveControlPlaneActor } from "../control-plane-audit.js";
|
||||
import {
|
||||
getLatestUpdateRestartSentinel,
|
||||
@@ -141,6 +142,7 @@ export const updateHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
respond(true, {
|
||||
sentinel,
|
||||
updateAvailable: getUpdateAvailable(),
|
||||
});
|
||||
},
|
||||
"update.run": async ({ params, respond, client, context }) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// System-agent prompts drive the OpenClaw conversation with typed-command output.
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import type { SystemAgentGreetingFacts } from "./greeting.js";
|
||||
import type { SystemAgentOverview } from "./overview.js";
|
||||
|
||||
/**
|
||||
@@ -14,6 +15,45 @@ export const SYSTEM_AGENT_ASSISTANT_TIMEOUT_MS = 30_000;
|
||||
/** Local startup stages can consume nearly 30s before dispatch; leave inference a real budget. */
|
||||
export const SYSTEM_AGENT_ASSISTANT_LOCAL_TIMEOUT_MS = 120_000;
|
||||
|
||||
/** Identity used only for the bounded, cached caretaker greeting turn. */
|
||||
export const SYSTEM_AGENT_GREETING_SYSTEM_PROMPT = [
|
||||
"You are OpenClaw, the system itself — caretaker of this machine's gateway, config, channels, and agents.",
|
||||
"Speak in first person, brief and warm, no corporate filler. Report status honestly; nominal systems get one calm line.",
|
||||
"Return only the greeting as markdown: 2-5 short lines, no heading, no JSON, and no inline command suggestions.",
|
||||
"If an update is available, mention the version and offer an upgrade. If channelHealthAvailable is false, say channel health is unavailable. If channels are degraded, name them.",
|
||||
"Do not invent causes, activity, fixes, or state beyond the supplied facts.",
|
||||
].join("\n");
|
||||
|
||||
/** Compact, deterministic facts payload for the metered greeting turn. */
|
||||
export function buildSystemAgentGreetingUserPrompt(params: {
|
||||
overview: SystemAgentOverview;
|
||||
facts: SystemAgentGreetingFacts;
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
config: {
|
||||
exists: params.overview.config.exists,
|
||||
valid: params.overview.config.valid,
|
||||
},
|
||||
defaultAgentId: params.overview.defaultAgentId,
|
||||
defaultModel: params.overview.defaultModel ?? null,
|
||||
agents: params.overview.agents.map((agent) => ({
|
||||
id: agent.id,
|
||||
name: agent.name ?? null,
|
||||
isDefault: agent.isDefault,
|
||||
model: agent.model ?? null,
|
||||
})),
|
||||
gateway: {
|
||||
reachable: params.overview.gateway.reachable,
|
||||
url: params.overview.gateway.url,
|
||||
},
|
||||
updateAvailable: params.facts.updateAvailable,
|
||||
channelHealthAvailable: params.facts.channelHealth.available,
|
||||
degradedChannels: params.facts.channelHealth.degraded,
|
||||
// recentExternalEdit stays out of the model payload: its alert line is
|
||||
// host-appended at delivery.
|
||||
});
|
||||
}
|
||||
|
||||
/** System prompt: persona plus the closed command vocabulary. */
|
||||
export const SYSTEM_AGENT_ASSISTANT_SYSTEM_PROMPT = [
|
||||
"You are OpenClaw, the system agent: a small, tidy hermit crab that lives in the config shell.",
|
||||
|
||||
@@ -5,12 +5,15 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
SYSTEM_AGENT_ASSISTANT_SYSTEM_PROMPT,
|
||||
SYSTEM_AGENT_GREETING_SYSTEM_PROMPT,
|
||||
buildSystemAgentAssistantUserPrompt,
|
||||
buildSystemAgentGreetingUserPrompt,
|
||||
parseSystemAgentAssistantPlanText,
|
||||
type SystemAgentAssistantPlan,
|
||||
type SystemAgentAssistantTurn,
|
||||
} from "./assistant-prompts.js";
|
||||
import { resolveSystemAgentAssistantTimeoutMs } from "./assistant-timeout.js";
|
||||
import type { SystemAgentGreetingFacts, SystemAgentGreetingPlan } from "./greeting.js";
|
||||
import { SystemAgentInferenceUnavailableError } from "./inference-error.js";
|
||||
import type { SystemAgentOverview } from "./overview.js";
|
||||
import {
|
||||
@@ -66,11 +69,55 @@ export async function planSystemAgentCommandWithConfiguredModel(params: {
|
||||
readonly verifiedInference: SystemAgentVerifiedInferenceBinding;
|
||||
deps?: SystemAgentConfiguredModelPlannerDeps;
|
||||
}): Promise<SystemAgentAssistantPlan | null> {
|
||||
const route = await requireVerifiedPlannerRoute(params.verifiedInference, params.deps);
|
||||
const input = params.input.trim();
|
||||
if (!input) {
|
||||
return null;
|
||||
}
|
||||
const prompt = buildSystemAgentAssistantUserPrompt({
|
||||
input,
|
||||
overview: params.overview,
|
||||
...(params.history ? { history: params.history } : {}),
|
||||
...(params.pendingOperation ? { pendingOperation: params.pendingOperation } : {}),
|
||||
});
|
||||
const result = await runConfiguredSystemAgentText({
|
||||
prompt,
|
||||
systemPrompt: SYSTEM_AGENT_ASSISTANT_SYSTEM_PROMPT,
|
||||
runIdPrefix: "openclaw-planner",
|
||||
verifiedInference: params.verifiedInference,
|
||||
deps: params.deps,
|
||||
});
|
||||
const parsed = parseSystemAgentAssistantPlanText(result?.text);
|
||||
return parsed && result ? { ...parsed, modelLabel: result.modelLabel } : null;
|
||||
}
|
||||
|
||||
/** One tool-free, verified inference turn for the cached caretaker greeting. */
|
||||
export async function planSystemAgentGreetingWithConfiguredModel(params: {
|
||||
overview: SystemAgentOverview;
|
||||
facts: SystemAgentGreetingFacts;
|
||||
readonly verifiedInference: SystemAgentVerifiedInferenceBinding;
|
||||
deps?: SystemAgentConfiguredModelPlannerDeps;
|
||||
timeoutMs: number;
|
||||
}): Promise<SystemAgentGreetingPlan | null> {
|
||||
const result = await runConfiguredSystemAgentText({
|
||||
prompt: buildSystemAgentGreetingUserPrompt(params),
|
||||
systemPrompt: SYSTEM_AGENT_GREETING_SYSTEM_PROMPT,
|
||||
runIdPrefix: "openclaw-greeting",
|
||||
verifiedInference: params.verifiedInference,
|
||||
deps: params.deps,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
return result ? { text: result.text, modelRef: result.modelLabel } : null;
|
||||
}
|
||||
|
||||
async function runConfiguredSystemAgentText(params: {
|
||||
prompt: string;
|
||||
systemPrompt: string;
|
||||
runIdPrefix: string;
|
||||
readonly verifiedInference: SystemAgentVerifiedInferenceBinding;
|
||||
deps?: SystemAgentConfiguredModelPlannerDeps;
|
||||
timeoutMs?: number;
|
||||
}): Promise<{ text: string; modelLabel: string } | null> {
|
||||
const route = await requireVerifiedPlannerRoute(params.verifiedInference, params.deps);
|
||||
let expectedAgentHarnessRuntimeArtifact: ReturnType<
|
||||
typeof resolveSystemAgentExpectedAgentHarnessRuntimeArtifact
|
||||
>;
|
||||
@@ -81,19 +128,13 @@ export async function planSystemAgentCommandWithConfiguredModel(params: {
|
||||
} catch (error) {
|
||||
throw new SystemAgentInferenceUnavailableError("planner", [error]);
|
||||
}
|
||||
const prompt = buildSystemAgentAssistantUserPrompt({
|
||||
input,
|
||||
overview: params.overview,
|
||||
...(params.history ? { history: params.history } : {}),
|
||||
...(params.pendingOperation ? { pendingOperation: params.pendingOperation } : {}),
|
||||
});
|
||||
const tempDir = await (params.deps?.createTempDir ?? createTempPlannerDir)();
|
||||
let plan: SystemAgentAssistantPlan | null;
|
||||
let text: string | undefined;
|
||||
try {
|
||||
const runId = `openclaw-planner-${randomUUID()}`;
|
||||
const timeoutMs = (
|
||||
params.deps?.resolveAssistantTimeoutMs ?? resolveSystemAgentAssistantTimeoutMs
|
||||
)(route);
|
||||
const runId = `${params.runIdPrefix}-${randomUUID()}`;
|
||||
const timeoutMs =
|
||||
params.timeoutMs ??
|
||||
(params.deps?.resolveAssistantTimeoutMs ?? resolveSystemAgentAssistantTimeoutMs)(route);
|
||||
const shared = {
|
||||
sessionId: `${runId}-session`,
|
||||
agentId: "openclaw",
|
||||
@@ -103,14 +144,14 @@ export async function planSystemAgentCommandWithConfiguredModel(params: {
|
||||
cwd: tempDir,
|
||||
agentDir: route.agentDir,
|
||||
config: route.runConfig,
|
||||
prompt,
|
||||
prompt: params.prompt,
|
||||
provider: route.provider,
|
||||
model: route.model,
|
||||
timeoutMs,
|
||||
thinkLevel: "off" as const,
|
||||
runId,
|
||||
extraSystemPrompt: SYSTEM_AGENT_ASSISTANT_SYSTEM_PROMPT,
|
||||
extraSystemPromptStatic: SYSTEM_AGENT_ASSISTANT_SYSTEM_PROMPT,
|
||||
extraSystemPrompt: params.systemPrompt,
|
||||
extraSystemPromptStatic: params.systemPrompt,
|
||||
messageChannel: "openclaw",
|
||||
messageProvider: "openclaw",
|
||||
disableTools: true,
|
||||
@@ -137,22 +178,22 @@ export async function planSystemAgentCommandWithConfiguredModel(params: {
|
||||
cleanupBundleMcpOnRunEnd: true,
|
||||
...(route.authProfileId ? { authProfileIdSource: "user" as const } : {}),
|
||||
});
|
||||
const parsed = parseSystemAgentAssistantPlanText(extractPlannerResultText(result));
|
||||
plan = parsed ? { ...parsed, modelLabel: route.modelLabel } : null;
|
||||
text = extractPlannerResultText(result)?.trim();
|
||||
} catch (error) {
|
||||
if (error instanceof SystemAgentInferenceUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
plan = null;
|
||||
text = undefined;
|
||||
} finally {
|
||||
await (params.deps?.removeTempDir ?? removeTempPlannerDir)(tempDir);
|
||||
}
|
||||
// Cleanup is the final suspension before callers can display or execute the
|
||||
// model result, so authority must still match after cleanup completes.
|
||||
if (plan) {
|
||||
await requireVerifiedPlannerRoute(params.verifiedInference, params.deps);
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
return plan;
|
||||
// Cleanup is the final suspension before callers can display model text, so
|
||||
// authority must still match after cleanup completes.
|
||||
await requireVerifiedPlannerRoute(params.verifiedInference, params.deps);
|
||||
return { text, modelLabel: route.modelLabel };
|
||||
}
|
||||
|
||||
async function requireVerifiedPlannerRoute(
|
||||
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
} from "./approval-intent.js";
|
||||
import type { SystemAgentAssistantPlanner, SystemAgentAssistantTurn } from "./assistant.js";
|
||||
import { approvalQuestion } from "./dialogue.js";
|
||||
import type {
|
||||
SystemAgentGreetingFacts,
|
||||
SystemAgentGreetingPlan,
|
||||
SystemAgentGreetingPlanner,
|
||||
} from "./greeting.js";
|
||||
import {
|
||||
SystemAgentInferenceUnavailableError,
|
||||
isSystemAgentInferenceUnavailableError,
|
||||
@@ -59,6 +64,8 @@ export type SystemAgentChatEngineOptions = {
|
||||
yes?: boolean;
|
||||
deps?: SystemAgentCommandDeps;
|
||||
planWithAssistant?: SystemAgentAssistantPlanner;
|
||||
/** Test seam for the one-shot cached caretaker greeting. */
|
||||
planGreeting?: SystemAgentGreetingPlanner;
|
||||
/** Test seam for the embedded agent-loop turn runner. */
|
||||
runAgentTurn?: SystemAgentTurnRunner;
|
||||
/** Test seam for the approval-intent classifier. */
|
||||
@@ -992,6 +999,28 @@ export class SystemAgentChatEngine {
|
||||
return { ...overview, defaultModel: verifiedRoute.modelLabel };
|
||||
}
|
||||
|
||||
async planGreeting(params: {
|
||||
overview: SystemAgentOverview;
|
||||
facts: SystemAgentGreetingFacts;
|
||||
timeoutMs: number;
|
||||
}): Promise<SystemAgentGreetingPlan | null> {
|
||||
const planner = this.opts.planGreeting;
|
||||
const plan = planner
|
||||
? await planner(params)
|
||||
: await import("./assistant.js").then(({ planSystemAgentGreetingWithConfiguredModel }) =>
|
||||
planSystemAgentGreetingWithConfiguredModel({
|
||||
...params,
|
||||
verifiedInference: this.verifiedInference,
|
||||
deps: this.opts.deps,
|
||||
}),
|
||||
);
|
||||
if (plan) {
|
||||
// Custom planners do not inherit the configured planner's cleanup guard.
|
||||
await this.requireVerifiedInference();
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
private async requireVerifiedInference() {
|
||||
const binding = this.verifiedInference;
|
||||
if (this.agentSession.verifiedInference !== binding) {
|
||||
|
||||
@@ -0,0 +1,895 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ConfigAuditRecord } from "../config/io.audit.js";
|
||||
import type { SequencedSqliteAuditRecordEntry } from "../infra/sqlite-audit-record-store.js";
|
||||
import { SYSTEM_AGENT_GREETING_SYSTEM_PROMPT } from "./assistant-prompts.js";
|
||||
import {
|
||||
acknowledgeSystemAgentGreetingDelivery,
|
||||
buildSystemAgentGreetingQuestion,
|
||||
loadSystemAgentGreetingFacts,
|
||||
resolveSystemAgentGreeting,
|
||||
SYSTEM_AGENT_EXTERNAL_EDIT_ALERT,
|
||||
systemAgentGreetingChannelHealth,
|
||||
systemAgentGreetingFactsHash,
|
||||
type SystemAgentGreetingCacheRecord,
|
||||
type SystemAgentGreetingCacheStore,
|
||||
type SystemAgentGreetingFacts,
|
||||
} from "./greeting.js";
|
||||
import type { SystemAgentOverview } from "./overview.js";
|
||||
|
||||
function createOverview(overrides: Partial<SystemAgentOverview> = {}): SystemAgentOverview {
|
||||
return {
|
||||
config: { path: "/tmp/openclaw.json", exists: true, valid: true, issues: [], hash: "hash" },
|
||||
agents: [{ id: "main", name: "Main", isDefault: true, model: "openai/gpt-5.5" }],
|
||||
defaultAgentId: "main",
|
||||
defaultModel: "openai/gpt-5.5",
|
||||
tools: {
|
||||
codex: { command: "codex", found: false },
|
||||
claude: { command: "claude", found: false },
|
||||
gemini: { command: "gemini", found: false },
|
||||
apiKeys: { openai: false, anthropic: false },
|
||||
},
|
||||
gateway: { url: "ws://127.0.0.1:18789", source: "test", reachable: true },
|
||||
references: {
|
||||
docsUrl: "https://docs.openclaw.ai",
|
||||
sourceUrl: "https://github.com/openclaw/openclaw",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const healthyFacts = (): SystemAgentGreetingFacts => ({
|
||||
updateAvailable: null,
|
||||
channelHealth: { available: true, degraded: [] },
|
||||
recentExternalEdit: false,
|
||||
auditSequence: 0,
|
||||
});
|
||||
|
||||
function createCache(
|
||||
initial?: Omit<SystemAgentGreetingCacheRecord, "lastSeenAuditSequence"> & {
|
||||
lastSeenAuditSequence?: number;
|
||||
},
|
||||
) {
|
||||
let record: SystemAgentGreetingCacheRecord | undefined = initial
|
||||
? { lastSeenAuditSequence: 0, ...initial }
|
||||
: undefined;
|
||||
const store: SystemAgentGreetingCacheStore = {
|
||||
latest: vi.fn(() =>
|
||||
record ? [{ key: "latest", value: record, createdAt: record.at ?? 0, sequence: 1 }] : [],
|
||||
),
|
||||
compareAndSet: vi.fn((_key, expectedValue, value) => {
|
||||
if (JSON.stringify(record ?? null) !== JSON.stringify(expectedValue)) {
|
||||
return false;
|
||||
}
|
||||
record = value ?? undefined;
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
return { store, read: () => record };
|
||||
}
|
||||
|
||||
function configAuditEntry(
|
||||
sequence: number,
|
||||
event: ConfigAuditRecord["event"],
|
||||
): SequencedSqliteAuditRecordEntry<ConfigAuditRecord> {
|
||||
return {
|
||||
key: `${event}-${sequence}`,
|
||||
value: { event, ts: new Date(sequence).toISOString() } as ConfigAuditRecord,
|
||||
createdAt: sequence,
|
||||
sequence,
|
||||
};
|
||||
}
|
||||
|
||||
function createConfigAudit(
|
||||
initial: Array<SequencedSqliteAuditRecordEntry<ConfigAuditRecord>> = [],
|
||||
) {
|
||||
const records = [...initial];
|
||||
const latest = vi.fn(({ limit, beforeSequence }: { limit: number; beforeSequence?: number }) =>
|
||||
records
|
||||
.filter((entry) => beforeSequence === undefined || entry.sequence < beforeSequence)
|
||||
.toSorted((left, right) => right.sequence - left.sequence)
|
||||
.slice(0, limit),
|
||||
);
|
||||
return {
|
||||
store: { latest },
|
||||
add: (entry: SequencedSqliteAuditRecordEntry<ConfigAuditRecord>) => records.push(entry),
|
||||
};
|
||||
}
|
||||
|
||||
describe("system agent greeting cache", () => {
|
||||
it("returns a matching model greeting without calling the planner", async () => {
|
||||
const overview = createOverview();
|
||||
const facts = healthyFacts();
|
||||
const cached: SystemAgentGreetingCacheRecord = {
|
||||
lastSeenAuditSequence: 4,
|
||||
factsHash: systemAgentGreetingFactsHash(overview, facts),
|
||||
text: "All systems nominal.",
|
||||
modelRef: "openai/gpt-5.5",
|
||||
at: 100,
|
||||
};
|
||||
const cache = createCache(cached);
|
||||
const planner = vi.fn();
|
||||
|
||||
await expect(
|
||||
resolveSystemAgentGreeting({
|
||||
overview,
|
||||
facts,
|
||||
planner,
|
||||
allowInference: false,
|
||||
cacheStore: cache.store,
|
||||
}),
|
||||
).resolves.toEqual({ text: cached.text, source: "cache" });
|
||||
acknowledgeSystemAgentGreetingDelivery({ auditSequence: 5, cacheStore: cache.store });
|
||||
expect(planner).not.toHaveBeenCalled();
|
||||
expect(cache.read()?.lastSeenAuditSequence).toBe(5);
|
||||
});
|
||||
|
||||
it("uses the template without calling the planner when inference is disabled", async () => {
|
||||
const cache = createCache();
|
||||
const planner = vi.fn();
|
||||
|
||||
await expect(
|
||||
resolveSystemAgentGreeting({
|
||||
overview: createOverview(),
|
||||
facts: healthyFacts(),
|
||||
planner,
|
||||
allowInference: false,
|
||||
cacheStore: cache.store,
|
||||
}),
|
||||
).resolves.toMatchObject({ source: "template" });
|
||||
|
||||
expect(planner).not.toHaveBeenCalled();
|
||||
expect(cache.read()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("calls the planner once after a state change and replaces the cache", async () => {
|
||||
const overview = createOverview();
|
||||
const priorFacts = healthyFacts();
|
||||
const facts = { ...priorFacts, updateAvailable: "2026.7.20", auditSequence: 6 };
|
||||
const cache = createCache({
|
||||
factsHash: systemAgentGreetingFactsHash(overview, priorFacts),
|
||||
text: "All systems nominal.",
|
||||
modelRef: "openai/gpt-5.5",
|
||||
at: 100,
|
||||
});
|
||||
const planner = vi.fn(async () => ({
|
||||
text: "I'm steady.\nAn upgrade to 2026.7.20 is ready when you are.",
|
||||
modelRef: "openai/gpt-5.5",
|
||||
}));
|
||||
|
||||
const result = await resolveSystemAgentGreeting({
|
||||
overview,
|
||||
facts,
|
||||
planner,
|
||||
cacheStore: cache.store,
|
||||
now: () => 200,
|
||||
});
|
||||
|
||||
expect(result.source).toBe("model");
|
||||
expect(planner).toHaveBeenCalledOnce();
|
||||
expect(planner).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ overview, facts, timeoutMs: 20_000 }),
|
||||
);
|
||||
acknowledgeSystemAgentGreetingDelivery({
|
||||
auditSequence: facts.auditSequence,
|
||||
cacheStore: cache.store,
|
||||
});
|
||||
expect(cache.read()).toMatchObject({
|
||||
lastSeenAuditSequence: 6,
|
||||
factsHash: systemAgentGreetingFactsHash(overview, facts),
|
||||
modelRef: "openai/gpt-5.5",
|
||||
at: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it("appends the host-owned edit alert at delivery without caching it", async () => {
|
||||
const overview = createOverview();
|
||||
const facts = { ...healthyFacts(), recentExternalEdit: true, auditSequence: 9 };
|
||||
const planner = vi.fn(async () => ({
|
||||
text: "All systems nominal.",
|
||||
modelRef: "openai/gpt-5.5",
|
||||
}));
|
||||
const cache = createCache();
|
||||
|
||||
const result = await resolveSystemAgentGreeting({
|
||||
overview,
|
||||
facts,
|
||||
planner,
|
||||
cacheStore: cache.store,
|
||||
now: () => 200,
|
||||
});
|
||||
|
||||
expect(result.text).toContain(SYSTEM_AGENT_EXTERNAL_EDIT_ALERT);
|
||||
// The cache stores only the model text; the alert is delivery-scoped.
|
||||
expect(cache.read()?.text).toBe("All systems nominal.");
|
||||
|
||||
const calm = await resolveSystemAgentGreeting({
|
||||
overview,
|
||||
facts: { ...facts, recentExternalEdit: false },
|
||||
planner,
|
||||
cacheStore: cache.store,
|
||||
now: () => 300,
|
||||
});
|
||||
expect(calm.source).toBe("cache");
|
||||
expect(calm.text).not.toContain(SYSTEM_AGENT_EXTERNAL_EDIT_ALERT);
|
||||
});
|
||||
|
||||
it("reports an edit that arrives between the facts read and delivery", () => {
|
||||
const cache = createCache({ lastSeenAuditSequence: 10 });
|
||||
const audit = createConfigAudit([configAuditEntry(10, "config.write")]);
|
||||
const facts = loadSystemAgentGreetingFacts({
|
||||
cacheStore: cache.store,
|
||||
configAuditStore: audit.store,
|
||||
});
|
||||
|
||||
audit.add(configAuditEntry(11, "config.external"));
|
||||
acknowledgeSystemAgentGreetingDelivery({
|
||||
auditSequence: facts.auditSequence,
|
||||
cacheStore: cache.store,
|
||||
});
|
||||
|
||||
expect(
|
||||
loadSystemAgentGreetingFacts({ cacheStore: cache.store, configAuditStore: audit.store }),
|
||||
).toMatchObject({ auditSequence: 11, recentExternalEdit: true });
|
||||
});
|
||||
|
||||
it("keeps a template delivery as cursor-only state, then reports a later edit", async () => {
|
||||
const overview = createOverview();
|
||||
const cache = createCache();
|
||||
const audit = createConfigAudit([configAuditEntry(3, "config.write")]);
|
||||
const facts = loadSystemAgentGreetingFacts({
|
||||
cacheStore: cache.store,
|
||||
configAuditStore: audit.store,
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveSystemAgentGreeting({
|
||||
overview,
|
||||
facts,
|
||||
planner: async () => {
|
||||
throw new Error("offline");
|
||||
},
|
||||
cacheStore: cache.store,
|
||||
}),
|
||||
).resolves.toMatchObject({ source: "template" });
|
||||
acknowledgeSystemAgentGreetingDelivery({
|
||||
auditSequence: facts.auditSequence,
|
||||
cacheStore: cache.store,
|
||||
});
|
||||
expect(cache.read()).toEqual({ lastSeenAuditSequence: 3 });
|
||||
|
||||
audit.add(configAuditEntry(4, "config.external"));
|
||||
expect(
|
||||
loadSystemAgentGreetingFacts({ cacheStore: cache.store, configAuditStore: audit.store }),
|
||||
).toMatchObject({ auditSequence: 4, recentExternalEdit: true });
|
||||
});
|
||||
|
||||
it("pages past six internal writes to find an external edit above the watermark", () => {
|
||||
const cache = createCache({ lastSeenAuditSequence: 1 });
|
||||
const audit = createConfigAudit([
|
||||
configAuditEntry(2, "config.external"),
|
||||
...Array.from({ length: 7 }, (_, index) => configAuditEntry(index + 3, "config.write")),
|
||||
]);
|
||||
|
||||
const facts = loadSystemAgentGreetingFacts({
|
||||
cacheStore: cache.store,
|
||||
configAuditStore: audit.store,
|
||||
});
|
||||
|
||||
expect(facts).toMatchObject({ auditSequence: 9, recentExternalEdit: true });
|
||||
expect(audit.store.latest).toHaveBeenCalledTimes(2);
|
||||
expect(audit.store.latest).toHaveBeenNthCalledWith(2, { limit: 5, beforeSequence: 5 });
|
||||
});
|
||||
|
||||
it("acknowledges a delivered edit so the next facts load is clear", () => {
|
||||
const cache = createCache({ lastSeenAuditSequence: 1 });
|
||||
const audit = createConfigAudit([
|
||||
configAuditEntry(2, "config.external"),
|
||||
configAuditEntry(3, "config.write"),
|
||||
]);
|
||||
const facts = loadSystemAgentGreetingFacts({
|
||||
cacheStore: cache.store,
|
||||
configAuditStore: audit.store,
|
||||
});
|
||||
expect(facts.recentExternalEdit).toBe(true);
|
||||
|
||||
acknowledgeSystemAgentGreetingDelivery({
|
||||
auditSequence: facts.auditSequence,
|
||||
cacheStore: cache.store,
|
||||
});
|
||||
|
||||
expect(
|
||||
loadSystemAgentGreetingFacts({ cacheStore: cache.store, configAuditStore: audit.store }),
|
||||
).toMatchObject({ auditSequence: 3, recentExternalEdit: false });
|
||||
});
|
||||
|
||||
it("coalesces concurrent planner calls for the same facts", async () => {
|
||||
const overview = createOverview();
|
||||
const facts = healthyFacts();
|
||||
const cache = createCache();
|
||||
let finishPlan: ((plan: { text: string; modelRef: string }) => void) | undefined;
|
||||
const planner = vi.fn(
|
||||
() =>
|
||||
new Promise<{ text: string; modelRef: string }>((resolve) => {
|
||||
finishPlan = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const first = resolveSystemAgentGreeting({ overview, facts, planner, cacheStore: cache.store });
|
||||
const second = resolveSystemAgentGreeting({
|
||||
overview,
|
||||
facts,
|
||||
planner,
|
||||
cacheStore: cache.store,
|
||||
});
|
||||
expect(planner).toHaveBeenCalledOnce();
|
||||
finishPlan?.({ text: "All systems nominal.", modelRef: "openai/gpt-5.5" });
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([
|
||||
{ text: "All systems nominal.", source: "model" },
|
||||
{ text: "All systems nominal.", source: "model" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not let an older in-flight greeting replace newer facts", async () => {
|
||||
const overview = createOverview();
|
||||
const oldFacts = healthyFacts();
|
||||
const newFacts = { ...oldFacts, updateAvailable: "2026.7.20" };
|
||||
const cache = createCache();
|
||||
let finishOld: ((plan: { text: string; modelRef: string }) => void) | undefined;
|
||||
let finishNew: ((plan: { text: string; modelRef: string }) => void) | undefined;
|
||||
const oldGreeting = resolveSystemAgentGreeting({
|
||||
overview,
|
||||
facts: oldFacts,
|
||||
planner: () =>
|
||||
new Promise((resolve) => {
|
||||
finishOld = resolve;
|
||||
}),
|
||||
cacheStore: cache.store,
|
||||
now: () => 100,
|
||||
});
|
||||
const newGreeting = resolveSystemAgentGreeting({
|
||||
overview,
|
||||
facts: newFacts,
|
||||
planner: () =>
|
||||
new Promise((resolve) => {
|
||||
finishNew = resolve;
|
||||
}),
|
||||
cacheStore: cache.store,
|
||||
now: () => 200,
|
||||
});
|
||||
|
||||
finishNew?.({ text: "Update 2026.7.20 is ready.", modelRef: "openai/gpt-5.5" });
|
||||
await newGreeting;
|
||||
finishOld?.({ text: "All systems nominal.", modelRef: "openai/gpt-5.5" });
|
||||
await oldGreeting;
|
||||
|
||||
expect(cache.read()).toMatchObject({
|
||||
factsHash: systemAgentGreetingFactsHash(overview, newFacts),
|
||||
text: "Update 2026.7.20 is ready.",
|
||||
at: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it("timestamps the greeting snapshot before inference begins", async () => {
|
||||
const events: string[] = [];
|
||||
const cache = createCache();
|
||||
await resolveSystemAgentGreeting({
|
||||
overview: createOverview(),
|
||||
facts: healthyFacts(),
|
||||
planner: async () => {
|
||||
events.push("planner");
|
||||
return { text: "All systems nominal.", modelRef: "openai/gpt-5.5" };
|
||||
},
|
||||
cacheStore: cache.store,
|
||||
now: () => {
|
||||
events.push("snapshot");
|
||||
return 100;
|
||||
},
|
||||
});
|
||||
|
||||
expect(events).toEqual(["snapshot", "planner"]);
|
||||
expect(cache.read()?.at).toBe(100);
|
||||
});
|
||||
|
||||
it("uses a complete uncached template and backs off unchanged model failures", async () => {
|
||||
const cache = createCache();
|
||||
const overview = createOverview();
|
||||
const planner = vi.fn(async () => {
|
||||
throw new Error("offline");
|
||||
});
|
||||
const facts: SystemAgentGreetingFacts = {
|
||||
updateAvailable: "2026.7.20",
|
||||
channelHealth: { available: true, degraded: ["Telegram"] },
|
||||
recentExternalEdit: true,
|
||||
auditSequence: 7,
|
||||
};
|
||||
|
||||
const result = await resolveSystemAgentGreeting({
|
||||
overview,
|
||||
facts,
|
||||
planner,
|
||||
cacheStore: cache.store,
|
||||
now: () => 100,
|
||||
});
|
||||
const repeated = await resolveSystemAgentGreeting({
|
||||
overview,
|
||||
facts,
|
||||
planner,
|
||||
cacheStore: cache.store,
|
||||
now: () => 200,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ source: "template" });
|
||||
expect(result.text).toContain("caretaker of this gateway");
|
||||
expect(result.text).toContain("Update 2026.7.20 is available");
|
||||
expect(result.text).toContain("Channels needing attention: Telegram");
|
||||
expect(result.text).toContain(SYSTEM_AGENT_EXTERNAL_EDIT_ALERT);
|
||||
expect(repeated).toEqual(result);
|
||||
expect(planner).toHaveBeenCalledOnce();
|
||||
acknowledgeSystemAgentGreetingDelivery({
|
||||
auditSequence: facts.auditSequence,
|
||||
cacheStore: cache.store,
|
||||
});
|
||||
expect(cache.read()).toEqual({ lastSeenAuditSequence: 7 });
|
||||
});
|
||||
|
||||
it("rejects a model greeting that omits mild facts", async () => {
|
||||
const cache = createCache();
|
||||
const facts: SystemAgentGreetingFacts = {
|
||||
updateAvailable: "2026.7.20",
|
||||
channelHealth: { available: true, degraded: ["Telegram"] },
|
||||
recentExternalEdit: true,
|
||||
auditSequence: 7,
|
||||
};
|
||||
const result = await resolveSystemAgentGreeting({
|
||||
overview: createOverview(),
|
||||
facts,
|
||||
planner: async () => ({
|
||||
text: "All systems nominal.",
|
||||
modelRef: "openai/gpt-5.5",
|
||||
}),
|
||||
cacheStore: cache.store,
|
||||
now: () => 100,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ source: "template" });
|
||||
expect(result.text).toContain("Update 2026.7.20 is available");
|
||||
expect(result.text).toContain("Telegram");
|
||||
expect(result.text).toContain(SYSTEM_AGENT_EXTERNAL_EDIT_ALERT);
|
||||
expect(cache.read()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects structured output smuggled behind a preamble line", async () => {
|
||||
const cache = createCache();
|
||||
const result = await resolveSystemAgentGreeting({
|
||||
overview: createOverview(),
|
||||
facts: healthyFacts(),
|
||||
planner: async () => ({
|
||||
text: 'Sure:\n{"status":"healthy"}',
|
||||
modelRef: "openai/gpt-5.5",
|
||||
}),
|
||||
cacheStore: cache.store,
|
||||
now: () => 100,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ source: "template" });
|
||||
expect(cache.read()?.text).toBeUndefined();
|
||||
});
|
||||
|
||||
it("requires an available update's version before caching model text", async () => {
|
||||
const cache = createCache();
|
||||
const result = await resolveSystemAgentGreeting({
|
||||
overview: createOverview(),
|
||||
facts: { ...healthyFacts(), updateAvailable: "2026.7.20" },
|
||||
planner: async () => ({
|
||||
text: "An update is ready when you are.",
|
||||
modelRef: "openai/gpt-5.5",
|
||||
}),
|
||||
cacheStore: cache.store,
|
||||
now: () => 100,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ source: "template" });
|
||||
expect(cache.read()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("requires every degraded channel label before caching model text", async () => {
|
||||
const cache = createCache();
|
||||
const result = await resolveSystemAgentGreeting({
|
||||
overview: createOverview(),
|
||||
facts: {
|
||||
...healthyFacts(),
|
||||
channelHealth: { available: true, degraded: ["Telegram", "Discord"] },
|
||||
},
|
||||
planner: async () => ({
|
||||
text: "Telegram needs attention.",
|
||||
modelRef: "openai/gpt-5.5",
|
||||
}),
|
||||
cacheStore: cache.store,
|
||||
now: () => 100,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ source: "template" });
|
||||
expect(cache.read()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts model text that names every degraded channel", async () => {
|
||||
const cache = createCache();
|
||||
const result = await resolveSystemAgentGreeting({
|
||||
overview: createOverview(),
|
||||
facts: {
|
||||
...healthyFacts(),
|
||||
channelHealth: { available: true, degraded: ["Telegram", "Discord"] },
|
||||
},
|
||||
planner: async () => ({
|
||||
text: "Telegram and Discord need attention.",
|
||||
modelRef: "openai/gpt-5.5",
|
||||
}),
|
||||
cacheStore: cache.store,
|
||||
now: () => 100,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
text: "Telegram and Discord need attention.",
|
||||
source: "model",
|
||||
});
|
||||
expect(cache.read()?.text).toBe("Telegram and Discord need attention.");
|
||||
});
|
||||
|
||||
it("requires channel health unavailability before caching model text", async () => {
|
||||
const cache = createCache();
|
||||
const facts: SystemAgentGreetingFacts = {
|
||||
...healthyFacts(),
|
||||
channelHealth: { available: false, degraded: [] },
|
||||
};
|
||||
|
||||
const result = await resolveSystemAgentGreeting({
|
||||
overview: createOverview(),
|
||||
facts,
|
||||
planner: async () => ({ text: "All systems nominal.", modelRef: "openai/gpt-5.5" }),
|
||||
cacheStore: cache.store,
|
||||
now: () => 100,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ source: "template" });
|
||||
expect(result.text).toContain("Channel health is not available yet");
|
||||
expect(cache.read()).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "missing config",
|
||||
overview: createOverview({
|
||||
config: {
|
||||
path: "/tmp/openclaw.json",
|
||||
exists: false,
|
||||
valid: false,
|
||||
issues: [],
|
||||
hash: null,
|
||||
},
|
||||
}),
|
||||
expected: "Config: missing",
|
||||
},
|
||||
{
|
||||
name: "invalid config",
|
||||
overview: createOverview({
|
||||
config: {
|
||||
path: "/tmp/openclaw.json",
|
||||
exists: true,
|
||||
valid: false,
|
||||
issues: ["invalid"],
|
||||
hash: null,
|
||||
},
|
||||
}),
|
||||
expected: "Config: invalid",
|
||||
},
|
||||
{
|
||||
name: "unreachable gateway",
|
||||
overview: createOverview({
|
||||
gateway: { url: "ws://127.0.0.1:18789", source: "test", reachable: false },
|
||||
}),
|
||||
expected: "Gateway: not reachable",
|
||||
},
|
||||
{
|
||||
name: "missing default model",
|
||||
overview: createOverview({ defaultModel: undefined }),
|
||||
expected: "Inference is unavailable",
|
||||
},
|
||||
])(
|
||||
"uses the deterministic template for $name without opening the cache",
|
||||
async ({ overview, expected }) => {
|
||||
const cache = createCache();
|
||||
const planner = vi.fn(async () => ({
|
||||
text: "Config setup is complete.",
|
||||
modelRef: "openai/gpt-5.5",
|
||||
}));
|
||||
const result = await resolveSystemAgentGreeting({
|
||||
overview,
|
||||
facts: healthyFacts(),
|
||||
planner,
|
||||
cacheStore: cache.store,
|
||||
now: () => 100,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ source: "template" });
|
||||
expect(result.text).toContain(expected);
|
||||
expect(planner).not.toHaveBeenCalled();
|
||||
expect(cache.store.latest).not.toHaveBeenCalled();
|
||||
expect(cache.read()).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("uses the model-free fallback when the optional greeting cache cannot open", async () => {
|
||||
const planner = vi.fn();
|
||||
const result = await resolveSystemAgentGreeting({
|
||||
overview: createOverview(),
|
||||
facts: healthyFacts(),
|
||||
planner,
|
||||
openCache: () => {
|
||||
throw new Error("sqlite unavailable");
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ source: "template" });
|
||||
expect(result.text).toContain("caretaker of this gateway");
|
||||
expect(planner).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the model-free fallback when the greeting cache cannot be read", async () => {
|
||||
const planner = vi.fn();
|
||||
const result = await resolveSystemAgentGreeting({
|
||||
overview: createOverview(),
|
||||
facts: healthyFacts(),
|
||||
planner,
|
||||
cacheStore: {
|
||||
latest: () => {
|
||||
throw new Error("sqlite corrupt");
|
||||
},
|
||||
compareAndSet: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ source: "template" });
|
||||
expect(planner).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hashes decision fields stably while ignoring diagnostic-only details", () => {
|
||||
const facts = {
|
||||
...healthyFacts(),
|
||||
channelHealth: { available: true, degraded: ["Telegram", "Discord"] },
|
||||
};
|
||||
const left = createOverview();
|
||||
const right = createOverview({
|
||||
config: {
|
||||
...left.config,
|
||||
path: "/another/config.json",
|
||||
hash: "another-hash",
|
||||
issues: ["diagnostic detail"],
|
||||
},
|
||||
agents: [...left.agents].toReversed(),
|
||||
gateway: { ...left.gateway, source: "another source", error: "diagnostic detail" },
|
||||
});
|
||||
expect(systemAgentGreetingFactsHash(left, facts)).toBe(
|
||||
systemAgentGreetingFactsHash(right, {
|
||||
...facts,
|
||||
auditSequence: 999,
|
||||
channelHealth: { available: true, degraded: ["Discord", "Telegram"] },
|
||||
}),
|
||||
);
|
||||
expect(systemAgentGreetingFactsHash(left, facts)).not.toBe(
|
||||
systemAgentGreetingFactsHash(left, {
|
||||
...facts,
|
||||
channelHealth: { available: false, degraded: [] },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("system agent greeting identity", () => {
|
||||
it("identifies OpenClaw as the machine caretaker and describes every mild fact", () => {
|
||||
expect(SYSTEM_AGENT_GREETING_SYSTEM_PROMPT).toContain(
|
||||
"You are OpenClaw, the system itself — caretaker of this machine's gateway, config, channels, and agents.",
|
||||
);
|
||||
expect(SYSTEM_AGENT_GREETING_SYSTEM_PROMPT).toContain("nominal systems get one calm line");
|
||||
expect(SYSTEM_AGENT_GREETING_SYSTEM_PROMPT).toContain("If an update is available");
|
||||
expect(SYSTEM_AGENT_GREETING_SYSTEM_PROMPT).toContain("If channelHealthAvailable is false");
|
||||
expect(SYSTEM_AGENT_GREETING_SYSTEM_PROMPT).toContain("If channels are degraded");
|
||||
// The external-edit alert is host-appended at delivery; the model prompt
|
||||
// must not mention it (double phrasing).
|
||||
expect(SYSTEM_AGENT_GREETING_SYSTEM_PROMPT).not.toContain("recentExternalEdit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("system agent greeting facts", () => {
|
||||
it("treats a missing greeting slot as audit watermark zero", () => {
|
||||
const cache = createCache();
|
||||
const audit = createConfigAudit([configAuditEntry(1, "config.external")]);
|
||||
|
||||
expect(
|
||||
loadSystemAgentGreetingFacts({ cacheStore: cache.store, configAuditStore: audit.store }),
|
||||
).toMatchObject({ auditSequence: 1, recentExternalEdit: true });
|
||||
});
|
||||
|
||||
it("uses cached update and health state without probing", () => {
|
||||
const facts = loadSystemAgentGreetingFacts({
|
||||
cacheStore: createCache({
|
||||
factsHash: "old",
|
||||
text: "old",
|
||||
modelRef: "model",
|
||||
at: 100,
|
||||
}).store,
|
||||
configAuditStore: {
|
||||
latest: () => [
|
||||
{
|
||||
key: "external",
|
||||
value: {
|
||||
event: "config.external",
|
||||
ts: new Date(200).toISOString(),
|
||||
} as ConfigAuditRecord,
|
||||
createdAt: 200,
|
||||
sequence: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
getUpdateAvailable: () => ({
|
||||
currentVersion: "2026.7.19",
|
||||
latestVersion: "2026.7.20",
|
||||
channel: "latest",
|
||||
}),
|
||||
getHealthCache: () =>
|
||||
({
|
||||
channels: {
|
||||
telegram: {
|
||||
accountId: "default",
|
||||
configured: true,
|
||||
healthState: "stale-socket",
|
||||
},
|
||||
},
|
||||
channelLabels: { telegram: "Telegram" },
|
||||
}) as never,
|
||||
});
|
||||
expect(facts).toEqual({
|
||||
updateAvailable: "2026.7.20",
|
||||
channelHealth: { available: true, degraded: ["Telegram"] },
|
||||
recentExternalEdit: true,
|
||||
auditSequence: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("deduplicates and sorts degraded channel labels", () => {
|
||||
const health = systemAgentGreetingChannelHealth({
|
||||
channels: {
|
||||
telegram: {
|
||||
accountId: "primary",
|
||||
accounts: {
|
||||
primary: { accountId: "primary", configured: true, probe: { ok: false } },
|
||||
quiet: { accountId: "quiet", configured: false, healthState: "not-running" },
|
||||
},
|
||||
},
|
||||
discord: { accountId: "default", running: true, connected: false },
|
||||
slack: { accountId: "default", configured: true, running: false },
|
||||
whatsapp: { accountId: "default", configured: true, running: true, linked: false },
|
||||
},
|
||||
channelLabels: {
|
||||
telegram: "Telegram",
|
||||
discord: "Discord",
|
||||
slack: "Slack",
|
||||
whatsapp: "WhatsApp",
|
||||
},
|
||||
} as never);
|
||||
expect(health).toEqual({
|
||||
available: true,
|
||||
degraded: ["Discord", "Slack", "Telegram", "WhatsApp"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("system agent quick actions", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "healthy",
|
||||
overview: createOverview(),
|
||||
facts: healthyFacts(),
|
||||
replies: ["talk to agent", "audit"],
|
||||
},
|
||||
{
|
||||
name: "gateway unreachable",
|
||||
overview: createOverview({
|
||||
gateway: { url: "ws://127.0.0.1:18789", source: "test", reachable: false },
|
||||
}),
|
||||
facts: healthyFacts(),
|
||||
replies: ["gateway status", "restart gateway", "talk to agent", "audit"],
|
||||
},
|
||||
{
|
||||
name: "channel health unavailable",
|
||||
overview: createOverview(),
|
||||
facts: { ...healthyFacts(), channelHealth: { available: false, degraded: [] } },
|
||||
replies: ["health", "talk to agent", "audit"],
|
||||
},
|
||||
{
|
||||
name: "missing config",
|
||||
overview: createOverview({
|
||||
config: {
|
||||
path: "/tmp/openclaw.json",
|
||||
exists: false,
|
||||
valid: true,
|
||||
issues: [],
|
||||
hash: null,
|
||||
},
|
||||
}),
|
||||
facts: healthyFacts(),
|
||||
replies: ["setup", "talk to agent", "audit"],
|
||||
},
|
||||
{
|
||||
name: "invalid config",
|
||||
overview: createOverview({
|
||||
config: {
|
||||
path: "/tmp/openclaw.json",
|
||||
exists: true,
|
||||
valid: false,
|
||||
issues: ["invalid"],
|
||||
hash: null,
|
||||
},
|
||||
}),
|
||||
facts: healthyFacts(),
|
||||
replies: ["doctor", "talk to agent", "audit"],
|
||||
},
|
||||
{
|
||||
name: "missing model",
|
||||
overview: createOverview({ defaultModel: undefined }),
|
||||
facts: healthyFacts(),
|
||||
replies: ["setup", "audit"],
|
||||
},
|
||||
{
|
||||
name: "update and manual edit",
|
||||
overview: createOverview(),
|
||||
facts: {
|
||||
updateAvailable: "2026.7.20",
|
||||
channelHealth: { available: true, degraded: [] },
|
||||
recentExternalEdit: true,
|
||||
auditSequence: 0,
|
||||
},
|
||||
replies: ["status", "talk to agent", "audit"],
|
||||
},
|
||||
{
|
||||
name: "degraded channel",
|
||||
overview: createOverview(),
|
||||
facts: {
|
||||
updateAvailable: null,
|
||||
channelHealth: { available: true, degraded: ["Telegram"] },
|
||||
recentExternalEdit: false,
|
||||
auditSequence: 0,
|
||||
},
|
||||
replies: ["health", "talk to agent", "audit"],
|
||||
},
|
||||
{
|
||||
name: "several exceptional facts",
|
||||
overview: createOverview({
|
||||
gateway: { url: "ws://127.0.0.1:18789", source: "test", reachable: false },
|
||||
}),
|
||||
facts: {
|
||||
updateAvailable: "2026.7.20",
|
||||
channelHealth: { available: true, degraded: ["Telegram"] },
|
||||
recentExternalEdit: true,
|
||||
auditSequence: 0,
|
||||
},
|
||||
replies: ["gateway status", "restart gateway", "talk to agent", "audit"],
|
||||
},
|
||||
])("builds canonical replies for $name facts", ({ overview, facts, replies }) => {
|
||||
const question = buildSystemAgentGreetingQuestion(overview, facts);
|
||||
expect(question.header).toBe("Quick actions");
|
||||
expect(question.options.map((option) => option.reply)).toEqual(replies);
|
||||
expect(question.options.length).toBeGreaterThanOrEqual(2);
|
||||
expect(question.options.length).toBeLessThanOrEqual(4);
|
||||
});
|
||||
|
||||
it("does not recommend agent handoff for a recent external edit", () => {
|
||||
const question = buildSystemAgentGreetingQuestion(createOverview(), {
|
||||
...healthyFacts(),
|
||||
recentExternalEdit: true,
|
||||
});
|
||||
expect(question.options.find((option) => option.reply === "talk to agent")?.recommended).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,650 @@
|
||||
// Cached, model-phrased caretaker greetings over deterministic gateway facts.
|
||||
import { createHash } from "node:crypto";
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import type { SystemAgentChatQuestion } from "../../packages/gateway-protocol/src/index.js";
|
||||
import type { HealthSummary } from "../commands/health.types.js";
|
||||
import {
|
||||
CONFIG_AUDIT_MAX_ENTRIES,
|
||||
CONFIG_AUDIT_SCOPE,
|
||||
type ConfigAuditRecord,
|
||||
} from "../config/io.audit.js";
|
||||
import { getHealthCache } from "../gateway/server/health-state.js";
|
||||
import { createSqliteAuditRecordStore } from "../infra/sqlite-audit-record-store.js";
|
||||
import { getUpdateAvailable, type UpdateAvailable } from "../infra/update-startup.js";
|
||||
import { formatSystemAgentStartupMessage, type SystemAgentOverview } from "./overview.js";
|
||||
|
||||
const SYSTEM_AGENT_GREETING_SCOPE = "system-agent-greeting";
|
||||
const SYSTEM_AGENT_GREETING_KEY = "latest";
|
||||
const SYSTEM_AGENT_GREETING_TIMEOUT_MS = 20_000;
|
||||
const SYSTEM_AGENT_GREETING_FAILURE_RETRY_MS = 60_000;
|
||||
const SYSTEM_AGENT_GREETING_MAX_CHARS = 700;
|
||||
const SYSTEM_AGENT_GREETING_MAX_LINES = 5;
|
||||
const CONFIG_AUDIT_PAGE_SIZE = 5;
|
||||
const GREETING_STATE_CAS_ATTEMPTS = 4;
|
||||
|
||||
export type SystemAgentGreetingFacts = {
|
||||
updateAvailable: string | null;
|
||||
channelHealth: { available: boolean; degraded: string[] };
|
||||
recentExternalEdit: boolean;
|
||||
/** Newest config-audit sequence observed while these facts were built. */
|
||||
auditSequence: number;
|
||||
};
|
||||
|
||||
export type SystemAgentGreetingCacheRecord = {
|
||||
lastSeenAuditSequence: number;
|
||||
factsHash?: string;
|
||||
text?: string;
|
||||
modelRef?: string;
|
||||
at?: number;
|
||||
};
|
||||
|
||||
export type SystemAgentGreetingPlan = {
|
||||
text: string;
|
||||
modelRef: string;
|
||||
};
|
||||
|
||||
export type SystemAgentGreetingPlanner = (params: {
|
||||
overview: SystemAgentOverview;
|
||||
facts: SystemAgentGreetingFacts;
|
||||
timeoutMs: number;
|
||||
}) => Promise<SystemAgentGreetingPlan | null>;
|
||||
|
||||
export type SystemAgentGreetingCacheStore = Pick<
|
||||
ReturnType<typeof createSqliteAuditRecordStore<SystemAgentGreetingCacheRecord>>,
|
||||
"compareAndSet" | "latest"
|
||||
>;
|
||||
|
||||
type SystemAgentGreetingConfigAuditStore = Pick<
|
||||
ReturnType<typeof createSqliteAuditRecordStore<ConfigAuditRecord>>,
|
||||
"latest"
|
||||
>;
|
||||
|
||||
type SystemAgentGreetingResolution = {
|
||||
text: string;
|
||||
source: "cache" | "model" | "template";
|
||||
};
|
||||
|
||||
const greetingFlights = new WeakMap<
|
||||
SystemAgentGreetingCacheStore,
|
||||
Map<string, Promise<SystemAgentGreetingResolution>>
|
||||
>();
|
||||
const greetingFailures = new WeakMap<
|
||||
SystemAgentGreetingCacheStore,
|
||||
{ factsHash: string; retryAfter: number }
|
||||
>();
|
||||
let defaultGreetingCache: SystemAgentGreetingCacheStore | undefined;
|
||||
|
||||
function openGreetingCache(env?: NodeJS.ProcessEnv): SystemAgentGreetingCacheStore {
|
||||
return createSqliteAuditRecordStore<SystemAgentGreetingCacheRecord>({
|
||||
scope: SYSTEM_AGENT_GREETING_SCOPE,
|
||||
maxEntries: 1,
|
||||
...(env ? { env } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function getDefaultGreetingCache(): SystemAgentGreetingCacheStore {
|
||||
defaultGreetingCache ??= openGreetingCache();
|
||||
return defaultGreetingCache;
|
||||
}
|
||||
|
||||
function openConfigAuditStore(env?: NodeJS.ProcessEnv): SystemAgentGreetingConfigAuditStore {
|
||||
return createSqliteAuditRecordStore<ConfigAuditRecord>({
|
||||
scope: CONFIG_AUDIT_SCOPE,
|
||||
maxEntries: CONFIG_AUDIT_MAX_ENTRIES,
|
||||
...(env ? { env } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function tryOr<T>(fallback: T, read: () => T): T {
|
||||
try {
|
||||
return read();
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function readGreetingCache(
|
||||
store: SystemAgentGreetingCacheStore,
|
||||
): SystemAgentGreetingCacheRecord | null {
|
||||
return store.latest({ limit: 1 })[0]?.value ?? null;
|
||||
}
|
||||
|
||||
function mutateGreetingState(
|
||||
store: SystemAgentGreetingCacheStore,
|
||||
mutate: (current: SystemAgentGreetingCacheRecord | null) => SystemAgentGreetingCacheRecord | null,
|
||||
createdAt = Date.now(),
|
||||
): void {
|
||||
for (let attempt = 0; attempt < GREETING_STATE_CAS_ATTEMPTS; attempt += 1) {
|
||||
const current = readGreetingCache(store);
|
||||
const next = mutate(current);
|
||||
if (
|
||||
next === current ||
|
||||
store.compareAndSet(SYSTEM_AGENT_GREETING_KEY, current, next, createdAt)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error("system-agent greeting state changed too often");
|
||||
}
|
||||
|
||||
function accountLooksDegraded(account: Record<string, unknown>): boolean {
|
||||
if (account.configured === false || account.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
const healthState =
|
||||
typeof account.healthState === "string" ? account.healthState.trim().toLowerCase() : "";
|
||||
const probe =
|
||||
account.probe && typeof account.probe === "object"
|
||||
? (account.probe as Record<string, unknown>)
|
||||
: null;
|
||||
return (
|
||||
(healthState !== "" && healthState !== "healthy") ||
|
||||
probe?.ok === false ||
|
||||
account.linked === false ||
|
||||
account.running === false ||
|
||||
(account.running === true && account.connected === false) ||
|
||||
(account.connected !== true &&
|
||||
typeof account.lastError === "string" &&
|
||||
account.lastError.trim().length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
/** Extract only degraded channel labels from the gateway's existing cached health aggregate. */
|
||||
export function systemAgentGreetingChannelHealth(
|
||||
health: HealthSummary | null,
|
||||
): SystemAgentGreetingFacts["channelHealth"] {
|
||||
if (!health) {
|
||||
return { available: false, degraded: [] };
|
||||
}
|
||||
const degraded = new Set<string>();
|
||||
for (const [channelId, channel] of Object.entries(health.channels)) {
|
||||
const accounts = channel.accounts ? Object.values(channel.accounts) : [channel];
|
||||
if (accounts.some((account) => accountLooksDegraded(account))) {
|
||||
degraded.add(health.channelLabels[channelId] ?? channelId);
|
||||
}
|
||||
}
|
||||
return { available: true, degraded: [...degraded].toSorted((a, b) => a.localeCompare(b)) };
|
||||
}
|
||||
|
||||
function readConfigAuditFacts(
|
||||
store: SystemAgentGreetingConfigAuditStore,
|
||||
lastSeenAuditSequence: number,
|
||||
): Pick<SystemAgentGreetingFacts, "auditSequence" | "recentExternalEdit"> {
|
||||
let auditSequence = 0;
|
||||
let beforeSequence: number | undefined;
|
||||
let recentExternalEdit = false;
|
||||
while (true) {
|
||||
const page = store.latest({
|
||||
limit: CONFIG_AUDIT_PAGE_SIZE,
|
||||
...(beforeSequence === undefined ? {} : { beforeSequence }),
|
||||
});
|
||||
if (beforeSequence === undefined) {
|
||||
auditSequence = page[0]?.sequence ?? 0;
|
||||
}
|
||||
if (page.length === 0) {
|
||||
break;
|
||||
}
|
||||
let reachedWatermark = false;
|
||||
for (const entry of page) {
|
||||
if (entry.sequence <= lastSeenAuditSequence) {
|
||||
reachedWatermark = true;
|
||||
break;
|
||||
}
|
||||
if (entry.value.event === "config.external") {
|
||||
recentExternalEdit = true;
|
||||
}
|
||||
}
|
||||
if (reachedWatermark || page.length < CONFIG_AUDIT_PAGE_SIZE) {
|
||||
break;
|
||||
}
|
||||
const nextBeforeSequence = page.at(-1)?.sequence;
|
||||
if (nextBeforeSequence === undefined || nextBeforeSequence === beforeSequence) {
|
||||
break;
|
||||
}
|
||||
beforeSequence = nextBeforeSequence;
|
||||
}
|
||||
return { auditSequence, recentExternalEdit };
|
||||
}
|
||||
|
||||
/** Read free facts from process/SQLite snapshots; this function never starts a probe. */
|
||||
export function loadSystemAgentGreetingFacts(
|
||||
opts: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
cacheStore?: SystemAgentGreetingCacheStore;
|
||||
openCache?: () => SystemAgentGreetingCacheStore;
|
||||
configAuditStore?: SystemAgentGreetingConfigAuditStore;
|
||||
getUpdateAvailable?: () => UpdateAvailable | null;
|
||||
getHealthCache?: () => HealthSummary | null;
|
||||
} = {},
|
||||
): SystemAgentGreetingFacts {
|
||||
// Facts stay best-effort: a broken snapshot source degrades that fact
|
||||
// instead of blocking the welcome.
|
||||
const cache = tryOr<SystemAgentGreetingCacheRecord | null>(null, () =>
|
||||
readGreetingCache(opts.cacheStore ?? opts.openCache?.() ?? openGreetingCache(opts.env)),
|
||||
);
|
||||
const auditFacts = tryOr({ auditSequence: 0, recentExternalEdit: false }, () =>
|
||||
readConfigAuditFacts(
|
||||
opts.configAuditStore ?? openConfigAuditStore(opts.env),
|
||||
cache?.lastSeenAuditSequence ?? 0,
|
||||
),
|
||||
);
|
||||
const update = tryOr<UpdateAvailable | null>(null, () =>
|
||||
(opts.getUpdateAvailable ?? getUpdateAvailable)(),
|
||||
);
|
||||
const health = tryOr<HealthSummary | null>(null, () => (opts.getHealthCache ?? getHealthCache)());
|
||||
return {
|
||||
updateAvailable: update?.latestVersion ?? null,
|
||||
channelHealth: systemAgentGreetingChannelHealth(health),
|
||||
...auditFacts,
|
||||
};
|
||||
}
|
||||
|
||||
/** SHA-256 over greeting decisions only; paths, errors, timestamps, and tool probes stay out. */
|
||||
export function systemAgentGreetingFactsHash(
|
||||
overview: SystemAgentOverview,
|
||||
facts: SystemAgentGreetingFacts,
|
||||
): string {
|
||||
const decisionFacts = {
|
||||
config: {
|
||||
exists: overview.config.exists,
|
||||
valid: overview.config.valid,
|
||||
},
|
||||
defaultAgentId: overview.defaultAgentId,
|
||||
defaultModel: overview.defaultModel ?? null,
|
||||
gateway: {
|
||||
reachable: overview.gateway.reachable,
|
||||
url: overview.gateway.url,
|
||||
},
|
||||
agents: overview.agents
|
||||
.map((agent) => ({
|
||||
id: agent.id,
|
||||
name: agent.name ?? null,
|
||||
isDefault: agent.isDefault,
|
||||
model: agent.model ?? null,
|
||||
}))
|
||||
.toSorted((a, b) => a.id.localeCompare(b.id)),
|
||||
updateAvailable: facts.updateAvailable,
|
||||
channelHealthAvailable: facts.channelHealth.available,
|
||||
degradedChannels: [...facts.channelHealth.degraded].toSorted((a, b) => a.localeCompare(b)),
|
||||
// recentExternalEdit is deliberately absent: its alert is host-appended at
|
||||
// delivery, so the cached model text stays valid across edit-flag flips.
|
||||
};
|
||||
return createHash("sha256").update(JSON.stringify(decisionFacts)).digest("hex");
|
||||
}
|
||||
|
||||
function normalizeGreetingText(text: string): string | null {
|
||||
const lines = text
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, SYSTEM_AGENT_GREETING_MAX_LINES);
|
||||
if (lines.length === 0 || lines.some((line) => /^#{1,6}\s/.test(line))) {
|
||||
return null;
|
||||
}
|
||||
// The prompt demands plain markdown lines; structured output must never be
|
||||
// cached (a single bad slot would replay on every welcome until facts change).
|
||||
// Every retained line is checked so a "Sure:" preamble cannot smuggle JSON.
|
||||
if (lines.some((line) => /^[[{]/.test(line) || line.startsWith("```"))) {
|
||||
return null;
|
||||
}
|
||||
return truncateUtf16Safe(lines.join("\n"), SYSTEM_AGENT_GREETING_MAX_CHARS).trim() || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The external-edit alert is host-owned: delivery acknowledges the audit
|
||||
* cursor, so a model phrasing miss would silently lose the notification.
|
||||
* Appending deterministically removes that class instead of validating it.
|
||||
*/
|
||||
export const SYSTEM_AGENT_EXTERNAL_EDIT_ALERT =
|
||||
"Heads up: the config was edited outside OpenClaw while I was away — open History to review it.";
|
||||
|
||||
function withHostOwnedAlerts(text: string, facts: SystemAgentGreetingFacts): string {
|
||||
if (!facts.recentExternalEdit) {
|
||||
return text;
|
||||
}
|
||||
return `${text}\n${SYSTEM_AGENT_EXTERNAL_EDIT_ALERT}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Positive-presence grounding only: exceptional facts the model was given must
|
||||
* appear in its text. Deliberately no negative-claim screening — keyword
|
||||
* blacklists false-reject phrasing like "no channels are degraded", and the
|
||||
* greeting is advisory chat text; chips, History, and health stay host-owned.
|
||||
* A hallucinated outage is bounded by the prompt, the 5-line cap, and template
|
||||
* fallback on the next facts change. Accepted tradeoff, not an oversight.
|
||||
*/
|
||||
function modelGreetingCoversFacts(text: string, facts: SystemAgentGreetingFacts): boolean {
|
||||
const normalized = text.toLocaleLowerCase();
|
||||
if (facts.updateAvailable && !normalized.includes(facts.updateAvailable.toLocaleLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
facts.channelHealth.degraded.some((label) => !normalized.includes(label.toLocaleLowerCase()))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!facts.channelHealth.available &&
|
||||
!(normalized.includes("channel health") && normalized.includes("unavailable"))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function requiresDeterministicGreeting(overview: SystemAgentOverview): boolean {
|
||||
return (
|
||||
!overview.config.exists ||
|
||||
!overview.config.valid ||
|
||||
!overview.defaultModel ||
|
||||
!overview.gateway.reachable
|
||||
);
|
||||
}
|
||||
|
||||
function formatSystemAgentGreetingFallback(
|
||||
overview: SystemAgentOverview,
|
||||
facts: SystemAgentGreetingFacts,
|
||||
): string {
|
||||
const alerts: string[] = [];
|
||||
if (facts.updateAvailable) {
|
||||
alerts.push(`Update ${facts.updateAvailable} is available.`);
|
||||
}
|
||||
if (facts.channelHealth.degraded.length > 0) {
|
||||
alerts.push(`Channels needing attention: ${facts.channelHealth.degraded.join(", ")}.`);
|
||||
} else if (!facts.channelHealth.available) {
|
||||
alerts.push("Channel health is not available yet.");
|
||||
}
|
||||
// recentExternalEdit is deliberately absent: withHostOwnedAlerts appends the
|
||||
// single canonical edit alert to every delivered greeting, template included.
|
||||
return [formatSystemAgentStartupMessage(overview), alerts.join(" ") || undefined]
|
||||
.filter((line): line is string => line !== undefined)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
async function withGreetingTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("system-agent greeting timed out")), timeoutMs);
|
||||
if (typeof timer === "object" && "unref" in timer) {
|
||||
timer.unref();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveUncachedSystemAgentGreeting(params: {
|
||||
overview: SystemAgentOverview;
|
||||
facts: SystemAgentGreetingFacts;
|
||||
planner: SystemAgentGreetingPlanner;
|
||||
cacheStore: SystemAgentGreetingCacheStore;
|
||||
factsHash: string;
|
||||
at: number;
|
||||
timeoutMs?: number;
|
||||
}): Promise<SystemAgentGreetingResolution> {
|
||||
const timeoutMs = params.timeoutMs ?? SYSTEM_AGENT_GREETING_TIMEOUT_MS;
|
||||
let plan: SystemAgentGreetingPlan | null = null;
|
||||
try {
|
||||
// This is the only metered greeting turn. The single-slot hash keeps unchanged
|
||||
// caretaker opens at zero tokens while preserving a model-free rescue path.
|
||||
plan = await withGreetingTimeout(
|
||||
params.planner({ overview: params.overview, facts: params.facts, timeoutMs }),
|
||||
timeoutMs,
|
||||
);
|
||||
} catch {
|
||||
plan = null;
|
||||
}
|
||||
const text = plan ? normalizeGreetingText(plan.text) : null;
|
||||
const groundedText = text && modelGreetingCoversFacts(text, params.facts) ? text : null;
|
||||
if (!groundedText || !plan?.modelRef.trim()) {
|
||||
// Keep provider outages cheap without writing a template into the model-greeting cache.
|
||||
greetingFailures.set(params.cacheStore, {
|
||||
factsHash: params.factsHash,
|
||||
retryAfter: params.at + SYSTEM_AGENT_GREETING_FAILURE_RETRY_MS,
|
||||
});
|
||||
return {
|
||||
text: formatSystemAgentGreetingFallback(params.overview, params.facts),
|
||||
source: "template",
|
||||
};
|
||||
}
|
||||
greetingFailures.delete(params.cacheStore);
|
||||
try {
|
||||
mutateGreetingState(
|
||||
params.cacheStore,
|
||||
(current) => {
|
||||
// A slower turn for old facts must not replace a newer system-state greeting.
|
||||
if (
|
||||
current?.factsHash &&
|
||||
current.factsHash !== params.factsHash &&
|
||||
(current.at ?? Number.NEGATIVE_INFINITY) >= params.at
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
lastSeenAuditSequence: current?.lastSeenAuditSequence ?? 0,
|
||||
factsHash: params.factsHash,
|
||||
text: groundedText,
|
||||
modelRef: plan.modelRef.trim(),
|
||||
at: params.at,
|
||||
};
|
||||
},
|
||||
params.at,
|
||||
);
|
||||
} catch {
|
||||
// Cache persistence is diagnostic-only; a successful greeting still wins.
|
||||
}
|
||||
return { text: groundedText, source: "model" };
|
||||
}
|
||||
|
||||
type ResolveSystemAgentGreetingParams = {
|
||||
overview: SystemAgentOverview;
|
||||
facts: SystemAgentGreetingFacts;
|
||||
planner: SystemAgentGreetingPlanner;
|
||||
/** False for internal session seeding: cache reads stay free and a miss uses the template. */
|
||||
allowInference?: boolean;
|
||||
cacheStore?: SystemAgentGreetingCacheStore;
|
||||
openCache?: () => SystemAgentGreetingCacheStore;
|
||||
now?: () => number;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export async function resolveSystemAgentGreeting(
|
||||
params: ResolveSystemAgentGreetingParams,
|
||||
): Promise<SystemAgentGreetingResolution> {
|
||||
const resolution = await resolveSystemAgentGreetingText(params);
|
||||
// Host-owned alerts append at delivery, never into the cache: the cached
|
||||
// model text must stay valid for deliveries where the fact is absent.
|
||||
return { ...resolution, text: withHostOwnedAlerts(resolution.text, params.facts) };
|
||||
}
|
||||
|
||||
async function resolveSystemAgentGreetingText(
|
||||
params: ResolveSystemAgentGreetingParams,
|
||||
): Promise<SystemAgentGreetingResolution> {
|
||||
if (requiresDeterministicGreeting(params.overview)) {
|
||||
// When the system is broken, precision beats personality: the rescue path
|
||||
// must neither depend on nor spend inference, and model text is never cached.
|
||||
return {
|
||||
text: formatSystemAgentGreetingFallback(params.overview, params.facts),
|
||||
source: "template",
|
||||
};
|
||||
}
|
||||
let cacheStore: SystemAgentGreetingCacheStore;
|
||||
try {
|
||||
cacheStore = params.cacheStore ?? params.openCache?.() ?? getDefaultGreetingCache();
|
||||
} catch {
|
||||
return {
|
||||
text: formatSystemAgentGreetingFallback(params.overview, params.facts),
|
||||
source: "template",
|
||||
};
|
||||
}
|
||||
const factsHash = systemAgentGreetingFactsHash(params.overview, params.facts);
|
||||
let cached: SystemAgentGreetingCacheRecord | null;
|
||||
try {
|
||||
cached = readGreetingCache(cacheStore);
|
||||
} catch {
|
||||
return {
|
||||
text: formatSystemAgentGreetingFallback(params.overview, params.facts),
|
||||
source: "template",
|
||||
};
|
||||
}
|
||||
if (
|
||||
typeof cached?.text === "string" &&
|
||||
cached.text.trim() &&
|
||||
cached.factsHash === factsHash &&
|
||||
typeof cached.modelRef === "string" &&
|
||||
typeof cached.at === "number"
|
||||
) {
|
||||
return { text: cached.text, source: "cache" };
|
||||
}
|
||||
if (params.allowInference === false) {
|
||||
return {
|
||||
text: formatSystemAgentGreetingFallback(params.overview, params.facts),
|
||||
source: "template",
|
||||
};
|
||||
}
|
||||
|
||||
const at = (params.now ?? Date.now)();
|
||||
// This timestamp orders competing model-cache writes; audit acknowledgement uses
|
||||
// the monotonic sequence captured with the facts instead of wall-clock time.
|
||||
const failure = greetingFailures.get(cacheStore);
|
||||
if (failure?.factsHash === factsHash && failure.retryAfter > at) {
|
||||
return {
|
||||
text: formatSystemAgentGreetingFallback(params.overview, params.facts),
|
||||
source: "template",
|
||||
};
|
||||
}
|
||||
if (failure && failure.retryAfter <= at) {
|
||||
greetingFailures.delete(cacheStore);
|
||||
}
|
||||
let flights = greetingFlights.get(cacheStore);
|
||||
if (!flights) {
|
||||
flights = new Map();
|
||||
greetingFlights.set(cacheStore, flights);
|
||||
}
|
||||
const existingFlight = flights.get(factsHash);
|
||||
if (existingFlight) {
|
||||
return existingFlight;
|
||||
}
|
||||
const flight = resolveUncachedSystemAgentGreeting({
|
||||
...params,
|
||||
cacheStore,
|
||||
factsHash,
|
||||
at,
|
||||
});
|
||||
flights.set(factsHash, flight);
|
||||
try {
|
||||
return await flight;
|
||||
} finally {
|
||||
if (flights.get(factsHash) === flight) {
|
||||
flights.delete(factsHash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the config-audit cursor only after the host has delivered the greeting. */
|
||||
export function acknowledgeSystemAgentGreetingDelivery(params: {
|
||||
auditSequence: number;
|
||||
cacheStore?: SystemAgentGreetingCacheStore;
|
||||
openCache?: () => SystemAgentGreetingCacheStore;
|
||||
now?: () => number;
|
||||
}): void {
|
||||
if (!Number.isSafeInteger(params.auditSequence) || params.auditSequence < 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const cacheStore = params.cacheStore ?? params.openCache?.() ?? getDefaultGreetingCache();
|
||||
mutateGreetingState(
|
||||
cacheStore,
|
||||
(current) => {
|
||||
const lastSeenAuditSequence = Math.max(
|
||||
current?.lastSeenAuditSequence ?? 0,
|
||||
params.auditSequence,
|
||||
);
|
||||
if (current?.lastSeenAuditSequence === lastSeenAuditSequence) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
lastSeenAuditSequence,
|
||||
};
|
||||
},
|
||||
(params.now ?? Date.now)(),
|
||||
);
|
||||
} catch {
|
||||
// Delivery wins even when diagnostic acknowledgement cannot be persisted.
|
||||
}
|
||||
}
|
||||
|
||||
function addQuickAction(
|
||||
options: SystemAgentChatQuestion["options"],
|
||||
option: SystemAgentChatQuestion["options"][number],
|
||||
): void {
|
||||
if (options.length >= 4 || options.some((candidate) => candidate.reply === option.reply)) {
|
||||
return;
|
||||
}
|
||||
options.push(option);
|
||||
}
|
||||
|
||||
/** Quick actions are host-derived so model wording can never invent executable replies. */
|
||||
export function buildSystemAgentGreetingQuestion(
|
||||
overview: SystemAgentOverview,
|
||||
facts: SystemAgentGreetingFacts,
|
||||
): SystemAgentChatQuestion {
|
||||
const exceptional: SystemAgentChatQuestion["options"] = [];
|
||||
if (!overview.config.exists) {
|
||||
addQuickAction(exceptional, { label: "Set up OpenClaw", reply: "setup" });
|
||||
} else if (!overview.config.valid) {
|
||||
addQuickAction(exceptional, { label: "Inspect config", reply: "doctor" });
|
||||
} else if (!overview.defaultModel) {
|
||||
// A valid config without verified inference cannot hand off to an agent;
|
||||
// setup is the canonical path to establish a model.
|
||||
addQuickAction(exceptional, { label: "Set up inference", reply: "setup" });
|
||||
}
|
||||
if (!overview.gateway.reachable) {
|
||||
addQuickAction(exceptional, { label: "Run gateway status", reply: "gateway status" });
|
||||
addQuickAction(exceptional, { label: "Restart gateway", reply: "restart gateway" });
|
||||
}
|
||||
if (!facts.channelHealth.available || facts.channelHealth.degraded.length > 0) {
|
||||
addQuickAction(exceptional, { label: "Check channel health", reply: "health" });
|
||||
}
|
||||
if (facts.updateAvailable) {
|
||||
addQuickAction(exceptional, { label: "Show update", reply: "status" });
|
||||
}
|
||||
// Keep History and agent handoff reachable even when several exceptional facts compete
|
||||
// for the schema's four slots. The greeting itself still names every exceptional fact.
|
||||
const options = exceptional.slice(0, 2);
|
||||
// Without a model the handoff chip would advertise a dead action; the
|
||||
// no-model branch above already routes users to setup instead.
|
||||
if (overview.defaultModel) {
|
||||
addQuickAction(options, {
|
||||
label: "Talk to my agent",
|
||||
reply: "talk to agent",
|
||||
recommended:
|
||||
exceptional.length === 0 &&
|
||||
!facts.recentExternalEdit &&
|
||||
facts.channelHealth.available &&
|
||||
overview.config.exists &&
|
||||
overview.config.valid &&
|
||||
overview.gateway.reachable,
|
||||
});
|
||||
}
|
||||
addQuickAction(options, {
|
||||
label: facts.recentExternalEdit ? "Review recent changes" : "Show recent changes",
|
||||
reply: "audit",
|
||||
});
|
||||
return {
|
||||
id: "system-agent-quick-actions",
|
||||
header: "Quick actions",
|
||||
question: "What would you like me to do?",
|
||||
options,
|
||||
};
|
||||
}
|
||||
@@ -101,10 +101,10 @@ describe("loadSystemAgentOverview", () => {
|
||||
'Next: run "gateway status" or "restart gateway"',
|
||||
);
|
||||
const startup = formatSystemAgentStartupMessage(overview);
|
||||
expect(startup).toContain("## Hi, I'm OpenClaw.");
|
||||
expect(startup).toContain("Using: openai/gpt-5.2");
|
||||
expect(startup).toContain("Hi, I'm OpenClaw — caretaker");
|
||||
expect(startup).toContain("Model: openai/gpt-5.2");
|
||||
expect(startup).toContain("Gateway: not reachable");
|
||||
expect(startup).toContain("I can start debugging with `gateway status`");
|
||||
expect(startup).not.toContain("`gateway status`");
|
||||
expect(startup).not.toContain("Codex:");
|
||||
expect(startup).not.toContain("Claude Code:");
|
||||
expect(startup).not.toContain("API keys:");
|
||||
@@ -117,9 +117,9 @@ describe("loadSystemAgentOverview", () => {
|
||||
expect(formatSystemAgentOverview(overview)).toContain(
|
||||
'Next: run "openclaw onboard" to establish inference',
|
||||
);
|
||||
expect(startup).toContain("Inference unavailable");
|
||||
expect(startup).toContain("run `openclaw onboard`");
|
||||
expect(startup).toContain("OpenClaw needs working inference");
|
||||
expect(startup).toContain("Inference is unavailable");
|
||||
expect(startup).toContain("Run `openclaw onboard`");
|
||||
expect(startup.match(/`[^`]+`/g)).toEqual(["`openclaw onboard`"]);
|
||||
expect(startup).not.toContain("local Claude Code/Codex/Gemini login");
|
||||
expect(startup).not.toContain("typed commands as last resort");
|
||||
});
|
||||
|
||||
@@ -304,13 +304,6 @@ function formatStartupConfigStatus(overview: SystemAgentOverview): string {
|
||||
return overview.config.valid ? "valid" : "invalid";
|
||||
}
|
||||
|
||||
function formatStartupUse(overview: SystemAgentOverview): string {
|
||||
if (overview.defaultModel) {
|
||||
return `Using: ${overview.defaultModel} — just tell me what you want.`;
|
||||
}
|
||||
return "Inference unavailable: run `openclaw onboard` and complete a live model check first.";
|
||||
}
|
||||
|
||||
function formatStartupGatewayStatus(overview: SystemAgentOverview): string {
|
||||
if (overview.gateway.reachable) {
|
||||
return `Gateway: reachable at ${overview.gateway.url}.`;
|
||||
@@ -318,20 +311,14 @@ function formatStartupGatewayStatus(overview: SystemAgentOverview): string {
|
||||
return `Gateway: not reachable at ${overview.gateway.url}; I already did the first probe.`;
|
||||
}
|
||||
|
||||
function formatStartupAction(overview: SystemAgentOverview): string {
|
||||
function formatStartupAction(overview: SystemAgentOverview): string | undefined {
|
||||
if (!overview.config.valid) {
|
||||
return "I can start debugging with `validate config` or `doctor`.";
|
||||
return "Config needs attention. Run `doctor` to inspect it.";
|
||||
}
|
||||
if (!overview.defaultModel) {
|
||||
return "OpenClaw needs working inference before it can help with the rest of setup.";
|
||||
return "Inference is unavailable. Run `openclaw onboard` and complete a live model check.";
|
||||
}
|
||||
if (!overview.config.exists) {
|
||||
return "Run `openclaw onboard` to establish inference before starting OpenClaw.";
|
||||
}
|
||||
if (!overview.gateway.reachable) {
|
||||
return "I can start debugging with `gateway status`, or queue `restart gateway` for approval.";
|
||||
}
|
||||
return "Everything basic is reachable. Use `talk to agent` when you want the normal agent.";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -357,13 +344,14 @@ export function formatSystemAgentStartupMessage(overview: SystemAgentOverview):
|
||||
? `${overview.defaultAgentId} (${agent.name})`
|
||||
: overview.defaultAgentId;
|
||||
return [
|
||||
"## Hi, I'm OpenClaw.",
|
||||
"",
|
||||
"- Start me when setup, config, Gateway, model choice, or agent routing feels off.",
|
||||
`- ${formatStartupUse(overview)}`,
|
||||
`- Config: ${formatStartupConfigStatus(overview)}. Default agent: ${agentLabel}.`,
|
||||
`- ${formatStartupGatewayStatus(overview)}`,
|
||||
"",
|
||||
"Hi, I'm OpenClaw — caretaker of this gateway, config, channels, and agents.",
|
||||
// Inference status stays independent of the recovery action line: with an
|
||||
// invalid config AND no model, both problems must be visible.
|
||||
overview.defaultModel ? `Model: ${overview.defaultModel}.` : "Inference is unavailable.",
|
||||
`Config: ${formatStartupConfigStatus(overview)}. Default agent: ${agentLabel}.`,
|
||||
formatStartupGatewayStatus(overview),
|
||||
formatStartupAction(overview),
|
||||
].join("\n");
|
||||
]
|
||||
.filter((line): line is string => line !== undefined)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
@@ -607,6 +607,40 @@ describe("custodian page", () => {
|
||||
expect(request.mock.calls[1]?.[1]).toMatchObject({ message: "status" });
|
||||
});
|
||||
|
||||
it("renders and sends quick actions on the normal caretaker welcome", async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
sessionId: "control-ui-caretaker-00000000-0000-4000-8000-000000000001",
|
||||
reply: "I'm OpenClaw. All systems nominal.",
|
||||
action: "none",
|
||||
question: {
|
||||
id: "system-agent-quick-actions",
|
||||
header: "Quick actions",
|
||||
question: "What would you like me to do?",
|
||||
options: [
|
||||
{ label: "Talk to my agent", reply: "talk to agent", recommended: true },
|
||||
{ label: "Show recent changes", reply: "audit" },
|
||||
],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
sessionId: "control-ui-caretaker-00000000-0000-4000-8000-000000000001",
|
||||
reply: "Here's the audit state.",
|
||||
action: "none",
|
||||
});
|
||||
const { context } = createContext(request);
|
||||
const { page } = await mountPage(context, { onboarding: false });
|
||||
await waitForFast(() => expect(request).toHaveBeenCalledOnce());
|
||||
await page.updateComplete;
|
||||
|
||||
page.querySelector<HTMLButtonElement>('[data-option-value="Show recent changes"]')!.click();
|
||||
|
||||
await waitForFast(() => expect(request).toHaveBeenCalledTimes(2));
|
||||
expect(request.mock.calls[1]?.[1]).toMatchObject({ message: "audit" });
|
||||
expect(request.mock.calls[1]?.[1]).not.toHaveProperty("welcomeVariant");
|
||||
});
|
||||
|
||||
it("starts a fresh welcome when onboarding mode changes", async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
|
||||
Reference in New Issue
Block a user