import { describe, expect, it } from "vitest"; import type { AgentState, AgentStoreSeed } from "@/features/agents/state/store"; import { createTranscriptEntryFromLine } from "@/features/agents/state/transcript"; import { buildOfficeAnimationState, clearOfficeAnimationTriggerHold, createOfficeAnimationTriggerState, reconcileOfficeAnimationTriggerState, reduceOfficeAnimationTriggerEvent, } from "@/lib/office/eventTriggers"; import type { EventFrame } from "@/lib/gateway/GatewayClient"; const makeAgent = ( overrides: Partial & Pick, ): AgentState => ({ agentId: overrides.agentId, name: overrides.name, sessionKey: overrides.sessionKey, avatarSeed: overrides.avatarSeed ?? overrides.agentId, avatarUrl: overrides.avatarUrl ?? null, model: overrides.model ?? null, thinkingLevel: overrides.thinkingLevel ?? "high", sessionExecHost: overrides.sessionExecHost, sessionExecSecurity: overrides.sessionExecSecurity, sessionExecAsk: overrides.sessionExecAsk, status: overrides.status ?? "idle", sessionCreated: overrides.sessionCreated ?? false, awaitingUserInput: overrides.awaitingUserInput ?? false, hasUnseenActivity: overrides.hasUnseenActivity ?? false, outputLines: overrides.outputLines ?? [], lastResult: overrides.lastResult ?? null, lastDiff: overrides.lastDiff ?? null, runId: overrides.runId ?? null, runStartedAt: overrides.runStartedAt ?? null, streamText: overrides.streamText ?? null, thinkingTrace: overrides.thinkingTrace ?? null, latestOverride: overrides.latestOverride ?? null, latestOverrideKind: overrides.latestOverrideKind ?? null, lastAssistantMessageAt: overrides.lastAssistantMessageAt ?? null, lastActivityAt: overrides.lastActivityAt ?? null, latestPreview: overrides.latestPreview ?? null, lastUserMessage: overrides.lastUserMessage ?? null, draft: overrides.draft ?? "", queuedMessages: overrides.queuedMessages ?? [], sessionSettingsSynced: overrides.sessionSettingsSynced ?? false, historyLoadedAt: overrides.historyLoadedAt ?? null, historyFetchLimit: overrides.historyFetchLimit ?? null, historyFetchedCount: overrides.historyFetchedCount ?? null, historyMaybeTruncated: overrides.historyMaybeTruncated ?? false, toolCallingEnabled: overrides.toolCallingEnabled ?? false, showThinkingTraces: overrides.showThinkingTraces ?? true, transcriptEntries: overrides.transcriptEntries ?? [], transcriptRevision: overrides.transcriptRevision ?? 0, transcriptSequenceCounter: overrides.transcriptSequenceCounter ?? 0, sessionEpoch: overrides.sessionEpoch ?? 0, lastHistoryRequestRevision: overrides.lastHistoryRequestRevision ?? null, lastAppliedHistoryRequestId: overrides.lastAppliedHistoryRequestId ?? null, }); const makeTranscriptEntry = (params: { line: string; role: "user" | "assistant"; sequenceKey: number; sessionKey: string; timestampMs: number; }) => { const entry = createTranscriptEntryFromLine({ line: params.line, sessionKey: params.sessionKey, source: "history", sequenceKey: params.sequenceKey, timestampMs: params.timestampMs, fallbackTimestampMs: params.timestampMs, role: params.role, kind: params.role === "user" ? "user" : "assistant", confirmed: true, }); if (!entry) { throw new Error("Failed to create transcript entry."); } return entry; }; describe("office event triggers", () => { it("derives room holds from agent messages", () => { const agents = [ makeAgent({ agentId: "main", name: "Main", sessionKey: "agent:main:main", lastUserMessage: "Check GitHub for pull requests.", }), makeAgent({ agentId: "qa", name: "QA", sessionKey: "agent:qa:main", lastUserMessage: "Please test this build in the QA lab.", }), makeAgent({ agentId: "skill", name: "Skill", sessionKey: "agent:skill:main", lastUserMessage: "Build another OpenClaw skill.", status: "running", runId: "run-skill", }), ]; const state = reconcileOfficeAnimationTriggerState({ state: createOfficeAnimationTriggerState(), agents, nowMs: 1_000, }); const animationState = buildOfficeAnimationState({ state, agents, nowMs: 1_000, }); expect(animationState.githubHoldByAgentId.main).toBe(true); expect(animationState.qaHoldByAgentId.qa).toBe(true); expect(animationState.skillGymHoldByAgentId.skill).toBe(true); }); it("reacts to runtime chat commands regardless of channel transport", () => { const agents = [ makeAgent({ agentId: "main", name: "Main", sessionKey: "agent:main:main", }), makeAgent({ agentId: "worker", name: "Worker", sessionKey: "agent:worker:slack", }), ]; const gymEvent: EventFrame = { type: "event", event: "chat", payload: { runId: "run-worker", sessionKey: "agent:worker:slack", state: "final", message: { role: "user", text: "Let's go to the gym.", }, }, }; const standupEvent: EventFrame = { type: "event", event: "chat", payload: { runId: "run-main", sessionKey: "agent:main:main", state: "final", message: { role: "user", text: "Start the standup meeting.", }, }, }; const afterGym = reduceOfficeAnimationTriggerEvent({ state: createOfficeAnimationTriggerState(), agents, event: gymEvent, nowMs: 5_000, }); const afterStandup = reduceOfficeAnimationTriggerEvent({ state: afterGym, agents, event: standupEvent, nowMs: 6_000, }); expect(afterStandup.manualGymUntilByAgentId.worker).toBeGreaterThan(5_000); expect(afterStandup.pendingStandupRequest?.message).toBe( "Start the standup meeting.", ); }); it("treats final transport chat messages without an explicit user role as commands", () => { const agents = [ makeAgent({ agentId: "worker", name: "Worker", sessionKey: "agent:worker:main", }), ]; const afterDesk = reduceOfficeAnimationTriggerEvent({ state: createOfficeAnimationTriggerState(), agents, nowMs: 7_000, event: { type: "event", event: "chat", payload: { runId: "run-worker-telegram", sessionKey: "agent:worker:telegram", state: "final", message: { text: "Please head to your desk now.", }, }, }, }); const afterGym = reduceOfficeAnimationTriggerEvent({ state: afterDesk, agents, nowMs: 8_000, event: { type: "event", event: "chat", payload: { runId: "run-worker-telegram", sessionKey: "agent:worker:telegram", state: "final", message: { text: "Go to the gym.", }, }, }, }); expect(afterGym.deskHoldByAgentId.worker).toBe(true); expect(afterGym.manualGymUntilByAgentId.worker).toBeGreaterThan(8_000); }); it("tracks streaming and reasoning activity from runtime events", () => { const agents = [ makeAgent({ agentId: "agent-1", name: "Agent 1", sessionKey: "agent:agent-1:web", }), ]; const afterChat = reduceOfficeAnimationTriggerEvent({ state: createOfficeAnimationTriggerState(), agents, nowMs: 10_000, event: { type: "event", event: "chat", payload: { runId: "run-1", sessionKey: "agent:agent-1:web", state: "delta", message: { role: "assistant", text: "Streaming reply.", }, }, }, }); const afterReasoning = reduceOfficeAnimationTriggerEvent({ state: afterChat, agents, nowMs: 10_100, event: { type: "event", event: "agent", payload: { runId: "run-1", sessionKey: "agent:agent-1:web", stream: "reasoning_trace", data: { text: "Thinking about a plan.", }, }, }, }); const animationState = buildOfficeAnimationState({ state: afterReasoning, agents, nowMs: 10_150, }); expect(animationState.streamingByAgentId["agent-1"]).toBe(true); expect(animationState.thinkingByAgentId["agent-1"]).toBe(true); expect(animationState.workingUntilByAgentId["agent-1"]).toBeGreaterThan(10_000); }); it("suppresses desk holds while a gym command is active", () => { const agents = [ makeAgent({ agentId: "agent-1", name: "Agent 1", sessionKey: "agent:agent-1:main", }), ]; const animationState = buildOfficeAnimationState({ agents, nowMs: 11_000, state: { ...createOfficeAnimationTriggerState(), deskHoldByAgentId: { "agent-1": true }, manualGymUntilByAgentId: { "agent-1": 12_000 }, }, }); expect(animationState.gymHoldByAgentId["agent-1"]).toBe(true); expect(animationState.deskHoldByAgentId["agent-1"]).toBeUndefined(); }); it("suppresses dismissed github holds until a new command arrives and emits cleanup cues", () => { const baseAgent = makeAgent({ agentId: "main", name: "Main", sessionKey: "agent:main:main", lastUserMessage: "Check GitHub for pull requests.", sessionEpoch: 1, }); const initialState = reconcileOfficeAnimationTriggerState({ state: createOfficeAnimationTriggerState(), agents: [baseAgent], nowMs: 20_000, }); const dismissedState = clearOfficeAnimationTriggerHold({ state: initialState, hold: "github", agentId: "main", }); const sameMessageState = reconcileOfficeAnimationTriggerState({ state: dismissedState, agents: [baseAgent], nowMs: 20_500, }); const nextMessageState = reconcileOfficeAnimationTriggerState({ state: sameMessageState, agents: [ makeAgent({ ...baseAgent, lastUserMessage: "Review some code in the server room.", sessionEpoch: 2, }), ], nowMs: 21_000, }); expect(sameMessageState.githubHoldByAgentId.main).toBeUndefined(); expect(nextMessageState.githubHoldByAgentId.main).toBe(true); expect(nextMessageState.cleaningCues).toHaveLength(1); expect(nextMessageState.cleaningCues[0]?.agentId).toBe("main"); }); it("does not restore completed messaging booth requests from transcript history", () => { const sessionKey = "agent:main:main"; const agents = [ makeAgent({ agentId: "main", name: "Main", sessionKey, lastUserMessage: "Text my wife that I am running late.", transcriptEntries: [ makeTranscriptEntry({ line: "Text my wife that I am running late.", role: "user", sequenceKey: 1, sessionKey, timestampMs: 40_000, }), makeTranscriptEntry({ line: "[messaging booth] Message to my wife sent.", role: "assistant", sequenceKey: 2, sessionKey, timestampMs: 41_000, }), ], }), ]; const state = reconcileOfficeAnimationTriggerState({ state: createOfficeAnimationTriggerState(), agents, nowMs: 45_000, }); const animationState = buildOfficeAnimationState({ state, agents, nowMs: 45_000, }); expect(state.textMessageByAgentId.main).toBeUndefined(); expect(animationState.smsBoothHoldByAgentId.main).toBeUndefined(); }); it("does not restore stale messaging booth requests from old transcript history", () => { const sessionKey = "agent:main:main"; const agents = [ makeAgent({ agentId: "main", name: "Main", sessionKey, lastUserMessage: "Text my wife.", transcriptEntries: [ makeTranscriptEntry({ line: "Text my wife.", role: "user", sequenceKey: 1, sessionKey, timestampMs: 10_000, }), ], }), ]; const state = reconcileOfficeAnimationTriggerState({ state: createOfficeAnimationTriggerState(), agents, nowMs: 200_000, }); const animationState = buildOfficeAnimationState({ state, agents, nowMs: 200_000, }); expect(state.textMessageByAgentId.main).toBeUndefined(); expect(animationState.textMessageByAgentId.main).toBeUndefined(); expect(animationState.smsBoothHoldByAgentId.main).toBeUndefined(); }); });