fix(context-engine): forward heartbeat to ingest fallbacks

This commit is contained in:
Josh Lehman
2026-06-05 09:14:49 -07:00
parent 47aedbbdbc
commit 599580cd96
4 changed files with 58 additions and 2 deletions

View File

@@ -509,6 +509,7 @@ describe("installContextEngineLoopHook", () => {
prePromptMessageCount: number;
}) => Record<string, unknown> | undefined,
onAfterTurnCheckpoint?: (messageCount: number) => void,
isHeartbeat?: boolean,
): () => void {
return installContextEngineLoopHook({
agent,
@@ -521,6 +522,7 @@ describe("installContextEngineLoopHook", () => {
...(prePromptCount !== undefined ? { getPrePromptMessageCount: () => prePromptCount } : {}),
...(getRuntimeContext ? { getRuntimeContext } : {}),
...(onAfterTurnCheckpoint ? { onAfterTurnCheckpoint } : {}),
...(isHeartbeat !== undefined ? { isHeartbeat } : {}),
});
}
@@ -984,7 +986,7 @@ describe("installContextEngineLoopHook", () => {
it("ingests new messages in batches when afterTurn is absent", async () => {
const agent = makeGuardableAgent();
const engine = makeMockEngine({ omitAfterTurn: true });
installHook(agent, engine);
installHook(agent, engine, undefined, undefined, undefined, true);
const batch0 = [makeUser("first"), makeToolResult("call_1", "r1")];
await callTransform(agent, batch0);
@@ -1001,7 +1003,9 @@ describe("installContextEngineLoopHook", () => {
throw new Error("expected ingestBatch mock");
}
expect(recordMockArg(ingestBatch).messages).toEqual(batch1.slice(2));
expect(recordMockArg(ingestBatch).isHeartbeat).toBe(true);
expect(recordMockArg(ingestBatch, 1).messages).toEqual(batch2.slice(4));
expect(recordMockArg(ingestBatch, 1).isHeartbeat).toBe(true);
expect(engine.assemble).toHaveBeenCalledTimes(2);
});
@@ -1019,9 +1023,23 @@ describe("installContextEngineLoopHook", () => {
expect(ingestParams?.sessionId).toBe(sessionId);
expect(ingestParams?.sessionKey).toBe(sessionKey);
expect(ingestParams?.message).toBe(toolResult);
expect(ingestParams?.isHeartbeat).toBeUndefined();
expect(engine.assemble).toHaveBeenCalledTimes(1);
});
it("passes heartbeat state through per-message ingest fallbacks", async () => {
const agent = makeGuardableAgent();
const engine = makeMockEngine({ omitAfterTurn: true, omitIngestBatch: true });
installHook(agent, engine, 1, undefined, undefined, true);
const toolResult = makeToolResult("call_1", "r1");
const messages = [makeUser("first"), toolResult];
await callTransform(agent, messages);
expect(engine.ingest).toHaveBeenCalledTimes(1);
expect(recordMockArg(engine.ingest).isHeartbeat).toBe(true);
});
it("falls through to source messages when engine.afterTurn throws", async () => {
const agent = makeGuardableAgent();
const engine = makeMockEngine({

View File

@@ -410,6 +410,7 @@ export function installContextEngineLoopHook(params: {
sessionId,
sessionKey,
messages: newMessages,
isHeartbeat: params.isHeartbeat,
});
} else {
for (const message of newMessages) {
@@ -417,6 +418,7 @@ export function installContextEngineLoopHook(params: {
sessionId,
sessionKey,
message,
isHeartbeat: params.isHeartbeat,
});
}
}

View File

@@ -209,11 +209,45 @@ describe("harness context engine lifecycle", () => {
runtimeContext: {},
runMaintenance: async () => undefined,
warn: () => {},
isHeartbeat: true,
});
const ingestBatchCalls = (ingestBatch as unknown as { mock: { calls: unknown[][] } }).mock
.calls;
const ingestBatchParams = ingestBatchCalls[0]?.[0] as { messages?: AgentMessage[] } | undefined;
const ingestBatchParams = ingestBatchCalls[0]?.[0] as
| { isHeartbeat?: boolean; messages?: AgentMessage[] }
| undefined;
expect(ingestBatchParams?.messages).toEqual([turnUser, turnAssistant]);
expect(ingestBatchParams?.isHeartbeat).toBe(true);
});
it("forwards heartbeat state to per-message ingest fallbacks", async () => {
const turnUser = textMessage("user", "new ask", 4);
const turnAssistant = textMessage("assistant", "new answer", 6);
const ingest = vi.fn(async () => ({ ingested: true }));
await finalizeHarnessContextEngineTurn({
contextEngine: createContextEngine({ ingest }),
promptError: false,
aborted: false,
yieldAborted: false,
sessionIdUsed: sessionParams.sessionIdUsed,
sessionKey: sessionParams.sessionKey,
sessionFile: sessionParams.sessionFile,
messagesSnapshot: [turnUser, turnAssistant],
prePromptMessageCount: 0,
tokenBudget: 2048,
runtimeContext: {},
runMaintenance: async () => undefined,
warn: () => {},
isHeartbeat: true,
});
const ingestCalls = (ingest as unknown as { mock: { calls: unknown[][] } }).mock.calls;
expect(ingestCalls).toHaveLength(2);
for (const call of ingestCalls) {
const ingestParams = call[0] as { isHeartbeat?: boolean };
expect(ingestParams.isHeartbeat).toBe(true);
}
});
});

View File

@@ -187,6 +187,7 @@ export async function finalizeHarnessContextEngineTurn(params: {
sessionId: params.sessionIdUsed,
sessionKey: params.sessionKey,
messages: newMessages,
isHeartbeat: params.isHeartbeat,
});
} catch (ingestErr) {
postTurnFinalizationSucceeded = false;
@@ -199,6 +200,7 @@ export async function finalizeHarnessContextEngineTurn(params: {
sessionId: params.sessionIdUsed,
sessionKey: params.sessionKey,
message: msg,
isHeartbeat: params.isHeartbeat,
});
} catch (ingestErr) {
postTurnFinalizationSucceeded = false;