feat(FN-4394): complete Step 4 — wire heartbeat scope resolver into runs
Fusion-Task-Id: FN-4394 Fusion-Task-Lineage: 1e7ad660-2cd2-40ba-b7b8-7ef552c6fa13
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
HEARTBEAT_SYSTEM_PROMPT,
|
||||
HEARTBEAT_NO_TASK_SYSTEM_PROMPT,
|
||||
HEARTBEAT_PROCEDURE,
|
||||
HEARTBEAT_PROCEDURE_OFF,
|
||||
HEARTBEAT_NO_TASK_PROCEDURE,
|
||||
getAgentSoulWords,
|
||||
} from "../agent-heartbeat.js";
|
||||
@@ -1146,6 +1147,72 @@ describe("executeHeartbeat", () => {
|
||||
expect(savedRun?.heartbeatProcedureSource).toBe("default");
|
||||
});
|
||||
|
||||
it("uses lite task-scoped procedure when project heartbeatScopeDiscipline is lite", async () => {
|
||||
const store = createStoreWithAgentForExec({ taskId: "FN-001" });
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
||||
mockTaskStore = createMockTaskStore({
|
||||
getSettings: vi.fn().mockResolvedValue({ heartbeatScopeDiscipline: "lite" }),
|
||||
});
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
||||
|
||||
expect(result.status).toBe("completed");
|
||||
const persistedRun = vi.mocked(store.saveRun).mock.calls
|
||||
.map(([arg]) => arg)
|
||||
.find((arg): arg is AgentHeartbeatRun => typeof arg.executionPrompt === "string");
|
||||
expect(persistedRun?.executionPrompt).toContain("Assignment review");
|
||||
expect(persistedRun?.executionPrompt).toContain("Classify scope before acting");
|
||||
expect(persistedRun?.executionPrompt).not.toContain("Per-tick self-check");
|
||||
expect(persistedRun?.contextSnapshot?.heartbeatScopeDiscipline).toBe("lite");
|
||||
});
|
||||
|
||||
it("uses agent runtimeConfig heartbeatScopeDiscipline over project default", async () => {
|
||||
const store = createStoreWithAgentForExec({
|
||||
runtimeConfig: { heartbeatScopeDiscipline: "strict" },
|
||||
taskId: "FN-001",
|
||||
});
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
||||
mockTaskStore = createMockTaskStore({
|
||||
getSettings: vi.fn().mockResolvedValue({ heartbeatScopeDiscipline: "lite" }),
|
||||
});
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
||||
|
||||
expect(result.status).toBe("completed");
|
||||
const persistedRun = vi.mocked(store.saveRun).mock.calls
|
||||
.map(([arg]) => arg)
|
||||
.find((arg): arg is AgentHeartbeatRun => typeof arg.executionPrompt === "string");
|
||||
expect(persistedRun?.executionPrompt).toContain("Per-tick self-check");
|
||||
expect(persistedRun?.executionPrompt).toContain("Classify the bound task");
|
||||
expect(persistedRun?.executionPrompt).not.toContain("Assignment review");
|
||||
expect(persistedRun?.contextSnapshot?.heartbeatScopeDiscipline).toBe("strict");
|
||||
});
|
||||
|
||||
it("off mode omits scope-classification guidance", async () => {
|
||||
const store = createStoreWithAgentForExec({ taskId: "FN-001" });
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
||||
mockTaskStore = createMockTaskStore({
|
||||
getSettings: vi.fn().mockResolvedValue({ heartbeatScopeDiscipline: "off" }),
|
||||
});
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
||||
|
||||
expect(result.status).toBe("completed");
|
||||
const executionPrompt = mockSession.prompt.mock.calls.at(-1)?.[0] as string;
|
||||
expect(executionPrompt).toContain(HEARTBEAT_PROCEDURE_OFF);
|
||||
expect(executionPrompt).not.toContain("Classify scope before acting");
|
||||
expect(executionPrompt).not.toContain("Classify the bound task");
|
||||
|
||||
const savedRun = await store.getRunDetail("agent-001", result.id);
|
||||
expect(savedRun?.contextSnapshot?.heartbeatScopeDiscipline).toBe("off");
|
||||
});
|
||||
|
||||
it("task-scoped run receives HEARTBEAT_SYSTEM_PROMPT as system prompt", async () => {
|
||||
const store = createStoreWithAgentForExec({ taskId: "FN-001" });
|
||||
const mockSession = createMockAgentSession();
|
||||
@@ -2775,6 +2842,7 @@ describe("executeHeartbeat", () => {
|
||||
taskId: "FN-001",
|
||||
triggeringCommentIds: ["comment-1"],
|
||||
triggeringCommentType: "task",
|
||||
heartbeatScopeDiscipline: "strict",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ describe("resolveHeartbeatScopeDisciplineMode", () => {
|
||||
expect(resolveHeartbeatScopeDisciplineMode(project("invalid" as never), agent(undefined))).toBe("strict");
|
||||
});
|
||||
|
||||
it("falls through invalid agent and invalid project modes to strict default", () => {
|
||||
expect(resolveHeartbeatScopeDisciplineMode(project("invalid" as never), agent("invalid"))).toBe("strict");
|
||||
});
|
||||
|
||||
it("defaults to strict when unset", () => {
|
||||
expect(resolveHeartbeatScopeDisciplineMode(undefined, undefined)).toBe("strict");
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
buildPluginPromptSection,
|
||||
resolveAgentHeartbeatProcedure,
|
||||
} from "./agent-instructions.js";
|
||||
import { resolveHeartbeatScopeDisciplineMode, selectHeartbeatProcedure } from "./heartbeat-procedure-resolver.js";
|
||||
import { buildPromptLayers, collapsePromptLayers } from "./prompt-layers.js";
|
||||
import { heartbeatLog, formatError } from "./logger.js";
|
||||
import { acquireTaskWorktree } from "./worktree-acquisition.js";
|
||||
@@ -2375,9 +2376,23 @@ export class HeartbeatMonitor {
|
||||
`Agent ${agentId} no-task heartbeat bypassed configured heartbeatProcedurePath and used HEARTBEAT_NO_TASK_PROCEDURE to keep prompt guidance aligned with ambient tools`,
|
||||
);
|
||||
}
|
||||
const heartbeatScopeDiscipline = resolveHeartbeatScopeDisciplineMode(heartbeatModelSettings, agent);
|
||||
const resolvedProcedureTemplate = selectHeartbeatProcedure(heartbeatScopeDiscipline, isNoTaskRun, {
|
||||
task: {
|
||||
strict: HEARTBEAT_PROCEDURE_STRICT,
|
||||
lite: HEARTBEAT_PROCEDURE_LITE,
|
||||
off: HEARTBEAT_PROCEDURE_OFF,
|
||||
},
|
||||
noTask: {
|
||||
strict: HEARTBEAT_NO_TASK_PROCEDURE_STRICT,
|
||||
lite: HEARTBEAT_NO_TASK_PROCEDURE_LITE,
|
||||
off: HEARTBEAT_NO_TASK_PROCEDURE_OFF,
|
||||
},
|
||||
});
|
||||
const heartbeatProcedureText = shouldOverrideCustomProcedureForNoTaskRun
|
||||
? HEARTBEAT_NO_TASK_PROCEDURE
|
||||
: (customProcedure ?? (isNoTaskRun ? HEARTBEAT_NO_TASK_PROCEDURE : HEARTBEAT_PROCEDURE));
|
||||
? resolvedProcedureTemplate
|
||||
: (customProcedure ?? resolvedProcedureTemplate);
|
||||
// Precedence: heartbeatProcedurePath (custom file) > resolved heartbeatScopeDiscipline template > strict default.
|
||||
const heartbeatProcedureSource = shouldOverrideCustomProcedureForNoTaskRun
|
||||
? "default-no-task-override"
|
||||
: (customProcedure ? "custom" : "default");
|
||||
@@ -2581,10 +2596,19 @@ export class HeartbeatMonitor {
|
||||
systemPrompt: truncatePrompt(systemPromptFinal, 100_000),
|
||||
executionPrompt: truncatePrompt(executionPrompt, 100_000),
|
||||
heartbeatProcedureSource,
|
||||
contextSnapshot: {
|
||||
...(run.contextSnapshot ?? {}),
|
||||
heartbeatScopeDiscipline,
|
||||
},
|
||||
};
|
||||
await this.store.saveRun(runWithPrompts);
|
||||
// Update local run reference so completeRun merges correctly
|
||||
Object.assign(run, { systemPrompt: runWithPrompts.systemPrompt, executionPrompt: runWithPrompts.executionPrompt, heartbeatProcedureSource: runWithPrompts.heartbeatProcedureSource });
|
||||
Object.assign(run, {
|
||||
systemPrompt: runWithPrompts.systemPrompt,
|
||||
executionPrompt: runWithPrompts.executionPrompt,
|
||||
heartbeatProcedureSource: runWithPrompts.heartbeatProcedureSource,
|
||||
contextSnapshot: runWithPrompts.contextSnapshot,
|
||||
});
|
||||
} catch (promptPersistErr) {
|
||||
heartbeatLog.warn(`Failed to persist prompts for ${agentId}/${run.id}: ${promptPersistErr instanceof Error ? promptPersistErr.message : String(promptPersistErr)}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user