feat(FN-1085): align agent routing and runtime contracts
- Harden core AgentStore lifecycle behavior and heartbeat runtime integration paths - Align dashboard agent APIs, server routes, and agent UI flows with the updated contract - Tighten CLI agent/message command routing and validate payload handling semantics - Expand test coverage across core, dashboard, engine, and CLI for route, heartbeat, and instruction regressions
This commit is contained in:
@@ -175,6 +175,43 @@ describe("AgentStore", () => {
|
||||
expect(updated.metadata).toEqual({ preserved: true }); // preserved
|
||||
});
|
||||
|
||||
it("allows clearing optional fields via explicit undefined", async () => {
|
||||
const created = await store.createAgent({
|
||||
name: "Clearable",
|
||||
role: "executor",
|
||||
title: "Worker",
|
||||
instructionsText: "Initial instructions",
|
||||
});
|
||||
|
||||
const withTransientState = await store.updateAgent(created.id, {
|
||||
pauseReason: "manual",
|
||||
lastError: "oops",
|
||||
});
|
||||
expect(withTransientState.pauseReason).toBe("manual");
|
||||
expect(withTransientState.lastError).toBe("oops");
|
||||
|
||||
const cleared = await store.updateAgent(created.id, {
|
||||
title: undefined,
|
||||
instructionsText: undefined,
|
||||
pauseReason: undefined,
|
||||
lastError: undefined,
|
||||
});
|
||||
|
||||
expect(cleared.title).toBeUndefined();
|
||||
expect(cleared.instructionsText).toBeUndefined();
|
||||
expect(cleared.pauseReason).toBeUndefined();
|
||||
expect(cleared.lastError).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects whitespace-only names", async () => {
|
||||
const created = await store.createAgent({
|
||||
name: "Rename Me",
|
||||
role: "executor",
|
||||
});
|
||||
|
||||
await expect(store.updateAgent(created.id, { name: " " })).rejects.toThrow("Agent name cannot be empty");
|
||||
});
|
||||
|
||||
it("throws for non-existent agent ID", async () => {
|
||||
await expect(
|
||||
store.updateAgent("agent-missing", { name: "Nope" })
|
||||
@@ -555,7 +592,10 @@ describe("AgentStore", () => {
|
||||
await s.recordHeartbeat(agent.id, "missed");
|
||||
await s.updateAgentState(agent.id, "active");
|
||||
await s.assignTask(agent.id, "KB-999");
|
||||
await s.updateAgent(agent.id, { lastError: "something broke" });
|
||||
await s.updateAgent(agent.id, {
|
||||
pauseReason: "manual",
|
||||
lastError: "something broke",
|
||||
});
|
||||
await s.updateAgentState(agent.id, "terminated");
|
||||
return agent;
|
||||
}
|
||||
@@ -567,6 +607,24 @@ describe("AgentStore", () => {
|
||||
expect(reset.state).toBe("idle");
|
||||
});
|
||||
|
||||
it("can reset directly from running", async () => {
|
||||
const agent = await store.createAgent({ name: "RunningReset", role: "executor" });
|
||||
await store.recordHeartbeat(agent.id, "ok");
|
||||
await store.updateAgentState(agent.id, "active");
|
||||
await store.updateAgentState(agent.id, "running");
|
||||
await store.assignTask(agent.id, "KB-123");
|
||||
await store.updateAgent(agent.id, {
|
||||
pauseReason: "stalled",
|
||||
lastError: "runner failed",
|
||||
});
|
||||
|
||||
const reset = await store.resetAgent(agent.id);
|
||||
expect(reset.state).toBe("idle");
|
||||
expect(reset.taskId).toBeUndefined();
|
||||
expect(reset.pauseReason).toBeUndefined();
|
||||
expect(reset.lastError).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears lastError", async () => {
|
||||
const agent = await createTerminatedAgent(store, "ResetClearsError");
|
||||
const reset = await store.resetAgent(agent.id);
|
||||
@@ -574,6 +632,13 @@ describe("AgentStore", () => {
|
||||
expect(reset.lastError).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears pauseReason", async () => {
|
||||
const agent = await createTerminatedAgent(store, "ResetClearsPause");
|
||||
const reset = await store.resetAgent(agent.id);
|
||||
|
||||
expect(reset.pauseReason).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears taskId", async () => {
|
||||
const agent = await createTerminatedAgent(store, "ResetClearsTask");
|
||||
const reset = await store.resetAgent(agent.id);
|
||||
|
||||
@@ -207,23 +207,28 @@ export class AgentStore extends EventEmitter {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
const nextName = "name" in updates && typeof updates.name === "string" ? updates.name.trim() : undefined;
|
||||
if (nextName !== undefined && !nextName) {
|
||||
throw new Error("Agent name cannot be empty");
|
||||
}
|
||||
|
||||
const updated: Agent = {
|
||||
...agent,
|
||||
name: updates.name?.trim() ?? agent.name,
|
||||
name: nextName ?? agent.name,
|
||||
role: updates.role ?? agent.role,
|
||||
metadata: updates.metadata !== undefined ? updates.metadata : agent.metadata,
|
||||
updatedAt: new Date().toISOString(),
|
||||
...(updates.title !== undefined && { title: updates.title }),
|
||||
...(updates.icon !== undefined && { icon: updates.icon }),
|
||||
...(updates.reportsTo !== undefined && { reportsTo: updates.reportsTo }),
|
||||
...(updates.runtimeConfig !== undefined && { runtimeConfig: updates.runtimeConfig }),
|
||||
...(updates.pauseReason !== undefined && { pauseReason: updates.pauseReason }),
|
||||
...(updates.permissions !== undefined && { permissions: updates.permissions }),
|
||||
...(updates.lastError !== undefined && { lastError: updates.lastError }),
|
||||
...(updates.totalInputTokens !== undefined && { totalInputTokens: updates.totalInputTokens }),
|
||||
...(updates.totalOutputTokens !== undefined && { totalOutputTokens: updates.totalOutputTokens }),
|
||||
...(updates.instructionsPath !== undefined && { instructionsPath: updates.instructionsPath }),
|
||||
...(updates.instructionsText !== undefined && { instructionsText: updates.instructionsText }),
|
||||
...("title" in updates && { title: updates.title }),
|
||||
...("icon" in updates && { icon: updates.icon }),
|
||||
...("reportsTo" in updates && { reportsTo: updates.reportsTo }),
|
||||
...("runtimeConfig" in updates && { runtimeConfig: updates.runtimeConfig }),
|
||||
...("pauseReason" in updates && { pauseReason: updates.pauseReason }),
|
||||
...("permissions" in updates && { permissions: updates.permissions }),
|
||||
...("lastError" in updates && { lastError: updates.lastError }),
|
||||
...("totalInputTokens" in updates && { totalInputTokens: updates.totalInputTokens }),
|
||||
...("totalOutputTokens" in updates && { totalOutputTokens: updates.totalOutputTokens }),
|
||||
...("instructionsPath" in updates && { instructionsPath: updates.instructionsPath }),
|
||||
...("instructionsText" in updates && { instructionsText: updates.instructionsText }),
|
||||
};
|
||||
|
||||
await this.writeAgent(updated);
|
||||
@@ -310,33 +315,45 @@ export class AgentStore extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Reset an agent from any state back to "idle".
|
||||
* Clears lastError, taskId, and ends any active heartbeat run.
|
||||
* Uses updateAgentState internally for proper validation and event emission.
|
||||
* Clears transient execution state (taskId, lastError, pauseReason)
|
||||
* and ends any active heartbeat run.
|
||||
* @param agentId - The agent ID
|
||||
* @returns The reset agent
|
||||
* @throws Error if agent not found or transition is invalid
|
||||
*/
|
||||
async resetAgent(agentId: string): Promise<Agent> {
|
||||
let agent = await this.getAgent(agentId);
|
||||
if (!agent) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
// End any active heartbeat run before transitioning
|
||||
const activeRun = await this.getActiveHeartbeatRun(agentId);
|
||||
if (activeRun) {
|
||||
await this.endHeartbeatRun(activeRun.id, "terminated");
|
||||
}
|
||||
|
||||
// Transition state via updateAgentState (validates transition, emits events)
|
||||
const agent = await this.updateAgentState(agentId, "idle");
|
||||
// Normalize to terminated first when idle is not directly reachable.
|
||||
if (agent.state !== "idle" && agent.state !== "terminated") {
|
||||
agent = await this.updateAgentState(agentId, "terminated");
|
||||
}
|
||||
|
||||
// Clear taskId and lastError on top of the state transition
|
||||
const reset: Agent = {
|
||||
...agent,
|
||||
taskId: undefined,
|
||||
lastError: undefined,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
if (agent.state !== "idle") {
|
||||
agent = await this.updateAgentState(agentId, "idle");
|
||||
}
|
||||
|
||||
await this.writeAgent(reset);
|
||||
if (agent.taskId !== undefined) {
|
||||
agent = await this.assignTask(agentId, undefined);
|
||||
}
|
||||
|
||||
return reset;
|
||||
if (agent.lastError !== undefined || agent.pauseReason !== undefined) {
|
||||
agent = await this.updateAgent(agentId, {
|
||||
lastError: undefined,
|
||||
pauseReason: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user