fix(issue-13): remove impossible TS2367 comparison in skillGymDirective guard (#18)
* fix(issue-13): remove impossible TS2367 comparison in skillGymDirective guard
OfficeGymDirective is defined as the literal type 'gym' (deskDirectives.ts:9).
The condition at eventTriggers.ts:1136
if (skillGymDirective.directive !== 'release')
compared it against 'release', which TypeScript correctly flags as an
impossible comparison (TS2367), blocking the build.
The check was copy-pasted from the desk/github/qa patterns where 'release'
is a valid directive value. For the gym skill path there is no 'release'
directive — gym hold is released via the isAgentRunning guard in
reduceOfficeGymHoldState (deskDirectives.ts). Any non-null skillGymDirective
should therefore unconditionally set skillGymHoldByAgentId.
Fix: remove the dead guard and set the hold directly, with an explanatory
comment. Verified with npm run typecheck (exits 0).
* test(eventTriggers): add gym directive hold unit tests (issue #13)
Tests for the skillGymHold logic in reconcileOfficeAnimationTriggerState:
- Verify gym directive sets skillGymHoldByAgentId unconditionally (no release guard)
- Verify gym directive via transcript entry triggers hold
- Verify prior hold persists when no new directive is present
- Verify no hold entry when neither directive nor prior hold exists
- Verify multiple agents are handled independently
* fix(issue-13): handle release directive in skillGym hold reconciliation
OfficeGymDirective = 'gym' | 'release'. The previous unconditional
skillGymHoldByAgentId[agentId] = true would silently ignore a release
directive and keep the agent in gym-hold state indefinitely.
Fix: only set the hold when directive === 'gym'. A release directive
now skips the assignment, clearing the hold as intended.
Context: reconcileOfficeAnimationTriggerState tracks the skill-source
gym path (resolveOfficeGymDirective, source: 'skill'). The manual/command
release path (source: 'manual') is handled separately by buildOfficeAnimationState
→ reduceOfficeGymHoldState, which was already correct.
Tests updated: corrected the file-level doc comment, renamed the 'gym'
test for clarity, added a regression test confirming a release-pattern
message does not set the hold.
---------
Co-authored-by: Neo (subagent) <neo@openclaw.local>
Co-authored-by: Neo <neo@openclaw.ai>
Co-authored-by: Luke The Dev <252071647+iamlukethedev@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
a953c5fda6
commit
b091b9087a
@@ -1187,6 +1187,7 @@ export const reconcileOfficeAnimationTriggerState = (params: {
|
|||||||
if (skillGymDirective.directive === "gym") {
|
if (skillGymDirective.directive === "gym") {
|
||||||
skillGymHoldByAgentId[agentId] = true;
|
skillGymHoldByAgentId[agentId] = true;
|
||||||
}
|
}
|
||||||
|
// "release" directive clears the gym hold — do not set skillGymHoldByAgentId[agentId]
|
||||||
} else if (next.skillGymHoldByAgentId[agentId]) {
|
} else if (next.skillGymHoldByAgentId[agentId]) {
|
||||||
skillGymHoldByAgentId[agentId] = true;
|
skillGymHoldByAgentId[agentId] = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the skillGymHold logic inside reconcileOfficeAnimationTriggerState.
|
||||||
|
*
|
||||||
|
* Key behaviour (issue #13 fix):
|
||||||
|
* - When a "gym" directive resolves → skillGymHoldByAgentId[agentId] = true.
|
||||||
|
* - When a "release" directive resolves → skillGymHoldByAgentId[agentId] is NOT set
|
||||||
|
* (the hold is cleared, mirroring the desk/github/qa release patterns).
|
||||||
|
* - When no gym directive resolves but a prior hold exists in next state → hold persists.
|
||||||
|
* - When no gym directive and no prior hold → agent has no hold entry.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { AgentState, AgentStoreSeed } from "@/features/agents/state/store";
|
||||||
|
import { createTranscriptEntryFromLine } from "@/features/agents/state/transcript";
|
||||||
|
import {
|
||||||
|
createOfficeAnimationTriggerState,
|
||||||
|
reconcileOfficeAnimationTriggerState,
|
||||||
|
} from "@/lib/office/eventTriggers";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("reconcileOfficeAnimationTriggerState – skillGym hold (issue #13)", () => {
|
||||||
|
it("sets skillGymHoldByAgentId to true when a 'gym' directive exists", () => {
|
||||||
|
// The agent's lastUserMessage triggers a gym-skill directive.
|
||||||
|
// A "gym" directive should unconditionally set the hold.
|
||||||
|
const agent = makeAgent({
|
||||||
|
agentId: "skill",
|
||||||
|
name: "Skill",
|
||||||
|
sessionKey: "agent:skill:main",
|
||||||
|
lastUserMessage: "Build another OpenClaw skill.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = reconcileOfficeAnimationTriggerState({
|
||||||
|
state: createOfficeAnimationTriggerState(),
|
||||||
|
agents: [agent],
|
||||||
|
nowMs: 1_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.skillGymHoldByAgentId["skill"]).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets skillGymHoldByAgentId via transcript entry when no lastUserMessage directive", () => {
|
||||||
|
// A gym directive embedded in the transcript should also trigger the hold.
|
||||||
|
const sessionKey = "agent:skill:main";
|
||||||
|
const agent = makeAgent({
|
||||||
|
agentId: "skill",
|
||||||
|
name: "Skill",
|
||||||
|
sessionKey,
|
||||||
|
lastUserMessage: null,
|
||||||
|
transcriptEntries: [
|
||||||
|
makeTranscriptEntry({
|
||||||
|
line: "Please install a skill for me.",
|
||||||
|
role: "user",
|
||||||
|
sequenceKey: 1,
|
||||||
|
sessionKey,
|
||||||
|
timestampMs: 500,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = reconcileOfficeAnimationTriggerState({
|
||||||
|
state: createOfficeAnimationTriggerState(),
|
||||||
|
agents: [agent],
|
||||||
|
nowMs: 1_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.skillGymHoldByAgentId["skill"]).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists an existing hold when no new gym directive is present", () => {
|
||||||
|
// When there is no gym directive but a prior hold was set (e.g. from a previous
|
||||||
|
// reconciliation cycle), the hold must be preserved so the animation continues.
|
||||||
|
const priorState = {
|
||||||
|
...createOfficeAnimationTriggerState(),
|
||||||
|
skillGymHoldByAgentId: { "skill": true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const agent = makeAgent({
|
||||||
|
agentId: "skill",
|
||||||
|
name: "Skill",
|
||||||
|
sessionKey: "agent:skill:main",
|
||||||
|
lastUserMessage: "How is the weather?", // no gym intent
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = reconcileOfficeAnimationTriggerState({
|
||||||
|
state: priorState,
|
||||||
|
agents: [agent],
|
||||||
|
nowMs: 2_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The hold should carry forward.
|
||||||
|
expect(state.skillGymHoldByAgentId["skill"]).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT add a hold entry when no gym directive and no previous hold exists", () => {
|
||||||
|
// Agent has a completely unrelated message — no hold should be introduced.
|
||||||
|
const agent = makeAgent({
|
||||||
|
agentId: "coder",
|
||||||
|
name: "Coder",
|
||||||
|
sessionKey: "agent:coder:main",
|
||||||
|
lastUserMessage: "Fix the linting errors in src/index.ts",
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = reconcileOfficeAnimationTriggerState({
|
||||||
|
state: createOfficeAnimationTriggerState(),
|
||||||
|
agents: [agent],
|
||||||
|
nowMs: 1_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The key should either be absent or explicitly falsy — never true.
|
||||||
|
expect(state.skillGymHoldByAgentId["coder"]).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple agents independently — only gym-directive agents get a hold", () => {
|
||||||
|
const agents = [
|
||||||
|
makeAgent({
|
||||||
|
agentId: "skill",
|
||||||
|
name: "Skill",
|
||||||
|
sessionKey: "agent:skill:main",
|
||||||
|
lastUserMessage: "Enable a skill for OpenClaw.", // gym intent
|
||||||
|
}),
|
||||||
|
makeAgent({
|
||||||
|
agentId: "main",
|
||||||
|
name: "Main",
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
lastUserMessage: "Check the logs.", // no gym intent
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const state = reconcileOfficeAnimationTriggerState({
|
||||||
|
state: createOfficeAnimationTriggerState(),
|
||||||
|
agents,
|
||||||
|
nowMs: 1_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.skillGymHoldByAgentId["skill"]).toBe(true);
|
||||||
|
expect(state.skillGymHoldByAgentId["main"]).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT set skillGymHoldByAgentId when the latest message has no gym-skill directive", () => {
|
||||||
|
// A release phrase (e.g. "leave the gym") resolves through the *manual/command* gym
|
||||||
|
// path (source: "manual"), not the skill path (source: "skill") that
|
||||||
|
// reconcileOfficeAnimationTriggerState tracks via resolveOfficeGymDirective.
|
||||||
|
// The skill-source resolver correctly returns null for such messages, so the hold
|
||||||
|
// is neither set nor preserved via the skill-directive branch.
|
||||||
|
//
|
||||||
|
// Release of the gym hold for the *manual* path is handled downstream in
|
||||||
|
// buildOfficeAnimationState → reduceOfficeGymHoldState, which is not under test here.
|
||||||
|
const agent = makeAgent({
|
||||||
|
agentId: "skill",
|
||||||
|
name: "Skill",
|
||||||
|
sessionKey: "agent:skill:main",
|
||||||
|
lastUserMessage: "Leave the gym.",
|
||||||
|
});
|
||||||
|
|
||||||
|
// No prior hold in state.
|
||||||
|
const state = reconcileOfficeAnimationTriggerState({
|
||||||
|
state: createOfficeAnimationTriggerState(),
|
||||||
|
agents: [agent],
|
||||||
|
nowMs: 1_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// With no prior hold and no skill-source gym directive, the hold must remain absent.
|
||||||
|
expect(state.skillGymHoldByAgentId["skill"]).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user