Skip to content

Commit 56db654

Browse files
committed
fix: carry reply metadata into runtime context
1 parent e5b2ca3 commit 56db654

10 files changed

Lines changed: 515 additions & 45 deletions

src/agents/cli-runner/prepare.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1742,6 +1742,47 @@ describe("prepareCliRunContext", () => {
17421742
}
17431743
});
17441744

1745+
it("carries trusted reply metadata into CLI runtime input without changing the transcript prompt", async () => {
1746+
const { dir, sessionFile } = createSessionFile();
1747+
try {
1748+
const context = await prepareCliRunContext({
1749+
sessionId: "session-test",
1750+
sessionKey: "agent:main:test",
1751+
agentId: "main",
1752+
trigger: "user",
1753+
sessionFile,
1754+
workspaceDir: dir,
1755+
prompt: "latest ask",
1756+
transcriptPrompt: "latest ask",
1757+
currentInboundContext: {
1758+
text: "",
1759+
reply: {
1760+
currentMessageId: "34974",
1761+
threadId: "34970",
1762+
replyToId: "34971",
1763+
replyTargetPresent: true,
1764+
},
1765+
},
1766+
provider: "test-cli",
1767+
model: "test-model",
1768+
timeoutMs: 1_000,
1769+
runId: "run-test-reply-context",
1770+
config: createCliBackendConfig(),
1771+
});
1772+
1773+
expect(context.params.prompt).toContain(
1774+
"Current reply metadata (trusted OpenClaw runtime metadata):",
1775+
);
1776+
expect(context.params.prompt).toContain('"currentMessageId": "34974"');
1777+
expect(context.params.prompt).toContain('"replyToId": "34971"');
1778+
expect(context.params.prompt).toContain("\n\nlatest ask");
1779+
expect(context.params.transcriptPrompt).toBe("latest ask");
1780+
expect(context.contextEngineTurnPrompt).toBe("latest ask");
1781+
} finally {
1782+
fs.rmSync(dir, { recursive: true, force: true });
1783+
}
1784+
});
1785+
17451786
it("uses compact current-turn context when a room event resumes a CLI session", async () => {
17461787
const { dir, sessionFile } = createSessionFile();
17471788
appendTranscriptEntry(sessionFile, {

src/agents/embedded-agent-runner/run/attempt-prompt-context.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,8 +245,9 @@ describe("prepareEmbeddedAttemptPromptContext", () => {
245245
const result = prepareEmbeddedAttemptPromptContext(fixture.input);
246246

247247
expect(result.promptSubmission.runtimeOnly).toBe(true);
248-
expect(result.promptForSession).toContain("Room event metadata");
248+
expect(result.promptForSession).toBe("Continue the OpenClaw runtime event.");
249249
expect(result.runtimeContextMessageForCurrentTurn).toBeUndefined();
250+
expect(result.systemPromptForHook).toContain("Room event metadata");
250251
expect(result.systemPromptForHook).toContain("Runtime room event");
251252
expect(fixture.setActiveSessionSystemPrompt).toHaveBeenCalledWith(
252253
expect.stringContaining("Runtime room event"),

src/agents/embedded-agent-runner/run/attempt-prompt-context.ts

Lines changed: 6 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import {
2323
} from "./attempt.llm-boundary.js";
2424
import { composeSystemPromptWithHookContext } from "./attempt.thread-helpers.js";
2525
import {
26-
buildCurrentInboundPrompt,
2726
buildRuntimeContextCustomMessage,
2827
resolveRuntimeContextPromptParts,
2928
type RuntimeContextCustomMessage,
@@ -170,29 +169,17 @@ export function prepareEmbeddedAttemptPromptContext(input: {
170169
appendContext: input.prompt.promptBuildAppendContext ?? "",
171170
}
172171
: undefined,
172+
currentInboundContext: attempt.currentInboundContext,
173173
emptyTranscriptMode: attempt.suppressNextUserMessagePersistence
174174
? "model-prompt"
175175
: "runtime-event",
176176
});
177177
const isRuntimeOnlyTurn = promptSubmission.runtimeOnly === true;
178-
const currentInboundContextText = isRuntimeOnlyTurn
179-
? undefined
180-
: attempt.currentInboundContext?.text?.trim() || undefined;
181178
// Normal user turns persist the bare prompt and carry current inbound metadata
182-
// in a hidden runtime-context message. Runtime-only turns have no bare user turn,
183-
// so their inbound context remains inline and byte-stable across replay.
184-
const promptForSession = isRuntimeOnlyTurn
185-
? buildCurrentInboundPrompt({
186-
context: attempt.currentInboundContext,
187-
prompt: promptSubmission.prompt,
188-
})
189-
: promptSubmission.prompt;
190-
const promptForModel = isRuntimeOnlyTurn
191-
? buildCurrentInboundPrompt({
192-
context: attempt.currentInboundContext,
193-
prompt: promptSubmission.modelPrompt ?? promptSubmission.prompt,
194-
})
195-
: (promptSubmission.modelPrompt ?? promptSubmission.prompt);
179+
// in a hidden runtime-context message. Runtime-only turns use the same bare
180+
// marker prompt and carry inbound metadata through runtime system context.
181+
const promptForSession = promptSubmission.prompt;
182+
const promptForModel = promptSubmission.modelPrompt ?? promptSubmission.prompt;
196183
const currentUserTimestampOverride =
197184
!input.isRawModelRun && typeof preparedUserTurnTimestamp === "number"
198185
? {
@@ -215,11 +202,7 @@ export function prepareEmbeddedAttemptPromptContext(input: {
215202
}
216203
const runtimeContextForHook = isRuntimeOnlyTurn
217204
? undefined
218-
: [
219-
currentInboundContextText,
220-
promptSubmission.runtimeContext?.trim(),
221-
input.heartbeatOutcomeContext?.trim(),
222-
]
205+
: [promptSubmission.runtimeContext?.trim(), input.heartbeatOutcomeContext?.trim()]
223206
.filter((value): value is string => Boolean(value))
224207
.join("\n\n") || undefined;
225208
const runtimeContextMessageForCurrentTurn =

src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2168,8 +2168,9 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
21682168
expect(hoisted.sessionManager.branch).toHaveBeenCalledWith("parent-leaf");
21692169
});
21702170

2171-
it("keeps current inbound context visible on runtime-only turns", async () => {
2171+
it("keeps current inbound context available as runtime system context on runtime-only turns", async () => {
21722172
let seenPrompt: string | undefined;
2173+
let seenSystemPrompt: string | undefined;
21732174

21742175
const result = await createContextEngineAttemptRunner({
21752176
contextEngine: createContextEngineBootstrapAndAssemble(),
@@ -2194,27 +2195,31 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
21942195
},
21952196
sessionPrompt: async (session, prompt) => {
21962197
seenPrompt = prompt;
2198+
seenSystemPrompt = session.agent.state.systemPrompt;
21972199
session.messages = [
21982200
...session.messages,
21992201
{ role: "assistant", content: "done", timestamp: 2 },
22002202
];
22012203
},
22022204
});
22032205

2204-
expect(seenPrompt).toContain("Reply target of current user message (untrusted, for context):");
2205-
expect(seenPrompt).toContain("Hello from the replied message");
2206-
expect(seenPrompt).toContain("Continue the OpenClaw runtime event.");
2206+
expect(seenPrompt).toBe("Continue the OpenClaw runtime event.");
2207+
expect(seenSystemPrompt).toContain(
2208+
"Reply target of current user message (untrusted, for context):",
2209+
);
2210+
expect(seenSystemPrompt).toContain("Hello from the replied message");
22072211
expect(result.finalPromptText).toBe(seenPrompt);
22082212
const trajectoryEvents = await readTrajectoryEvents(tempPaths);
22092213
const contextCompiled = trajectoryEvents.find((event) => event.type === "context.compiled");
2210-
expect(contextCompiled?.data?.prompt).toContain("Hello from the replied message");
2214+
expect(contextCompiled?.data?.prompt).toBe("Continue the OpenClaw runtime event.");
22112215
expect(contextCompiled?.data?.systemPrompt).toContain("runtime bare mention event");
2216+
expect(contextCompiled?.data?.systemPrompt).toContain("Hello from the replied message");
22122217
});
22132218

2214-
it("submits suppressed room event context as the model prompt", async () => {
2219+
it("submits suppressed room event context as runtime context", async () => {
22152220
let seenPrompt: string | undefined;
2216-
let seenModelMessages: unknown[] | undefined;
22172221
let seenMessages: unknown[] | undefined;
2222+
let seenModelMessages: unknown[] | undefined;
22182223
const runBeforePromptBuild = vi.fn(async () => ({
22192224
prependContext: "dynamic hook context",
22202225
appendContext: "dynamic hook tail",
@@ -2268,17 +2273,19 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
22682273
// routed into the runtime-context carrier instead of the user text.
22692274
expect(seenPrompt).toBe("[OpenClaw room event]");
22702275
expect(seenPrompt).not.toContain("inbound_event_kind: room_event");
2276+
expect(seenPrompt).not.toContain("visible_reply_contract: message_tool_only");
2277+
expect(seenPrompt).not.toContain("Current event:\n#2003 Bob: hey claw summarize the plan");
22712278
expect(seenPrompt).not.toBe("Continue the OpenClaw runtime event.");
22722279
expect(seenPrompt).not.toContain("dynamic hook context");
22732280
expect(seenPrompt).not.toContain("dynamic hook tail");
2274-
const roomRuntimeContext = findRecord(
2281+
const runtimeContext = findRecord(
22752282
requireRecords(seenMessages, "seen messages"),
22762283
(message) => message.customType === "openclaw.runtime-context",
22772284
"runtime context message",
22782285
);
2279-
expect(roomRuntimeContext.content).toContain("inbound_event_kind: room_event");
2280-
expect(roomRuntimeContext.content).toContain("visible_reply_contract: message_tool_only");
2281-
expect(roomRuntimeContext.content).toContain(
2286+
expect(runtimeContext.content).toContain("inbound_event_kind: room_event");
2287+
expect(runtimeContext.content).toContain("visible_reply_contract: message_tool_only");
2288+
expect(runtimeContext.content).toContain(
22822289
"Current event:\n#2003 Bob: hey claw summarize the plan",
22832290
);
22842291
expect(JSON.stringify(seenModelMessages)).toContain("dynamic hook context");
@@ -2291,6 +2298,9 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
22912298
"visible_reply_contract: message_tool_only",
22922299
);
22932300
expect(contextCompiled?.data?.prompt).toContain("[OpenClaw room event]");
2301+
expect(contextCompiled?.data?.prompt).not.toContain(
2302+
"visible_reply_contract: message_tool_only",
2303+
);
22942304
});
22952305

22962306
it("skips blank visible prompts with replay history before provider submission", async () => {

src/agents/embedded-agent-runner/run/params.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,24 @@ type ReasoningStreamPayload = Pick<
5858
requiresReasoningProgressOptIn?: boolean;
5959
};
6060

61+
type CurrentInboundReplyMetadata = {
62+
currentMessageId?: string;
63+
threadId?: string;
64+
replyToId?: string;
65+
replyToIdFull?: string;
66+
replyTargetPresent?: boolean;
67+
quotePresent?: boolean;
68+
replyChainPresent?: boolean;
69+
replyChainMessageIds?: string[];
70+
};
71+
6172
export type CurrentInboundPromptContext = {
6273
text: string;
6374
resumableText?: string;
6475
promptJoiner?: "\n\n" | "\n" | " ";
6576
/** Generated goal blocks owned by inbound-context assembly, never user text. */
6677
injectedGoalContexts?: string[];
78+
reply?: CurrentInboundReplyMetadata;
6779
};
6880

6981
export type RunEmbeddedAgentParams = {

0 commit comments

Comments
 (0)