diff --git a/docs/discussion-8-backlog.md b/docs/discussion-8-backlog.md new file mode 100644 index 0000000..4735c74 --- /dev/null +++ b/docs/discussion-8-backlog.md @@ -0,0 +1,30 @@ +# Discussion #8 follow-up backlog. + +This file captures the remaining items from the GitHub discussion so they stay visible after the first implementation pass. + +## Done in this branch. + +- Multi-agent visibility improvements in the office HUD. +- Config-driven office state mapping for local and remote agent presence. + +## Remaining items. + +### 3. Mobile-first office UX. + +- Improve touch-first navigation for `/office`, especially camera movement and chat access on narrow screens. +- Verify iOS Safari behavior with a dedicated pass on touch gestures and viewport-safe overlays. + +### 4. Sound and event cues. + +- Add lightweight operational audio cues for important office events. +- Keep cues configurable so operators can mute or tailor them per environment. + +### 5. Enterprise auth integration guidance. + +- Document `oauth2-proxy` and Entra/OIDC deployment patterns around `STUDIO_ACCESS_TOKEN` and the `studio_access` cookie contract. +- Re-evaluate whether built-in OIDC is needed after documentation and operator feedback. + +### 6. External event webhooks. + +- Define an event-ingress API for external systems to trigger office reactions. +- Decide on payload schema, authentication, and rate-limiting before adding visual/audio reactions. diff --git a/src/features/office/components/panels/SettingsPanel.tsx b/src/features/office/components/panels/SettingsPanel.tsx index 8f58ab6..f6ef049 100644 --- a/src/features/office/components/panels/SettingsPanel.tsx +++ b/src/features/office/components/panels/SettingsPanel.tsx @@ -1,6 +1,11 @@ "use client"; import { useState } from "react"; +import { + OFFICE_RENDERABLE_AGENT_STATUSES, + type OfficeAgentStateMapping, + type OfficeRenderableAgentStatus, +} from "@/lib/office/agentStateMapping"; import { CURATED_ELEVENLABS_VOICES } from "@/lib/voiceReply/catalog"; import type { StudioGatewayAdapterType } from "@/lib/studio/settings"; @@ -19,6 +24,10 @@ type SettingsPanelProps = { officeTitle: string; officeTitleLoaded: boolean; onOfficeTitleChange: (title: string) => void; + agentStateMapping: OfficeAgentStateMapping; + onAgentStateMappingChange: ( + next: Partial, + ) => void; remoteOfficeEnabled: boolean; remoteOfficeSourceKind: "presence_endpoint" | "openclaw_gateway"; remoteOfficeLabel: string; @@ -56,6 +65,8 @@ export function SettingsPanel({ officeTitle, officeTitleLoaded, onOfficeTitleChange, + agentStateMapping, + onAgentStateMappingChange, remoteOfficeEnabled, remoteOfficeSourceKind, remoteOfficeLabel, @@ -90,6 +101,36 @@ export function SettingsPanel({ selectedAdapterType === "demo" || selectedAdapterType === "custom"; const [remoteOfficeTokenDraft, setRemoteOfficeTokenDraft] = useState(""); + const renderableStateOptions = OFFICE_RENDERABLE_AGENT_STATUSES; + + const renderMappingSelect = ( + params: { + label: string; + value: OfficeRenderableAgentStatus; + onChange: (value: OfficeRenderableAgentStatus) => void; + description: string; + }, + ) => ( + + ); return (
@@ -402,6 +443,99 @@ export function SettingsPanel({ )}
+
+
+
+
Office state mapping
+
+ Decide how local runtime status and remote presence states appear in the + office. +
+
+ + Config + +
+
+
+
+ Local agents +
+
+ {renderMappingSelect({ + label: "Idle signal", + value: agentStateMapping.local.idle, + onChange: (value) => + onAgentStateMappingChange({ + local: { idle: value }, + }), + description: "Used when a local agent is idle and has no active run.", + })} + {renderMappingSelect({ + label: "Running signal", + value: agentStateMapping.local.running, + onChange: (value) => + onAgentStateMappingChange({ + local: { running: value }, + }), + description: "Used when a local agent is running or latched as active.", + })} + {renderMappingSelect({ + label: "Error signal", + value: agentStateMapping.local.error, + onChange: (value) => + onAgentStateMappingChange({ + local: { error: value }, + }), + description: "Used when a local agent reports an error.", + })} +
+
+
+
+ Remote office +
+
+ {renderMappingSelect({ + label: "Idle state", + value: agentStateMapping.remote.idle, + onChange: (value) => + onAgentStateMappingChange({ + remote: { idle: value }, + }), + description: "Used when the remote office reports an idle agent.", + })} + {renderMappingSelect({ + label: "Working state", + value: agentStateMapping.remote.working, + onChange: (value) => + onAgentStateMappingChange({ + remote: { working: value }, + }), + description: "Used when the remote office reports active work.", + })} + {renderMappingSelect({ + label: "Meeting state", + value: agentStateMapping.remote.meeting, + onChange: (value) => + onAgentStateMappingChange({ + remote: { meeting: value }, + }), + description: "Used when the remote office reports meeting activity.", + })} + {renderMappingSelect({ + label: "Error state", + value: agentStateMapping.remote.error, + onChange: (value) => + onAgentStateMappingChange({ + remote: { error: value }, + }), + description: "Used when the remote office reports an error state.", + })} +
+
+
+
diff --git a/src/features/office/screens/OfficeScreen.tsx b/src/features/office/screens/OfficeScreen.tsx index 237dea8..121cd9d 100644 --- a/src/features/office/screens/OfficeScreen.tsx +++ b/src/features/office/screens/OfficeScreen.tsx @@ -114,6 +114,12 @@ import { } from "@/lib/agents/personalityBuilder"; import { writeGatewayAgentFiles } from "@/lib/gateway/agentFiles"; import { randomUUID } from "@/lib/uuid"; +import { + DEFAULT_OFFICE_AGENT_STATE_MAPPING, + resolveLocalOfficeRenderableStatus, + resolveRemoteOfficeRenderableStatus, + type OfficeAgentStateMapping, +} from "@/lib/office/agentStateMapping"; import { HQSidebar, type HQSidebarTab, @@ -508,41 +514,34 @@ const getDeterministicItem = (id: string) => { return ITEMS[Math.abs(hash) % ITEMS.length]; }; -const mapAgentToOffice = (agent: AgentState): OfficeAgent => { - if (agent.status === "error") { - return { - id: agent.agentId, - name: agent.name || "Unknown", - subtitle: agent.role ?? null, - status: "error", - color: stringToColor(agent.agentId), - item: getDeterministicItem(agent.agentId), - avatarProfile: agent.avatarProfile ?? null, - }; - } - const isWorking = agent.status === "running" || Boolean(agent.runId); +const mapAgentToOffice = ( + agent: AgentState, + mapping: OfficeAgentStateMapping = DEFAULT_OFFICE_AGENT_STATE_MAPPING, +): OfficeAgent => { return { id: agent.agentId, name: agent.name || "Unknown", subtitle: agent.role ?? null, - status: isWorking ? "working" : "idle", + status: resolveLocalOfficeRenderableStatus(agent, mapping), color: stringToColor(agent.agentId), item: getDeterministicItem(agent.agentId), avatarProfile: agent.avatarProfile ?? null, }; }; -const mapRemotePresenceAgentToOffice = (agent: { +const mapRemotePresenceAgentToOffice = ( + agent: { agentId: string; name: string; state: "idle" | "working" | "meeting" | "error"; -}): OfficeAgent => { + }, + mapping: OfficeAgentStateMapping = DEFAULT_OFFICE_AGENT_STATE_MAPPING, +): OfficeAgent => { const stableId = `remote:${agent.agentId}`; - const isWorking = agent.state === "working" || agent.state === "meeting"; return { id: stableId, name: agent.name || "Unknown", - status: agent.state === "error" ? "error" : isWorking ? "working" : "idle", + status: resolveRemoteOfficeRenderableStatus(agent.state, mapping), color: stringToColor(stableId), item: getDeterministicItem(stableId), avatarProfile: null, @@ -1063,6 +1062,7 @@ export function OfficeScreen({ const { loaded: officeTitleLoaded, title: officeTitle, + agentStateMapping, remoteOfficeEnabled, remoteOfficeSourceKind, remoteOfficeLabel, @@ -1070,6 +1070,7 @@ export function OfficeScreen({ remoteOfficeGatewayUrl, remoteOfficeTokenConfigured, setTitle: setOfficeTitle, + setAgentStateMapping, setRemoteOfficeEnabled, setRemoteOfficeSourceKind, setRemoteOfficeLabel, @@ -3762,7 +3763,7 @@ export function OfficeScreen({ : `desk-hold-${agent.agentId}`), } : agent; - const officeAgent = mapAgentToOffice(effectiveAgent); + const officeAgent = mapAgentToOffice(effectiveAgent, agentStateMapping); nextCache.set(agent.agentId, { agent, deskHeld, @@ -3786,6 +3787,7 @@ export function OfficeScreen({ smsBoothHoldByAgentId, state.agents, workingUntilByAgentId, + agentStateMapping, ]); const streamingTextByAgentId = useMemo(() => { const map: Record = {}; @@ -3838,9 +3840,9 @@ export function OfficeScreen({ const remoteOfficeAgents = useMemo( () => (remoteOfficeSnapshot?.agents ?? []).map((agent) => - mapRemotePresenceAgentToOffice(agent) + mapRemotePresenceAgentToOffice(agent, agentStateMapping) ), - [remoteOfficeSnapshot] + [remoteOfficeSnapshot, agentStateMapping] ); const chatRosterEntries = useMemo( () => [ @@ -4255,6 +4257,7 @@ export function OfficeScreen({ soundclawEnabled={soundclawReady} officeTitle={officeTitle} officeTitleLoaded={officeTitleLoaded} + agentStateMapping={agentStateMapping} remoteOfficeEnabled={remoteOfficeEnabled} remoteOfficeSourceKind={remoteOfficeSourceKind} remoteOfficeLabel={remoteOfficeLabel} @@ -4268,6 +4271,7 @@ export function OfficeScreen({ voiceRepliesSpeed={voiceRepliesSpeed} voiceRepliesLoaded={voiceRepliesLoaded} onOfficeTitleChange={setOfficeTitle} + onAgentStateMappingChange={setAgentStateMapping} onRemoteOfficeEnabledChange={setRemoteOfficeEnabled} onRemoteOfficeSourceKindChange={setRemoteOfficeSourceKind} onRemoteOfficeLabelChange={setRemoteOfficeLabel} diff --git a/src/features/retro-office/RetroOffice3D.tsx b/src/features/retro-office/RetroOffice3D.tsx index 12087a5..c0ab6f0 100644 --- a/src/features/retro-office/RetroOffice3D.tsx +++ b/src/features/retro-office/RetroOffice3D.tsx @@ -52,6 +52,7 @@ import type { OfficeAnimationState } from "@/lib/office/eventTriggers"; import type { StandupMeeting } from "@/lib/office/standup/types"; import type { SkillStatusEntry } from "@/lib/skills/types"; import type { StudioGatewayAdapterType } from "@/lib/studio/settings"; +import type { OfficeAgentStateMapping } from "@/lib/office/agentStateMapping"; import type { TaskBoardCard, TaskBoardStatus, @@ -2456,6 +2457,7 @@ export function RetroOffice3D({ remoteOfficeStatusText?: string; remoteLayoutSnapshot?: OfficeLayoutSnapshot | null; remoteOfficeTokenConfigured?: boolean; + agentStateMapping?: OfficeAgentStateMapping; voiceRepliesEnabled?: boolean; voiceRepliesVoiceId?: string | null; voiceRepliesSpeed?: number; @@ -2469,6 +2471,7 @@ export function RetroOffice3D({ onRemoteOfficePresenceUrlChange?: (url: string) => void; onRemoteOfficeGatewayUrlChange?: (url: string) => void; onRemoteOfficeTokenChange?: (token: string) => void; + onAgentStateMappingChange?: (mapping: OfficeAgentStateMapping) => void; onVoiceRepliesToggle?: (enabled: boolean) => void; onVoiceRepliesVoiceChange?: (voiceId: string | null) => void; onVoiceRepliesSpeedChange?: (speed: number) => void; @@ -7203,6 +7206,10 @@ export function RetroOffice3D({ onRemoteOfficeTokenChange={(token) => onRemoteOfficeTokenChange?.(token) } + agentStateMapping={agentStateMapping} + onAgentStateMappingChange={(mapping) => + onAgentStateMappingChange?.(mapping) + } voiceRepliesEnabled={voiceRepliesEnabled} voiceRepliesVoiceId={voiceRepliesVoiceId} voiceRepliesSpeed={voiceRepliesSpeed} @@ -7227,6 +7234,68 @@ export function RetroOffice3D({ {!immersiveOverlayActive ? ( <> + {(() => { + const localAgents = agents.filter((agent) => !isRemoteOfficeAgentId(agent.id)); + const remoteAgents = agents.filter((agent) => isRemoteOfficeAgentId(agent.id)); + const countByStatus = ( + source: typeof agents, + status: "working" | "idle" | "error", + ) => source.filter((agent) => agent.status === status).length; + return ( +
+
+
+
+ Fleet visibility +
+
+ Local and remote agent presence at a glance. +
+
+ + {agents.length} total + +
+
+ {[ + { + label: "Local fleet", + agents: localAgents, + }, + { + label: remoteOfficeEnabled ? "Remote fleet" : "Remote fleet off", + agents: remoteAgents, + }, + ].map((section) => ( +
+
+ + {section.label} + + + {section.agents.length} + +
+
+ + {countByStatus(section.agents, "working")} working + + + {countByStatus(section.agents, "idle")} idle + + + {countByStatus(section.agents, "error")} error + +
+
+ ))} +
+
+ ); + })()} {/* Ideas 3 + 6 + 8: Mini status bar — bottom left. */}
{/* Idea 3: Activity feed entries — newest on bottom. */} diff --git a/src/hooks/useStudioOfficePreference.ts b/src/hooks/useStudioOfficePreference.ts index 43cdc28..6f93b2f 100644 --- a/src/hooks/useStudioOfficePreference.ts +++ b/src/hooks/useStudioOfficePreference.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import type { StudioSettingsCoordinator } from "@/lib/studio/coordinator"; +import type { OfficeAgentStateMappingPatch } from "@/lib/office/agentStateMapping"; import { defaultStudioOfficePreferencePublic, resolveOfficePreferencePublic, @@ -76,6 +77,37 @@ export const useStudioOfficePreference = ({ [gatewayUrl, settingsCoordinator] ); + const setAgentStateMapping = useCallback( + (agentStateMapping: OfficeAgentStateMappingPatch) => { + const gatewayKey = gatewayUrl.trim(); + setPreference((current) => ({ + ...current, + agentStateMapping: { + local: { + ...current.agentStateMapping.local, + ...(agentStateMapping.local ?? {}), + }, + remote: { + ...current.agentStateMapping.remote, + ...(agentStateMapping.remote ?? {}), + }, + }, + })); + if (!gatewayKey) return; + settingsCoordinator.schedulePatch( + { + office: { + [gatewayKey]: { + agentStateMapping, + }, + }, + }, + 0 + ); + }, + [gatewayUrl, settingsCoordinator] + ); + const setRemoteOfficeEnabled = useCallback( (remoteOfficeEnabled: boolean) => { const gatewayKey = gatewayUrl.trim(); @@ -197,6 +229,7 @@ export const useStudioOfficePreference = ({ loaded, preference, title: preference.title, + agentStateMapping: preference.agentStateMapping, remoteOfficeEnabled: preference.remoteOfficeEnabled, remoteOfficeSourceKind: preference.remoteOfficeSourceKind, remoteOfficeLabel: preference.remoteOfficeLabel, @@ -204,6 +237,7 @@ export const useStudioOfficePreference = ({ remoteOfficeGatewayUrl: preference.remoteOfficeGatewayUrl, remoteOfficeTokenConfigured: preference.remoteOfficeTokenConfigured, setTitle, + setAgentStateMapping, setRemoteOfficeEnabled, setRemoteOfficeSourceKind, setRemoteOfficeLabel, diff --git a/src/lib/office/agentStateMapping.ts b/src/lib/office/agentStateMapping.ts new file mode 100644 index 0000000..249ce83 --- /dev/null +++ b/src/lib/office/agentStateMapping.ts @@ -0,0 +1,157 @@ +import type { AgentState } from "@/features/agents/state/store"; +import type { OfficeAgentState } from "@/lib/office/schema"; + +export const OFFICE_RENDERABLE_AGENT_STATUSES = [ + "idle", + "working", + "error", +] as const; + +export type OfficeRenderableAgentStatus = + (typeof OFFICE_RENDERABLE_AGENT_STATUSES)[number]; + +export const LOCAL_OFFICE_STATE_SIGNALS = [ + "idle", + "running", + "error", +] as const; + +export type LocalOfficeStateSignal = (typeof LOCAL_OFFICE_STATE_SIGNALS)[number]; + +export const REMOTE_OFFICE_STATE_SIGNALS = [ + "idle", + "working", + "meeting", + "error", +] as const; + +export type RemoteOfficeStateSignal = + (typeof REMOTE_OFFICE_STATE_SIGNALS)[number]; + +export type OfficeAgentStateMapping = { + local: Record; + remote: Record; +}; + +export type OfficeAgentStateMappingPatch = { + local?: Partial>; + remote?: Partial>; +}; + +export const DEFAULT_OFFICE_AGENT_STATE_MAPPING: OfficeAgentStateMapping = { + local: { + idle: "idle", + running: "working", + error: "error", + }, + remote: { + idle: "idle", + working: "working", + meeting: "working", + error: "error", + }, +}; + +const isRenderableStatus = ( + value: unknown, +): value is OfficeRenderableAgentStatus => + typeof value === "string" && + OFFICE_RENDERABLE_AGENT_STATUSES.includes( + value as OfficeRenderableAgentStatus, + ); + +const normalizeLocalMapping = ( + value: unknown, + fallback: Record, +): Record => { + const source = + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + return { + idle: isRenderableStatus(source.idle) ? source.idle : fallback.idle, + running: isRenderableStatus(source.running) + ? source.running + : fallback.running, + error: isRenderableStatus(source.error) ? source.error : fallback.error, + }; +}; + +const normalizeRemoteMapping = ( + value: unknown, + fallback: Record, +): Record => { + const source = + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + return { + idle: isRenderableStatus(source.idle) ? source.idle : fallback.idle, + working: isRenderableStatus(source.working) + ? source.working + : fallback.working, + meeting: isRenderableStatus(source.meeting) + ? source.meeting + : fallback.meeting, + error: isRenderableStatus(source.error) ? source.error : fallback.error, + }; +}; + +export const normalizeOfficeAgentStateMapping = ( + value: unknown, + fallback: OfficeAgentStateMapping = DEFAULT_OFFICE_AGENT_STATE_MAPPING, +): OfficeAgentStateMapping => { + const source = + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + return { + local: normalizeLocalMapping(source.local, fallback.local), + remote: normalizeRemoteMapping(source.remote, fallback.remote), + }; +}; + +export const mergeOfficeAgentStateMapping = ( + fallback: OfficeAgentStateMapping, + patch: OfficeAgentStateMappingPatch | null | undefined, +): OfficeAgentStateMapping => { + if (!patch) { + return normalizeOfficeAgentStateMapping(fallback, fallback); + } + return normalizeOfficeAgentStateMapping( + { + local: { + ...fallback.local, + ...(patch.local ?? {}), + }, + remote: { + ...fallback.remote, + ...(patch.remote ?? {}), + }, + }, + fallback, + ); +}; + +export const resolveLocalOfficeStateSignal = ( + agent: Pick, +): LocalOfficeStateSignal => { + if (agent.status === "error") { + return "error"; + } + if (agent.status === "running" || Boolean(agent.runId)) { + return "running"; + } + return "idle"; +}; + +export const resolveLocalOfficeRenderableStatus = ( + agent: Pick, + mapping: OfficeAgentStateMapping = DEFAULT_OFFICE_AGENT_STATE_MAPPING, +): OfficeRenderableAgentStatus => + mapping.local[resolveLocalOfficeStateSignal(agent)]; + +export const resolveRemoteOfficeRenderableStatus = ( + state: OfficeAgentState, + mapping: OfficeAgentStateMapping = DEFAULT_OFFICE_AGENT_STATE_MAPPING, +): OfficeRenderableAgentStatus => mapping.remote[state]; diff --git a/src/lib/studio/settings.ts b/src/lib/studio/settings.ts index 453a832..801526f 100644 --- a/src/lib/studio/settings.ts +++ b/src/lib/studio/settings.ts @@ -14,6 +14,12 @@ import { type TaskBoardPreference, type TaskBoardPreferencePatch, } from "@/features/office/tasks/types"; +import { + DEFAULT_OFFICE_AGENT_STATE_MAPPING, + normalizeOfficeAgentStateMapping, + type OfficeAgentStateMapping, + type OfficeAgentStateMappingPatch, +} from "@/lib/office/agentStateMapping"; export type StudioGatewaySettings = { url: string; @@ -116,6 +122,7 @@ export type StudioVoiceRepliesPreferencePatch = { export type StudioOfficePreference = { title: string; + agentStateMapping: OfficeAgentStateMapping; remoteOfficeEnabled: boolean; remoteOfficeSourceKind: "presence_endpoint" | "openclaw_gateway"; remoteOfficeLabel: string; @@ -133,6 +140,7 @@ export type StudioOfficePreference = { export type StudioOfficePreferencePublic = { title: string; + agentStateMapping: OfficeAgentStateMapping; remoteOfficeEnabled: boolean; remoteOfficeSourceKind: "presence_endpoint" | "openclaw_gateway"; remoteOfficeLabel: string; @@ -150,6 +158,7 @@ export type StudioOfficePreferencePublic = { export type StudioOfficePreferencePatch = { title?: string | null; + agentStateMapping?: OfficeAgentStateMappingPatch | null; remoteOfficeEnabled?: boolean; remoteOfficeSourceKind?: "presence_endpoint" | "openclaw_gateway"; remoteOfficeLabel?: string | null; @@ -506,6 +515,7 @@ const normalizeCompanyRoleTitles = (value: unknown, fallback: string[] = []) => export const defaultStudioOfficePreference = (): StudioOfficePreference => ({ title: DEFAULT_OFFICE_TITLE, + agentStateMapping: DEFAULT_OFFICE_AGENT_STATE_MAPPING, remoteOfficeEnabled: false, remoteOfficeSourceKind: DEFAULT_REMOTE_OFFICE_SOURCE_KIND, remoteOfficeLabel: DEFAULT_REMOTE_OFFICE_LABEL, @@ -524,6 +534,7 @@ export const defaultStudioOfficePreference = (): StudioOfficePreference => ({ export const defaultStudioOfficePreferencePublic = (): StudioOfficePreferencePublic => ({ title: DEFAULT_OFFICE_TITLE, + agentStateMapping: DEFAULT_OFFICE_AGENT_STATE_MAPPING, remoteOfficeEnabled: false, remoteOfficeSourceKind: DEFAULT_REMOTE_OFFICE_SOURCE_KIND, remoteOfficeLabel: DEFAULT_REMOTE_OFFICE_LABEL, @@ -543,6 +554,7 @@ export const sanitizeStudioOfficePreference = ( value: StudioOfficePreference ): StudioOfficePreferencePublic => ({ title: value.title, + agentStateMapping: value.agentStateMapping, remoteOfficeEnabled: value.remoteOfficeEnabled, remoteOfficeSourceKind: value.remoteOfficeSourceKind, remoteOfficeLabel: value.remoteOfficeLabel, @@ -968,6 +980,10 @@ const normalizeOfficePreference = ( if (!isRecord(value)) return fallback; return { title: normalizeOfficeTitle(value.title, fallback.title), + agentStateMapping: normalizeOfficeAgentStateMapping( + value.agentStateMapping, + fallback.agentStateMapping, + ), remoteOfficeEnabled: typeof value.remoteOfficeEnabled === "boolean" ? value.remoteOfficeEnabled diff --git a/tests/unit/officeAgentStateMapping.test.ts b/tests/unit/officeAgentStateMapping.test.ts new file mode 100644 index 0000000..0a7718a --- /dev/null +++ b/tests/unit/officeAgentStateMapping.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_OFFICE_AGENT_STATE_MAPPING, + mergeOfficeAgentStateMapping, + normalizeOfficeAgentStateMapping, + resolveLocalOfficeRenderableStatus, + resolveLocalOfficeStateSignal, + resolveRemoteOfficeRenderableStatus, +} from "@/lib/office/agentStateMapping"; + +describe("office agent state mapping", () => { + it("keeps the default mapping stable", () => { + expect(DEFAULT_OFFICE_AGENT_STATE_MAPPING.local.running).toBe("working"); + expect(DEFAULT_OFFICE_AGENT_STATE_MAPPING.remote.meeting).toBe("working"); + }); + + it("normalizes partial mappings against defaults", () => { + const normalized = normalizeOfficeAgentStateMapping({ + local: { + running: "idle", + }, + remote: { + meeting: "error", + }, + }); + + expect(normalized.local).toEqual({ + idle: "idle", + running: "idle", + error: "error", + }); + expect(normalized.remote).toEqual({ + idle: "idle", + working: "working", + meeting: "error", + error: "error", + }); + }); + + it("merges patch values without discarding untouched signals", () => { + const merged = mergeOfficeAgentStateMapping( + DEFAULT_OFFICE_AGENT_STATE_MAPPING, + { + local: { running: "idle" }, + remote: { working: "error" }, + }, + ); + + expect(merged.local.idle).toBe("idle"); + expect(merged.local.running).toBe("idle"); + expect(merged.remote.working).toBe("error"); + expect(merged.remote.meeting).toBe("working"); + }); + + it("resolves local signals from runtime status and run ids", () => { + expect( + resolveLocalOfficeStateSignal({ + status: "idle", + runId: "run-1", + }), + ).toBe("running"); + expect( + resolveLocalOfficeStateSignal({ + status: "error", + runId: null, + }), + ).toBe("error"); + }); + + it("uses the configured renderable status for local and remote states", () => { + const mapping = mergeOfficeAgentStateMapping( + DEFAULT_OFFICE_AGENT_STATE_MAPPING, + { + local: { running: "idle" }, + remote: { meeting: "error" }, + }, + ); + + expect( + resolveLocalOfficeRenderableStatus( + { + status: "running", + runId: "run-2", + }, + mapping, + ), + ).toBe("idle"); + expect(resolveRemoteOfficeRenderableStatus("meeting", mapping)).toBe("error"); + }); +}); diff --git a/tests/unit/studioSettings.test.ts b/tests/unit/studioSettings.test.ts index 8d29d92..5eebd37 100644 --- a/tests/unit/studioSettings.test.ts +++ b/tests/unit/studioSettings.test.ts @@ -192,6 +192,81 @@ describe("studio settings normalization", () => { ); }); + + + it("normalizes office agent state mappings per gateway", () => { + const normalized = normalizeStudioSettings({ + office: { + " [REDACTED] ": { + agentStateMapping: { + local: { running: "idle" }, + remote: { meeting: "error" }, + }, + }, + }, + }); + + expect(normalized.office["[REDACTED]"]).toEqual( + expect.objectContaining({ + agentStateMapping: { + local: { + idle: "idle", + running: "idle", + error: "error", + }, + remote: { + idle: "idle", + working: "working", + meeting: "error", + error: "error", + }, + }, + }), + ); + }); + + it("merges office agent state mapping patches", () => { + const current = normalizeStudioSettings({ + office: { + "[REDACTED]": { + agentStateMapping: { + local: { running: "working" }, + remote: { meeting: "working" }, + }, + }, + }, + }); + + const merged = mergeStudioSettings(current, { + office: { + "[REDACTED]": { + agentStateMapping: { + local: { running: "idle" }, + remote: { error: "working" }, + }, + }, + }, + }); + + expect(merged.office["[REDACTED]"]).toEqual( + expect.objectContaining({ + agentStateMapping: { + local: { + idle: "idle", + running: "idle", + error: "error", + }, + remote: { + idle: "idle", + working: "working", + meeting: "working", + error: "working", + }, + }, + }), + ); + }); + it("normalizes task board cards per gateway", () => { const normalized = normalizeStudioSettings({ taskBoard: {