a997f13601
* feat(kanban): add Kanban board with task-manager skill, modal UI, and desk clutter Implement a full Kanban board system for tracking agent tasks: - Add task-manager skill with shared JSON task store for persistence - Render board as a floating modal over the live 3D office (not immersive) - Auto-create tasks from actionable user messages with heuristic filtering - Sync task status through OpenClaw agent lifecycle events - Collapse task details panel by default, expand on card click - Add dynamic desk clutter (papers, folders, etc.) reflecting active task count - Exclude done tasks from desk clutter count - Extract KANBAN_CLUTTER_OFFSET for easy positioning adjustment - Add install flow with progress bar for the task-manager skill - Include unit and e2e test coverage Made-with: Cursor * feat(kanban): production-harden task board with AI-free classification, resilient persistence, and modal UX - Harden shared task store with atomic writes, payload size limits, and server-side enum validation - Add client resilience: request timeouts (AbortController), exponential backoff retries, poll deduplication - Implement optimistic UI with rollback on all card mutations (update, move, archive) - Add modal accessibility: focus trap, Escape to close, aria-modal, keyboard card navigation - Trust OpenClaw agent lifecycle phase=start as task classification signal instead of regex heuristics - Keep regex heuristic only as lightweight filter for direct chat events (conversational noise) - Expand verb recognition with typo tolerance and broader action vocabulary - Create tasks from agent runs even when no chat event is received (external channel support) - Merge dual header bars into single bar; reposition close button outside modal corner - Exclude done tasks from desk clutter count; make clutter position configurable via KANBAN_CLUTTER_OFFSET - Update default furniture layout to match user configuration - Ensure kanban_board furniture persists in local storage across sessions - Add comprehensive test coverage for store, API route, and controller logic Made-with: Cursor --------- Co-authored-by: iamlukethedev <lucas.guilherme@smartwayslfl.com>
460 lines
13 KiB
TypeScript
460 lines
13 KiB
TypeScript
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<AgentState> & Pick<AgentStoreSeed, "agentId" | "name" | "sessionKey">,
|
|
): 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("does not replay standup commands from old transcript history", () => {
|
|
const sessionKey = "agent:main:main";
|
|
const agents = [
|
|
makeAgent({
|
|
agentId: "main",
|
|
name: "Main",
|
|
sessionKey,
|
|
lastUserMessage: "Start the standup meeting.",
|
|
transcriptEntries: [
|
|
makeTranscriptEntry({
|
|
line: "Start the standup meeting.",
|
|
role: "user",
|
|
sequenceKey: 1,
|
|
sessionKey,
|
|
timestampMs: 10_000,
|
|
}),
|
|
],
|
|
}),
|
|
];
|
|
|
|
const state = reconcileOfficeAnimationTriggerState({
|
|
state: createOfficeAnimationTriggerState(),
|
|
agents,
|
|
nowMs: 100_000,
|
|
});
|
|
|
|
expect(state.pendingStandupRequest).toBeNull();
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|