Compare commits

..

1 Commits

Author SHA1 Message Date
Peter Steinberger
fabf62d0f7 feat: expose requester origin to tool policy hooks 2026-06-23 16:04:57 -07:00
72 changed files with 1877 additions and 2306 deletions

View File

@@ -1,185 +0,0 @@
# App Review Notes
Use these steps to exercise the live OpenClaw iOS App Review Gateway.
## Demo Account / Setup
Use the OpenClaw iOS app with the live review Gateway setup code included in
the `Notes` field of this App Review submission.
The setup code is a single generated code string. It already contains the public
Gateway host and setup credential.
## Setup Walkthrough
1. Open the OpenClaw app.
2. Tap `Continue`.
3. On `Connect Gateway`, tap `Set Up Manually`.
4. In the `Setup Code` section, tap the `Paste setup code` field.
5. Paste the setup code string from the App Review submission `Notes` field.
6. Tap `Apply Setup Code`.
7. If `Trust and connect` appears, tap `Trust and connect`.
8. Wait for the `Connected` screen.
9. On `Connected`, tap `Open OpenClaw`.
10. Confirm the `Control` screen shows `Gateway Online`.
11. Tap `Settings`.
12. Tap `Approvals`.
13. Tap `Open Notifications`.
14. Tap `Enable Notifications`.
15. On `Enable OpenClaw Hosted Push Relay?`, tap `Continue`.
16. If iOS asks whether OpenClaw may send notifications, tap `Allow`.
17. Confirm `Notifications` shows `Enabled`.
## Chat
1. Tap the `Chat` tab.
2. Tap the text field labeled `Message main...`.
3. Send this exact message:
```text
Start Apple review checklist.
```
Expected result: the assistant replies with the available App Review demos.
## Approval Demo
1. Tap the `Chat` tab.
2. Tap the text field labeled `Message main...`.
3. Send this exact message:
```text
Run the approval demo.
```
Expected result: the iPhone shows `Exec approval required` with the harmless
command `printf 'OpenClaw App Review approval demo complete\n'`. Tap
`Allow Once`. The chat then replies:
```text
The approval demo completed.
```
## Talk
1. Tap the `Talk` tab.
2. Tap `Start Talk`.
3. If iOS asks for microphone access, tap `Allow`.
4. If iOS asks for Speech Recognition access, tap `Allow`.
5. Confirm the screen changes to `Ready to talk` and shows `Stop Talk`.
6. Say:
```text
Summarize this review setup in one sentence.
```
Expected result: the assistant responds by voice. Tap `Stop Talk` when done.
## Talk + Background Audio
1. Tap the `Talk` tab.
2. Confirm `Speakerphone` is on.
3. Confirm `Background listening` is on.
4. Tap `Start Talk`.
5. If iOS asks for microphone access, tap `Allow`.
6. If iOS asks for Speech Recognition access, tap `Allow`.
7. Confirm `Stop Talk` is visible.
8. Say:
```text
Tell me when you can hear me.
```
9. While Talk is active, send OpenClaw to the background by returning to the
Home Screen or locking the iPhone. Do not force quit the app.
10. Continue speaking then wait for assistant audio reply.
Expected result: realtime Talk audio continues while OpenClaw is backgrounded.
Reopen OpenClaw, confirm Talk is still active, then tap `Stop Talk`.
## Gateway Status
1. Tap `Control`.
2. Tap `Instances`.
3. Confirm the screen shows `Gateway online`.
4. Confirm at least one `agent` row is connected.
5. Confirm the iPhone review device appears in the connected instances list.
## Push Notification
1. Tap the `Chat` tab.
2. Tap the text field labeled `Message main...`.
3. Send this exact message:
```text
Start push notification demo.
```
4. Immediately send OpenClaw to the background and lock the iPhone. Do not
force quit the app.
Expected result: the iPhone Lock Screen receives a visible `OpenClaw`
notification with this body:
```text
OpenClaw App Review push notification demo
```
Tap the notification and unlock the iPhone if prompted. If OpenClaw opens on
`Control`, tap `Chat`. Expected chat reply:
```text
The push notification demo completed.
```
## Push Wake / Status
1. Tap the `Chat` tab.
2. Send this exact message:
```text
Start push wake demo.
```
3. Immediately send OpenClaw to the background and lock the iPhone. Do not
force quit the app.
4. Wait for the `OpenClaw` notification on the Lock Screen. It normally appears
about 10 seconds after the message is sent.
5. Tap the notification and unlock the iPhone if prompted. If OpenClaw opens on
`Control`, tap `Chat`.
Expected result: the app reconnects to the live Gateway and Chat replies:
```text
The push wake and node status demo completed.
```
## Device Permissions
1. Tap `Settings`.
2. Tap `Permissions`.
3. Confirm these current app controls are available:
- `Camera`
- `Location` with `Off`, `While Using`, and `Always`
- `Keep Awake`
4. Expand `Privacy & Access`.
5. Confirm these request controls are available:
- `Contacts` / `Request Access`
- `Calendar (Add Events)` / `Request Access`
- `Calendar (View Events)` / `Request Full Access`
- `Reminders` / `Request Access`
## Share Sheet
1. Open Safari.
2. Navigate to `https://example.com`.
3. Tap the Safari toolbar `More` button.
4. Tap `Share`.
5. Tap `OpenClaw`.
6. Confirm the OpenClaw share extension appears and shows
`Edit text, then tap Send.` and `Send to OpenClaw`.
7. Tap `Send to OpenClaw`.
Expected result: the OpenClaw share extension sends the shared Safari page to
the live review Gateway and shows `Sent to OpenClaw.` Returning to OpenClaw
Chat shows the shared `Example Domain` page.

View File

@@ -1,2 +1,2 @@
f7247b5bbfe3f96bffffd25a8be2f89b37999e36731f34a159ae21ded1cedd05 plugin-sdk-api-baseline.json
ce88a53dadc194ceccc63f50146aee03a1a425f551117da826a21519d5bf80db plugin-sdk-api-baseline.jsonl
edf22df3bcc2037f2059904ed91ed6761d7fa8fcedec1d40fa464db81be09123 plugin-sdk-api-baseline.json
78797dbf24b26e4a5f578e13119431372786ce7f7e2bbddbb5228aaf95be15ee plugin-sdk-api-baseline.jsonl

View File

@@ -186,8 +186,12 @@ file.
- optional `event.runId`
- optional `event.toolCallId`
- context fields such as `ctx.agentId`, `ctx.sessionKey`, `ctx.sessionId`,
`ctx.runId`, `ctx.jobId` (set on cron-driven runs), `ctx.toolKind`,
`ctx.toolInputKind`, and diagnostic `ctx.trace`
`ctx.runId`, `ctx.jobId` (set on cron-driven runs), `ctx.trigger`,
`ctx.toolKind`, `ctx.toolInputKind`, and diagnostic `ctx.trace`
- for channel-originated calls, origin fields such as `ctx.channel`,
`ctx.messageProvider`, `ctx.channelId`, `ctx.chatId`, `ctx.senderId`, and
extensible `ctx.channelContext` sender/chat metadata. These use the same
identity semantics described below for agent hook contexts.
It can return:

View File

@@ -12,7 +12,11 @@ import {
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { buildApprovalResponse, handleCodexAppServerApprovalRequest } from "./approval-bridge.js";
import {
buildApprovalResponse,
handleCodexAppServerApprovalRequest as handleCodexAppServerApprovalRequestImpl,
} from "./approval-bridge.js";
import { buildCodexToolHookRunContext } from "./tool-hook-context.js";
vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async (importOriginal) => ({
...(await importOriginal<typeof import("openclaw/plugin-sdk/agent-harness-runtime")>()),
@@ -42,6 +46,20 @@ const mockResolveNativeHookRelayDeferredToolApproval = vi.mocked(
const mockReviewExecRequestWithConfiguredModel = vi.mocked(reviewExecRequestWithConfiguredModel);
const mockRunBeforeToolCallHook = vi.mocked(runBeforeToolCallHook);
type ApprovalRequestParams = Parameters<typeof handleCodexAppServerApprovalRequestImpl>[0];
function handleCodexAppServerApprovalRequest(
params: Omit<ApprovalRequestParams, "toolHookContext"> & {
toolHookContext?: ApprovalRequestParams["toolHookContext"];
},
) {
return handleCodexAppServerApprovalRequestImpl({
...params,
toolHookContext:
params.toolHookContext ?? buildCodexToolHookRunContext({ attempt: params.paramsForRun }),
});
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`Expected ${label}`);
@@ -243,6 +261,8 @@ describe("Codex app-server approval bridge", () => {
ctx: {
agentId: "main",
sessionKey: "agent:main:session-1",
messageProvider: "telegram",
channel: "telegram",
channelId: "chat-1",
},
});
@@ -1164,11 +1184,18 @@ describe("Codex app-server approval bridge", () => {
});
});
it("normalizes prefixed channel targets for OpenClaw tool policy context", async () => {
it("uses the caller-resolved hook context for approval fallback policy", async () => {
const params = createParams();
params.messageChannel = "telegram";
params.messageProvider = "telegram";
params.currentChannelId = "telegram:-100123";
params.agentId = "raw-agent";
params.sessionId = "raw-session";
params.sessionKey = "agent:raw:session";
params.runId = "raw-run";
params.messageChannel = "discord";
params.messageProvider = "discord";
params.currentChannelId = "discord:raw-target";
params.jobId = "raw-job";
params.senderId = "raw-user";
params.chatId = "raw-chat";
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-prefixed", status: "accepted" })
.mockResolvedValueOnce({ id: "plugin:approval-prefixed", decision: "allow-once" });
@@ -1182,6 +1209,27 @@ describe("Codex app-server approval bridge", () => {
command: "pnpm test extensions/codex/src/app-server",
},
paramsForRun: params,
toolHookContext: {
agentId: "resolved-agent",
sessionId: "resolved-session",
sessionKey: "agent:resolved:session",
runId: "resolved-run",
jobId: "resolved-job",
trigger: "user",
messageProvider: "telegram-voice",
channel: "telegram",
channelId: "-100123",
chatId: "native-chat-1",
senderId: "user-1",
channelContext: {
sender: {
id: "user-1",
displayName: "Ada",
providerUserId: "provider-user-1",
},
chat: { id: "native-chat-1", providerThreadKey: "thread-key-1" },
},
},
threadId: "thread-1",
turnId: "turn-1",
});
@@ -1189,11 +1237,29 @@ describe("Codex app-server approval bridge", () => {
expect(mockRunBeforeToolCallHook).toHaveBeenCalledWith(
expect.objectContaining({
ctx: expect.objectContaining({
agentId: "resolved-agent",
sessionId: "resolved-session",
sessionKey: "agent:resolved:session",
runId: "resolved-run",
jobId: "resolved-job",
trigger: "user",
messageProvider: "telegram-voice",
channel: "telegram",
channelId: "-100123",
chatId: "native-chat-1",
senderId: "user-1",
channelContext: {
sender: {
id: "user-1",
displayName: "Ada",
providerUserId: "provider-user-1",
},
chat: { id: "native-chat-1", providerThreadKey: "thread-key-1" },
},
}),
}),
);
expect(gatewayRequestPayload().turnSourceTo).toBe("telegram:-100123");
expect(gatewayRequestPayload().turnSourceTo).toBe("discord:raw-target");
});
it("denies command approvals before prompting when OpenClaw tool policy blocks", async () => {

View File

@@ -8,7 +8,6 @@ import {
*/
import {
type AgentApprovalEventData,
buildAgentHookContextChannelFields,
formatApprovalDisplayPath,
hasNativeHookRelayInvocation,
invokeNativeHookRelay,
@@ -17,6 +16,7 @@ import {
type NativeHookRelayProcessResponse,
type NativeHookRelayRegistrationHandle,
runBeforeToolCallHook,
type ToolHookRunContext,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import { normalizeTrimmedStringList } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -75,6 +75,7 @@ export async function handleCodexAppServerApprovalRequest(params: {
method: string;
requestParams: JsonValue | undefined;
paramsForRun: EmbeddedRunAttemptParams;
toolHookContext: ToolHookRunContext;
threadId: string;
turnId: string;
nativeHookRelay?: Pick<
@@ -106,6 +107,7 @@ export async function handleCodexAppServerApprovalRequest(params: {
method: params.method,
requestParams,
paramsForRun: params.paramsForRun,
toolHookContext: params.toolHookContext,
context,
nativeHookRelay: params.nativeHookRelay,
signal: params.signal,
@@ -619,6 +621,7 @@ async function runOpenClawToolPolicyForApprovalRequest(params: {
method: string;
requestParams: JsonObject | undefined;
paramsForRun: EmbeddedRunAttemptParams;
toolHookContext: ToolHookRunContext;
context: ApprovalContext;
nativeHookRelay?: Pick<
NativeHookRelayRegistrationHandle,
@@ -652,13 +655,6 @@ async function runOpenClawToolPolicyForApprovalRequest(params: {
if (nativeRelayOutcome?.handled) {
return { outcome: "no-decision" };
}
const hookChannelId = buildAgentHookContextChannelFields({
sessionKey: params.paramsForRun.sessionKey,
messageChannel: params.paramsForRun.messageChannel,
messageProvider: params.paramsForRun.messageProvider,
currentChannelId: params.paramsForRun.currentChannelId,
messageTo: params.paramsForRun.messageTo,
}).channelId;
const outcome = await runBeforeToolCallHook({
toolName: policyRequest.toolName,
params: policyRequest.params,
@@ -666,13 +662,9 @@ async function runOpenClawToolPolicyForApprovalRequest(params: {
approvalMode: "request",
signal: params.signal,
ctx: {
...(params.paramsForRun.agentId ? { agentId: params.paramsForRun.agentId } : {}),
...params.toolHookContext,
...(params.paramsForRun.config ? { config: params.paramsForRun.config } : {}),
...(cwd ? { cwd } : {}),
...(params.paramsForRun.sessionKey ? { sessionKey: params.paramsForRun.sessionKey } : {}),
...(params.paramsForRun.sessionId ? { sessionId: params.paramsForRun.sessionId } : {}),
...(params.paramsForRun.runId ? { runId: params.paramsForRun.runId } : {}),
...(hookChannelId ? { channelId: hookChannelId } : {}),
},
});
if (outcome.blocked) {

View File

@@ -944,8 +944,16 @@ describe("Codex app-server dynamic tool build", () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.currentChannelId = "D123";
params.messageChannel = "discord";
params.messageProvider = "discord-voice";
params.currentChannelId = "discord:D123";
params.currentMessagingTarget = "user:U123";
params.chatId = "chat-123";
params.senderId = "user-123";
params.channelContext = {
sender: { id: "user-123" },
chat: { id: "chat-123" },
};
params.runtimePlan = createCodexRuntimePlanFixture();
const factoryOptions: unknown[] = [];
setOpenClawCodingToolsFactoryForTests((options) => {
@@ -956,9 +964,19 @@ describe("Codex app-server dynamic tool build", () => {
await buildDynamicToolsForTest(params, workspaceDir, { sandbox: null as never });
expect(factoryOptions[0]).toMatchObject({
currentChannelId: "D123",
messageChannel: "discord",
messageProvider: "discord",
toolPolicyMessageProvider: "discord-voice",
currentChannelId: "discord:D123",
currentMessagingTarget: "user:U123",
chatId: "chat-123",
senderId: "user-123",
hookChannelContext: {
sender: { id: "user-123" },
chat: { id: "chat-123" },
},
});
expect((factoryOptions[0] as { channelContext?: unknown }).channelContext).toBeUndefined();
});
it("passes the approval reviewer device into Codex dynamic tools", async () => {

View File

@@ -125,7 +125,7 @@ export function resolveCodexAppServerHookChannelId(
messageChannel: params.messageChannel,
messageProvider: params.messageProvider,
currentChannelId: params.currentChannelId,
messageTo: params.messageTo,
messageTo: params.currentMessagingTarget ?? params.messageTo,
}).channelId;
}
@@ -239,6 +239,7 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
elevated: params.bashElevated,
},
sandbox: input.sandbox,
messageChannel: params.messageChannel,
messageProvider: resolveCodexMessageToolProvider(params),
toolPolicyMessageProvider: params.messageProvider ?? params.messageChannel,
agentAccountId: params.agentAccountId,
@@ -249,6 +250,7 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy,
senderId: params.senderId,
hookChannelContext: params.channelContext,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
@@ -290,6 +292,7 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
),
suppressManagedWebSearch: false,
currentChannelId: params.currentChannelId,
chatId: params.chatId,
currentMessagingTarget: params.currentMessagingTarget,
hookChannelId: resolveCodexAppServerHookChannelId(params, input.sandboxSessionKey),
currentThreadTs: params.currentThreadTs,

View File

@@ -1102,426 +1102,6 @@ describe("createCodexDynamicToolBridge", () => {
]);
});
it("marks delivered message-tool-only source replies as terminal", async () => {
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", { messageId: "imessage-6264" }),
{ sourceReplyDeliveryMode: "message_tool_only" },
);
const result = await handleMessageToolCall(bridge, {
action: "send",
message: "visible reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("keeps message-tool-only source replies terminal when middleware redacts receipt details", async () => {
const registry = createEmptyPluginRegistry();
registry.agentToolResultMiddlewares.push({
pluginId: "receipt-redactor",
pluginName: "Receipt redactor",
rawHandler: () => undefined,
handler: (event: { result: AgentToolResult<unknown> }) => ({
result: {
content: event.result.content,
details: { redacted: true },
},
}),
runtimes: ["codex"],
source: "test",
});
setActivePluginRegistry(registry);
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", {
receipt: {
primaryPlatformMessageId: "imessage-6264",
platformMessageIds: ["imessage-6264"],
},
}),
{ sourceReplyDeliveryMode: "message_tool_only" },
);
const result = await handleMessageToolCall(bridge, {
action: "send",
message: "visible reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("does not treat target telemetry alone as delivered message-tool-only source reply evidence", async () => {
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "imessage",
currentChannelId: "chat-1",
});
const result = await handleMessageToolCall(bridge, {
action: "send",
message: "visible reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
expect.objectContaining({
tool: "message",
provider: "imessage",
to: "chat-1",
text: "visible reply",
}),
]);
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("keeps message-tool-only source replies terminal for explicit current source routes", async () => {
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", { ok: true, messageId: "imessage-853" }),
{
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "imessage",
currentChannelId: "imessage:+12069106512",
currentMessagingTarget: "+12069106512",
},
);
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "+12069106512",
messageId: "853",
message: "visible reply",
buttons: [],
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("keeps message-tool-only source replies terminal when the reply receipt matches the current message id", async () => {
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", {
ok: true,
messageId: "provider-message-1",
repliedTo: "provider-guid-857",
}),
{
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "imessage",
currentChannelId: "imessage:any;-;+12069106512",
currentMessageId: "provider-guid-857",
},
);
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "+12069106512",
messageId: "857",
message: "visible reply",
buttons: [],
});
expect(result).toEqual(expectInputText("Sent."));
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
expect.objectContaining({
tool: "message",
provider: "imessage",
to: "+12069106512",
text: "visible reply",
}),
]);
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("keeps message-tool-only source replies terminal when a text receipt matches the current message id", async () => {
const receiptText = JSON.stringify({
ok: true,
messageId: "provider-message-1",
repliedTo: "provider-guid-861",
});
const bridge = createBridgeWithToolResult("message", textToolResult(receiptText), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "imessage",
currentChannelId: "imessage:any;-;+12069106512",
currentMessageId: "provider-guid-861",
});
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "+12069106512",
messageId: "861",
message: "visible reply",
buttons: [],
});
expect(result).toEqual(expectInputText(receiptText));
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("keeps message-tool-only source replies terminal for explicit native target segments", async () => {
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "imessage",
currentChannelId: "imessage:any;-;+12069106512",
});
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "+12069106512",
messageId: "863",
message: "visible reply",
buttons: [],
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("keeps message-tool-only source replies terminal when the provider is only in the current channel id", async () => {
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelId: "imessage:any;-;+12069106512",
});
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "+12069106512",
messageId: "865",
message: "visible reply",
buttons: [],
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("records message-tool-owned terminal replies as delivered source replies", async () => {
const bridge = createBridgeWithToolResult(
"message",
{
...textToolResult("Sent.", { ok: true }),
terminate: true,
} as AgentToolResult<unknown>,
{ sourceReplyDeliveryMode: "message_tool_only" },
);
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "+12069106512",
messageId: "867",
message: "visible reply",
buttons: [],
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("does not treat bare send telemetry as delivered message-tool-only source reply evidence", async () => {
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
sourceReplyDeliveryMode: "message_tool_only",
});
const result = await handleMessageToolCall(bridge, {
action: "send",
message: "visible reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(bridge.telemetry.didSendViaMessagingTool).toBe(true);
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("does not let prior message-send telemetry terminate a later non-delivery tool result", async () => {
const execute = vi
.fn()
.mockResolvedValueOnce(textToolResult("Sent.", { messageId: "source-reply-1" }))
.mockResolvedValueOnce(textToolResult("No message sent.", { ok: true }));
const bridge = createCodexDynamicToolBridge({
tools: [createTool({ name: "message", execute })],
signal: new AbortController().signal,
hookContext: { sourceReplyDeliveryMode: "message_tool_only" },
});
const firstResult = await handleMessageToolCall(bridge, {
action: "send",
message: "visible reply",
});
const secondResult = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-2",
namespace: null,
tool: "message",
arguments: { action: "inspect" },
});
expect(firstResult.terminate).toBe(true);
expect(bridge.telemetry.didSendViaMessagingTool).toBe(true);
expect(secondResult).toEqual(expectInputText("No message sent."));
expect(secondResult.terminate).toBeUndefined();
});
it("does not mark explicit message-tool sends as terminal source replies", async () => {
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", { messageId: "other-chat-message" }),
{ sourceReplyDeliveryMode: "message_tool_only" },
);
const result = await handleMessageToolCall(bridge, {
action: "send",
target: "channel:other",
message: "cross-channel reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("does not mark mismatched explicit message-tool sends as terminal source replies", async () => {
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "imessage",
currentChannelId: "imessage:+12069106512",
currentMessagingTarget: "+12069106512",
});
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "slack",
target: "+12069106512",
messageId: "853",
message: "cross-provider reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("does not mark same-target sibling-thread replies as terminal source replies", async () => {
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "slack",
currentChannelId: "slack:C123",
currentMessagingTarget: "C123",
currentThreadId: "171.222",
});
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "slack",
target: "C123",
threadId: "171.333",
message: "sibling thread reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("does not mark implicit-target sibling-thread replies as terminal source replies", async () => {
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "slack",
currentChannelId: "slack:C123",
currentMessagingTarget: "C123",
currentThreadId: "171.222",
});
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "slack",
threadId: "171.333",
message: "sibling thread reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("does not mark top-level source replies with explicit thread routes as terminal", async () => {
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "slack",
currentChannelId: "slack:C123",
currentMessagingTarget: "C123",
});
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "slack",
target: "C123",
threadId: "171.333",
message: "thread reply from top-level source",
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("does not let matching reply receipts override explicit non-source routes", async () => {
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", {
ok: true,
messageId: "other-chat-message",
repliedTo: "provider-guid-853",
}),
{
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "imessage",
currentChannelId: "imessage:+12069106512",
currentMessagingTarget: "+12069106512",
currentMessageId: "provider-guid-853",
},
);
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "other-chat",
message: "cross-channel reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("does not record messaging side effects when the send fails", async () => {
const tool = createTool({
name: "message",
@@ -2266,6 +1846,17 @@ describe("createCodexDynamicToolBridge", () => {
sessionId: "session-1",
sessionKey: "agent:agent-1:session-1",
runId: "run-1",
jobId: "job-1",
trigger: "user",
messageProvider: "discord-voice",
channel: "discord",
chatId: "channel-1",
senderId: "user-1",
channelId: "channel-1",
channelContext: {
sender: { id: "user-1", displayName: "Ada" },
chat: { id: "channel-1" },
},
},
});
@@ -2369,6 +1960,17 @@ describe("createCodexDynamicToolBridge", () => {
sessionId: "session-1",
sessionKey: "agent:agent-1:session-1",
runId: "run-1",
jobId: "job-1",
trigger: "user",
messageProvider: "discord-voice",
channel: "discord",
chatId: "channel-1",
senderId: "user-1",
channelId: "channel-1",
channelContext: {
sender: { id: "user-1", displayName: "Ada" },
chat: { id: "channel-1" },
},
},
});
@@ -2395,6 +1997,17 @@ describe("createCodexDynamicToolBridge", () => {
sessionId: "session-1",
sessionKey: "agent:agent-1:session-1",
runId: "run-1",
jobId: "job-1",
trigger: "user",
messageProvider: "discord-voice",
channel: "discord",
chatId: "channel-1",
senderId: "user-1",
channelId: "channel-1",
channelContext: {
sender: { id: "user-1", displayName: "Ada" },
chat: { id: "channel-1" },
},
toolCallId: "call-1",
});
expectExecuteCall(execute, { callId: "call-1", args: { command: "pwd", mode: "safe" } });
@@ -2417,6 +2030,17 @@ describe("createCodexDynamicToolBridge", () => {
sessionId: "session-1",
sessionKey: "agent:agent-1:session-1",
runId: "run-1",
jobId: "job-1",
trigger: "user",
messageProvider: "discord-voice",
channel: "discord",
chatId: "channel-1",
senderId: "user-1",
channelId: "channel-1",
channelContext: {
sender: { id: "user-1", displayName: "Ada" },
chat: { id: "channel-1" },
},
toolCallId: "call-1",
});
});

View File

@@ -18,7 +18,6 @@ import {
getChannelAgentToolMeta,
getPluginToolMeta,
type EmbeddedRunAttemptParams,
isDeliveredMessageToolOnlySourceReplyResult,
isReplaySafeToolCall,
isToolWrappedWithBeforeToolCallHook,
isToolResultError,
@@ -33,6 +32,7 @@ import {
type HeartbeatToolResponse,
type MessagingToolSend,
type MessagingToolSourceReplyPayload,
type ToolHookRunContext,
wrapToolWithBeforeToolCallHook,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime";
@@ -54,26 +54,19 @@ import type {
JsonValue,
} from "./protocol.js";
type CodexDynamicToolHookContext = {
agentId?: string;
type CodexDynamicToolHookContext = ToolHookRunContext & {
config?: EmbeddedRunAttemptParams["config"];
sessionId?: string;
sessionKey?: string;
runId?: string;
channelId?: string;
currentChannelProvider?: string;
currentChannelId?: string;
currentMessagingTarget?: string;
currentMessageId?: string | number;
currentThreadId?: string;
replyToMode?: "off" | "first" | "all" | "batched";
hasRepliedRef?: { value: boolean };
sourceReplyDeliveryMode?: EmbeddedRunAttemptParams["sourceReplyDeliveryMode"];
onToolOutcome?: EmbeddedRunAttemptParams["onToolOutcome"];
allocateToolOutcomeOrdinal?: EmbeddedRunAttemptParams["allocateToolOutcomeOrdinal"];
};
type CodexToolResultHookContext = Omit<CodexDynamicToolHookContext, "config">;
type CodexToolResultHookContext = ToolHookRunContext;
type ProjectedCodexDynamicTool = {
tool: AnyAgentTool;
@@ -103,218 +96,6 @@ function applyCurrentMessageProvider(
return { ...args, provider };
}
function normalizeRouteToken(value: string | number | undefined): string | undefined {
if (typeof value === "number") {
return Number.isFinite(value) ? String(value) : undefined;
}
const normalized = value?.trim().toLowerCase();
return normalized ? normalized : undefined;
}
function sourceRouteTokens(hookContext: CodexDynamicToolHookContext | undefined): Set<string> {
const tokens = new Set<string>();
const currentTarget = normalizeRouteToken(hookContext?.currentMessagingTarget);
const currentChannel = normalizeRouteToken(hookContext?.currentChannelId);
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
if (currentTarget) {
tokens.add(currentTarget);
}
if (currentChannel) {
tokens.add(currentChannel);
}
const channelPrefixIndex = currentChannel?.indexOf(":") ?? -1;
if (channelPrefixIndex >= 0 && currentChannel) {
const unprefixedChannel = currentChannel.slice(channelPrefixIndex + 1);
if (unprefixedChannel) {
tokens.add(unprefixedChannel);
for (const segment of unprefixedChannel.split(/[;,]/u)) {
const token = normalizeRouteToken(segment);
if (token) {
tokens.add(token);
}
}
}
}
if (currentProvider && currentChannel?.startsWith(`${currentProvider}:`)) {
const unprefixedChannel = currentChannel.slice(currentProvider.length + 1);
if (unprefixedChannel) {
tokens.add(unprefixedChannel);
}
}
return tokens;
}
function routeTokenMatchesSource(
token: string | undefined,
hookContext: CodexDynamicToolHookContext | undefined,
): boolean {
const normalized = normalizeRouteToken(token);
return normalized !== undefined && sourceRouteTokens(hookContext).has(normalized);
}
function routeProviderMatchesSource(
provider: string | undefined,
hookContext: CodexDynamicToolHookContext | undefined,
): boolean {
const normalized = normalizeRouteToken(provider);
if (!normalized) {
return false;
}
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
const currentChannel = normalizeRouteToken(hookContext?.currentChannelId);
return currentProvider === normalized || currentChannel?.startsWith(`${normalized}:`) === true;
}
function routeTokenMatchesCurrentMessage(
token: string | number | undefined,
hookContext: CodexDynamicToolHookContext | undefined,
): boolean {
const normalized = normalizeRouteToken(token);
return (
normalized !== undefined && normalized === normalizeRouteToken(hookContext?.currentMessageId)
);
}
function readRouteToken(record: Record<string, unknown>, key: string): string | number | undefined {
const value = record[key];
return typeof value === "string" || typeof value === "number" ? value : undefined;
}
function explicitRouteTokensMismatchCurrent(
args: Record<string, unknown>,
keys: readonly string[],
currentToken: string | number | undefined,
): boolean {
const normalizedCurrent = normalizeRouteToken(currentToken);
if (!normalizedCurrent) {
return false;
}
return keys.some((key) => {
const normalized = normalizeRouteToken(readRouteToken(args, key));
return normalized !== undefined && normalized !== normalizedCurrent;
});
}
function explicitThreadRouteTargetsNonSource(
args: Record<string, unknown>,
hookContext: CodexDynamicToolHookContext | undefined,
messagingTarget: MessagingToolSend | undefined,
): boolean {
const normalizedCurrentThread = normalizeRouteToken(hookContext?.currentThreadId);
const explicitThreadTokens = [
...EXPLICIT_MESSAGE_THREAD_KEYS.map((key) => normalizeRouteToken(readRouteToken(args, key))),
normalizeRouteToken(messagingTarget?.threadId),
].filter((value): value is string => value !== undefined);
if (explicitThreadTokens.length === 0) {
return false;
}
return (
normalizedCurrentThread === undefined ||
explicitThreadTokens.some((value) => value !== normalizedCurrentThread)
);
}
function replyReceiptMatchesCurrentMessage(
value: unknown,
hookContext: CodexDynamicToolHookContext | undefined,
depth = 0,
): boolean {
if (depth > 4 || value === null) {
return false;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed || !["{", "["].includes(trimmed[0] ?? "")) {
return false;
}
try {
return replyReceiptMatchesCurrentMessage(JSON.parse(trimmed), hookContext, depth + 1);
} catch {
return false;
}
}
if (typeof value !== "object") {
return false;
}
if (Array.isArray(value)) {
return value.some((item) => replyReceiptMatchesCurrentMessage(item, hookContext, depth + 1));
}
const record = value as Record<string, unknown>;
for (const key of ["repliedTo", "replyTo", "replyToId", "replyToIdFull"]) {
if (
routeTokenMatchesCurrentMessage(
typeof record[key] === "string" ? record[key] : undefined,
hookContext,
)
) {
return true;
}
}
for (const key of [
"content",
"details",
"payload",
"receipt",
"result",
"results",
"sendResult",
"text",
]) {
if (replyReceiptMatchesCurrentMessage(record[key], hookContext, depth + 1)) {
return true;
}
}
return false;
}
function hasExplicitNonSourceMessageRoute(
args: Record<string, unknown>,
hookContext: CodexDynamicToolHookContext | undefined,
messagingTarget: MessagingToolSend | undefined,
): boolean {
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
for (const key of EXPLICIT_MESSAGE_PROVIDER_KEYS) {
const provider = normalizeRouteToken(typeof args[key] === "string" ? args[key] : undefined);
if (
provider &&
currentProvider !== provider &&
!routeProviderMatchesSource(provider, hookContext)
) {
return true;
}
}
const targetValues = [
...EXPLICIT_MESSAGE_TARGET_KEYS.map((key) =>
typeof args[key] === "string" ? args[key] : undefined,
),
...(Array.isArray(args.targets)
? args.targets.map((value) => (typeof value === "string" ? value : undefined))
: []),
].filter((value): value is string => normalizeRouteToken(value) !== undefined);
if (explicitThreadRouteTargetsNonSource(args, hookContext, messagingTarget)) {
return true;
}
if (
explicitRouteTokensMismatchCurrent(
args,
EXPLICIT_MESSAGE_REPLY_KEYS,
hookContext?.currentMessageId,
)
) {
return true;
}
if (targetValues.length === 0) {
return false;
}
if (targetValues.some((value) => !routeTokenMatchesSource(value, hookContext))) {
return true;
}
return (
messagingTarget?.to !== undefined && !routeTokenMatchesSource(messagingTarget.to, hookContext)
);
}
/** Runtime bridge returned to Codex app-server attempt code. */
export type CodexDynamicToolBridge = {
availableSpecs: CodexDynamicToolSpec[];
@@ -329,7 +110,6 @@ export type CodexDynamicToolBridge = {
) => Promise<CodexDynamicToolCallResponse>;
telemetry: {
didSendViaMessagingTool: boolean;
didDeliverSourceReplyViaMessageTool: boolean;
messagingToolSentTexts: string[];
messagingToolSentMediaUrls: string[];
messagingToolSentTargets: MessagingToolSend[];
@@ -348,10 +128,6 @@ export const CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE = "openclaw";
// Keep OpenClaw session spawning searchable in Codex mode so Codex's native
// spawn_agent remains the primary Codex subagent surface.
const ALWAYS_DIRECT_DYNAMIC_TOOL_NAMES = new Set(["sessions_yield"]);
const EXPLICIT_MESSAGE_PROVIDER_KEYS = ["channel", "provider"];
const EXPLICIT_MESSAGE_TARGET_KEYS = ["target", "to", "channelId"];
const EXPLICIT_MESSAGE_THREAD_KEYS = ["threadId", "thread_id", "messageThreadId", "topicId"];
const EXPLICIT_MESSAGE_REPLY_KEYS = ["replyTo", "replyToId", "replyToIdFull"];
const DEFAULT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS = 16_000;
/**
@@ -396,7 +172,6 @@ export function createCodexDynamicToolBridge(params: {
emitQuarantinedDynamicToolDiagnostics(quarantinedTools, params.hookContext);
const telemetry: CodexDynamicToolBridge["telemetry"] = {
didSendViaMessagingTool: false,
didDeliverSourceReplyViaMessageTool: false,
messagingToolSentTexts: [],
messagingToolSentMediaUrls: [],
messagingToolSentTargets: [],
@@ -531,11 +306,7 @@ export function createCodexDynamicToolBridge(params: {
void runAgentHarnessAfterToolCallHook({
toolName,
toolCallId: call.callId,
runId: toolResultHookContext.runId,
agentId: toolResultHookContext.agentId,
sessionId: toolResultHookContext.sessionId,
sessionKey: toolResultHookContext.sessionKey,
channelId: toolResultHookContext.channelId,
...toolResultHookContext,
startArgs: executedArgs,
result,
startedAt,
@@ -554,9 +325,10 @@ export function createCodexDynamicToolBridge(params: {
executedArgs,
params.hookContext?.currentChannelProvider,
);
const messagingTarget = isMessagingTool(toolName)
? extractMessagingToolSend(toolName, messagingTelemetryArgs, messagingContext)
: undefined;
const messagingTarget =
isMessagingTool(toolName) && isMessagingToolSendAction(toolName, executedArgs)
? extractMessagingToolSend(toolName, messagingTelemetryArgs, messagingContext)
: undefined;
const confirmedMessagingTarget =
!rawIsError && messagingTarget
? extractMessagingToolSendResult(messagingTarget, telemetryRawResult)
@@ -578,46 +350,12 @@ export function createCodexDynamicToolBridge(params: {
},
terminalType,
);
const blocksSourceReplyTermination = hasExplicitNonSourceMessageRoute(
executedArgs,
params.hookContext,
confirmedMessagingTarget,
);
const deliveredSourceReply = isDeliveredMessageToolOnlySourceReplyResult({
sourceReplyDeliveryMode: params.hookContext?.sourceReplyDeliveryMode,
toolName,
args: executedArgs,
result,
hookResult: rawResult,
isError: resultIsError,
allowExplicitSourceRoute: !blocksSourceReplyTermination,
});
const receiptConfirmedSourceReply =
params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" &&
toolName === "message" &&
normalizeRouteToken(
typeof executedArgs.action === "string" ? executedArgs.action : undefined,
) === "reply" &&
!resultIsError &&
!blocksSourceReplyTermination &&
(replyReceiptMatchesCurrentMessage(rawResult, params.hookContext) ||
replyReceiptMatchesCurrentMessage(result, params.hookContext));
const toolConfirmedSourceReply =
params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" &&
toolName === "message" &&
!resultIsError &&
(rawResult.terminate === true || result.terminate === true);
if (deliveredSourceReply || receiptConfirmedSourceReply || toolConfirmedSourceReply) {
telemetry.didDeliverSourceReplyViaMessageTool = true;
}
withDynamicToolTermination(
response,
rawResult.terminate === true ||
result.terminate === true ||
isToolResultYield(rawResult) ||
isToolResultYield(result) ||
deliveredSourceReply ||
receiptConfirmedSourceReply,
isToolResultYield(result),
);
const asyncStarted =
isAsyncStartedToolResult(rawResult) || isAsyncStartedToolResult(result);
@@ -661,11 +399,7 @@ export function createCodexDynamicToolBridge(params: {
void runAgentHarnessAfterToolCallHook({
toolName,
toolCallId: call.callId,
runId: toolResultHookContext.runId,
agentId: toolResultHookContext.agentId,
sessionId: toolResultHookContext.sessionId,
sessionKey: toolResultHookContext.sessionKey,
channelId: toolResultHookContext.channelId,
...toolResultHookContext,
startArgs: executedArgs,
error: errorMessage,
startedAt,
@@ -956,13 +690,35 @@ function dedupeQuarantinedDynamicTools(
function toToolResultHookContext(
ctx: CodexDynamicToolHookContext | undefined,
): CodexToolResultHookContext {
const { agentId, sessionId, sessionKey, runId, channelId } = ctx ?? {};
const {
agentId,
sessionId,
sessionKey,
runId,
jobId,
trace,
trigger,
messageProvider,
channel,
chatId,
senderId,
channelId,
channelContext,
} = ctx ?? {};
return {
...(agentId && { agentId }),
...(sessionId && { sessionId }),
...(sessionKey && { sessionKey }),
...(runId && { runId }),
...(jobId && { jobId }),
...(trace && { trace }),
...(trigger && { trigger }),
...(messageProvider && { messageProvider }),
...(channel && { channel }),
...(chatId && { chatId }),
...(senderId && { senderId }),
...(channelId && { channelId }),
...(channelContext && { channelContext }),
};
}
@@ -1057,7 +813,7 @@ function collectToolTelemetry(params: {
}
if (
!isMessagingTool(params.toolName) ||
(!isMessagingToolSendAction(params.toolName, params.args) && !params.messagingTarget)
!isMessagingToolSendAction(params.toolName, params.args)
) {
return;
}

View File

@@ -794,19 +794,6 @@ describe("CodexAppServerEventProjector", () => {
expect(result.toolMediaUrls).toStrictEqual([]);
});
it("propagates message-tool-only source reply delivery telemetry", async () => {
const projector = await createProjector();
const result = projector.buildResult({
...buildEmptyToolTelemetry(),
didSendViaMessagingTool: true,
didDeliverSourceReplyViaMessageTool: true,
});
expect(result.didSendViaMessagingTool).toBe(true);
expect(result.didDeliverSourceReplyViaMessageTool).toBe(true);
});
it("does not promote repeated tool progress text to the final assistant reply", async () => {
const onToolResult = vi.fn();
const projector = await createProjector({
@@ -2558,15 +2545,36 @@ describe("CodexAppServerEventProjector", () => {
});
});
it("emits after_tool_call observations for Codex-native tool item completions", async () => {
it("keeps resolved hook identity authoritative for Codex-native tool completions", async () => {
const afterToolCall = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "after_tool_call", handler: afterToolCall }]),
);
const projector = await createProjector({
const projectorParams = {
...(await createParams()),
agentId: "main",
sessionKey: "agent:main:session-1",
agentId: "raw-agent",
sessionId: "raw-session",
sessionKey: "agent:raw:session-1",
runId: "raw-run",
};
const projector = await createProjector(projectorParams, {
toolHookContext: {
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
jobId: "job-1",
trigger: "user",
messageProvider: "discord-voice",
channel: "discord",
chatId: "channel-1",
senderId: "user-1",
channelId: "channel-1",
channelContext: {
sender: { id: "user-1" },
chat: { id: "channel-1" },
},
},
});
await projector.handleNotification(
@@ -2623,6 +2631,17 @@ describe("CodexAppServerEventProjector", () => {
expect(context.sessionId).toBe("session-1");
expect(context.sessionKey).toBe("agent:main:session-1");
expect(context.runId).toBe("run-1");
expect(context.jobId).toBe("job-1");
expect(context.trigger).toBe("user");
expect(context.messageProvider).toBe("discord-voice");
expect(context.channel).toBe("discord");
expect(context.chatId).toBe("channel-1");
expect(context.senderId).toBe("user-1");
expect(context.channelId).toBe("channel-1");
expect(context.channelContext).toEqual({
sender: { id: "user-1" },
chat: { id: "channel-1" },
});
expect(context.toolName).toBe("bash");
expect(context.toolCallId).toBe("cmd-observed");
});

View File

@@ -18,6 +18,7 @@ import {
type HeartbeatToolResponse,
type MessagingToolSend,
type MessagingToolSourceReplyPayload,
type ToolHookRunContext,
type ToolProgressDetailMode,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime";
@@ -53,7 +54,6 @@ import { attachCodexMirrorIdentity, buildCodexUserPromptMessage } from "./transc
export type CodexAppServerToolTelemetry = {
didSendViaMessagingTool: boolean;
didDeliverSourceReplyViaMessageTool?: boolean;
messagingToolSentTexts: string[];
messagingToolSentMediaUrls: string[];
messagingToolSentTargets: MessagingToolSend[];
@@ -66,6 +66,7 @@ export type CodexAppServerToolTelemetry = {
export type CodexAppServerEventProjectorOptions = {
nativePostToolUseRelayEnabled?: boolean;
toolHookContext?: ToolHookRunContext;
onNativeToolResultRecorded?: () => void | Promise<void>;
trajectoryRecorder?: CodexTrajectoryRecorder | null;
};
@@ -413,8 +414,6 @@ export class CodexAppServerEventProjector {
currentAttemptAssistant,
...(this.lastNativeToolError ? { lastToolError: this.lastNativeToolError } : {}),
didSendViaMessagingTool: toolTelemetry.didSendViaMessagingTool,
didDeliverSourceReplyViaMessageTool:
toolTelemetry.didDeliverSourceReplyViaMessageTool === true,
messagingToolSentTexts: toolTelemetry.messagingToolSentTexts,
messagingToolSentMediaUrls: toolTelemetry.messagingToolSentMediaUrls,
messagingToolSentTargets: toolTelemetry.messagingToolSentTargets,
@@ -1369,6 +1368,9 @@ export class CodexAppServerEventProjector {
agentId: this.params.agentId,
sessionId: this.params.sessionId,
sessionKey: this.params.sessionKey,
// The attempt boundary resolves aliases and sandbox session identity once.
// Keep that canonical snapshot authoritative over optional raw projector params.
...this.options.toolHookContext,
startArgs: itemToolArgs(item) ?? {},
...(result !== undefined ? { result } : {}),
...(error ? { error } : {}),

View File

@@ -8,6 +8,7 @@ import {
type EmbeddedRunAttemptParams,
type NativeHookRelayEvent,
type NativeHookRelayRegistrationHandle,
type ToolHookRunContext,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
addTimerTimeoutGraceMs,
@@ -121,6 +122,7 @@ export function createCodexNativeHookRelay(params: {
config: EmbeddedRunAttemptParams["config"];
runId: string;
channelId?: string;
toolHookContext?: ToolHookRunContext;
attemptTimeoutMs: number;
startupTimeoutMs: number;
turnStartTimeoutMs: number;
@@ -146,6 +148,7 @@ export function createCodexNativeHookRelay(params: {
...(params.config ? { config: params.config } : {}),
runId: params.runId,
...(params.channelId ? { channelId: params.channelId } : {}),
...(params.toolHookContext ? { toolHookContext: params.toolHookContext } : {}),
allowedEvents: params.events,
ttlMs: resolveCodexNativeHookRelayTtlMs({
explicitTtlMs: params.options?.ttlMs,

View File

@@ -1,9 +1,6 @@
// Codex tests cover run attemptynamic tools plugin behavior.
import path from "node:path";
import {
onAgentEvent,
type AgentEventPayload,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { onAgentEvent, type AgentEventPayload } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
emitTrustedDiagnosticEvent,
onInternalDiagnosticEvent,
@@ -609,6 +606,21 @@ describe("runCodexAppServerAttempt dynamic tools", () => {
}
});
it("prefers the current messaging target for hook channel fallback", () => {
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
params.messageChannel = "telegram";
params.messageProvider = "telegram";
params.messageTo = "telegram:stale-target";
params.currentMessagingTarget = "telegram:current-target";
expect(testing.resolveCodexAppServerHookChannelId(params, "agent:main:session-1")).toBe(
"current-target",
);
});
it("passes normalized channel context to app-server dynamic tool result hooks", async () => {
const afterToolCall = vi.fn();
initializeGlobalHookRunner(

View File

@@ -30,9 +30,7 @@ const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
web_search: "disabled",
});
function writeCodexAppServerBinding(
...args: Parameters<typeof writeRawCodexAppServerBinding>
) {
function writeCodexAppServerBinding(...args: Parameters<typeof writeRawCodexAppServerBinding>) {
const [sessionFile, binding, lookup] = args;
return writeRawCodexAppServerBinding(
sessionFile,
@@ -95,7 +93,15 @@ describe("runCodexAppServerAttempt native hook relay", () => {
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
params.messageChannel = "discord";
params.messageProvider = "discord-voice";
params.currentChannelId = "channel:target";
params.trigger = "user";
params.senderId = "user-1";
params.chatId = "native-target";
params.channelContext = {
sender: { id: "user-1", providerUserId: "discord-user-1" },
chat: { id: "native-target", guildId: "guild-1" },
};
const run = runCodexAppServerAttempt(params, {
nativeHookRelay: {
@@ -135,6 +141,22 @@ describe("runCodexAppServerAttempt native hook relay", () => {
threadId: "thread-1",
turnId: "turn-1",
autoApprove: true,
toolHookContext: {
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
trigger: "user",
messageProvider: "discord-voice",
channel: "discord",
channelId: "target",
chatId: "native-target",
senderId: "user-1",
channelContext: {
sender: { id: "user-1", providerUserId: "discord-user-1" },
chat: { id: "native-target", guildId: "guild-1" },
},
},
});
expect(approvalArgs?.nativeHookRelay).toMatchObject({
relayId,

View File

@@ -38,6 +38,7 @@ import {
type EmbeddedRunAttemptResult,
type NativeHookRelayEvent,
type NativeHookRelayRegistrationHandle,
type ToolHookRunContext,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime";
import {
@@ -248,6 +249,7 @@ import {
type CodexAppServerThreadLifecycleBinding,
type CodexContextEngineThreadBootstrapProjection,
} from "./thread-lifecycle.js";
import { buildCodexToolHookRunContext } from "./tool-hook-context.js";
import {
inferCodexDynamicToolMeta,
resolveCodexToolProgressDetailMode,
@@ -717,6 +719,14 @@ export async function runCodexAppServerAttempt(
});
}
const hookChannelId = resolveCodexAppServerHookChannelId(params, sandboxSessionKey);
const toolHookRunContext = buildCodexToolHookRunContext({
attempt: params,
agentId: sessionAgentId,
sessionId: params.sessionId,
sessionKey: sandboxSessionKey,
runId: params.runId,
channelId: hookChannelId,
});
preDynamicStartupStages.mark("context-engine-support");
const preDynamicSummary = preDynamicStartupStages.snapshot();
if (shouldWarnCodexDynamicToolBuildStageSummary(preDynamicSummary)) {
@@ -832,20 +842,14 @@ export async function runCodexAppServerAttempt(
}),
directToolNames: resolveCodexDynamicToolDirectNames(params),
hookContext: {
agentId: sessionAgentId,
...toolHookRunContext,
config: params.config,
sessionId: params.sessionId,
sessionKey: sandboxSessionKey,
runId: params.runId,
channelId: hookChannelId,
currentChannelProvider: resolveCodexMessageToolProvider(params),
currentChannelId: params.currentChannelId,
currentMessagingTarget: params.currentMessagingTarget,
currentMessageId: params.currentMessageId,
currentThreadId: params.currentThreadTs,
replyToMode: params.replyToMode,
hasRepliedRef: params.hasRepliedRef,
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
onToolOutcome: onCodexToolOutcome,
allocateToolOutcomeOrdinal: allocateCodexToolOutcomeOrdinal,
},
@@ -1446,6 +1450,7 @@ export async function runCodexAppServerAttempt(
config: params.config,
runId: params.runId,
channelId: hookChannelId,
toolHookContext: toolHookRunContext,
attemptTimeoutMs: params.timeoutMs,
startupTimeoutMs,
turnStartTimeoutMs: params.timeoutMs,
@@ -2152,6 +2157,7 @@ export async function runCodexAppServerAttempt(
method: request.method,
params: request.params,
paramsForRun: params,
toolHookContext: toolHookRunContext,
threadId: thread.threadId,
turnId,
nativeHookRelay,
@@ -2763,6 +2769,7 @@ export async function runCodexAppServerAttempt(
nativePostToolUseRelayEnabled:
nativeHookRelay?.allowedEvents.includes("post_tool_use") === true &&
nativeHookRelay.shouldRelayEvent("post_tool_use"),
toolHookContext: toolHookRunContext,
trajectoryRecorder,
onNativeToolResultRecorded: maybeAnnounceFastModeAutoOff,
},
@@ -3432,6 +3439,7 @@ function handleApprovalRequest(params: {
method: string;
params: JsonValue | undefined;
paramsForRun: EmbeddedRunAttemptParams;
toolHookContext: ToolHookRunContext;
threadId: string;
turnId: string;
nativeHookRelay?: NativeHookRelayRegistrationHandle;
@@ -3445,6 +3453,7 @@ function handleApprovalRequest(params: {
method: params.method,
requestParams: params.params,
paramsForRun: params.paramsForRun,
toolHookContext: params.toolHookContext,
threadId: params.threadId,
turnId: params.turnId,
nativeHookRelay: params.nativeHookRelay,

View File

@@ -861,9 +861,12 @@ describe("runCodexAppServerSideQuestion", () => {
).toMatchObject({
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionKey: "agent:main:runtime-policy",
runId: "run-side-1",
channelId: "voice-room",
toolHookContext: {
sessionKey: "agent:main:runtime-policy",
},
allowedEvents: ["pre_tool_use", "post_tool_use", "before_agent_finalize"],
});
return threadResult("side-thread");
@@ -889,6 +892,7 @@ describe("runCodexAppServerSideQuestion", () => {
runCodexAppServerSideQuestion(
sideParams({
sessionKey: "agent:main:session-1",
sandboxSessionKey: "agent:main:runtime-policy",
messageChannel: "discord",
messageProvider: "discord-voice",
currentChannelId: "discord:voice-room",
@@ -971,6 +975,7 @@ describe("runCodexAppServerSideQuestion", () => {
runCodexAppServerSideQuestion(
sideParams({
sessionKey: "agent:main:session-1",
sandboxSessionKey: "agent:main:runtime-policy",
messageChannel: "discord",
messageProvider: "discord-voice",
opts: { runId: "run-side-approval" },
@@ -988,6 +993,7 @@ describe("runCodexAppServerSideQuestion", () => {
threadId?: string;
turnId?: string;
paramsForRun?: { messageChannel?: string; messageProvider?: string };
toolHookContext?: { sessionKey?: string };
nativeHookRelay?: { relayId?: string; allowedEvents?: readonly string[] };
}
| undefined;
@@ -1007,6 +1013,9 @@ describe("runCodexAppServerSideQuestion", () => {
messageChannel: "discord",
messageProvider: "discord-voice",
},
toolHookContext: {
sessionKey: "agent:main:runtime-policy",
},
});
expect(approvalArgs?.nativeHookRelay).toMatchObject({
relayId: relayIdDuringFork,
@@ -1482,6 +1491,14 @@ describe("runCodexAppServerSideQuestion", () => {
});
it("bridges side-thread dynamic tool requests to OpenClaw tools", async () => {
const beforeToolCall = vi.fn();
const afterToolCall = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([
{ hookName: "before_tool_call", handler: beforeToolCall },
{ hookName: "after_tool_call", handler: afterToolCall },
]),
);
const client = createFakeClient();
let toolResponse: unknown;
client.request.mockImplementation(async (method: string) => {
@@ -1527,6 +1544,13 @@ describe("runCodexAppServerSideQuestion", () => {
expect(toolArguments).toEqual({ topic: "AGENTS.md" });
expect(toolSignal).toBeInstanceOf(AbortSignal);
expect(toolOptions).toBeUndefined();
expect(beforeToolCall).toHaveBeenCalledTimes(1);
expect(mockCall(beforeToolCall)[1]).toMatchObject({ sessionKey: "session-1" });
await vi.waitFor(() => expect(afterToolCall).toHaveBeenCalledTimes(1));
expect(mockCall(afterToolCall)[1]).toMatchObject({ sessionKey: "session-1" });
expect(createOpenClawCodingToolsMock).toHaveBeenCalledWith(
expect.objectContaining({ sessionKey: "session-1" }),
);
expect(toolResponse).toEqual({
success: true,
contentItems: [{ type: "inputText", text: "tool output" }],
@@ -1610,14 +1634,29 @@ describe("runCodexAppServerSideQuestion", () => {
expect(activeDiagnosticToolKeys(diagnosticEvents)).toEqual(new Set());
});
it("normalizes hook channel ids for side-thread dynamic tool requests", async () => {
it("preserves requester identity while normalizing side-thread hook channels", async () => {
const afterToolCall = vi.fn();
const beforeToolCall = vi.fn((...args: unknown[]) => {
const context = args[1] as { channelId?: string };
expect(context.channelId).toBe("voice-room");
const context = args[1] as Record<string, unknown>;
expect(context).toMatchObject({
sessionKey: "agent:main:runtime-policy",
messageProvider: "discord-voice",
channel: "discord",
channelId: "voice-room",
chatId: "native-voice-chat",
senderId: "sender-1",
channelContext: {
sender: { id: "sender-1", providerUserId: "discord-user-1" },
chat: { id: "native-voice-chat", guildId: "guild-1" },
},
});
return undefined;
});
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "before_tool_call", handler: beforeToolCall }]),
createMockPluginRegistry([
{ hookName: "before_tool_call", handler: beforeToolCall },
{ hookName: "after_tool_call", handler: afterToolCall },
]),
);
const client = createFakeClient();
client.request.mockImplementation(async (method: string) => {
@@ -1657,17 +1696,48 @@ describe("runCodexAppServerSideQuestion", () => {
await expect(
runCodexAppServerSideQuestion(
sideParams({
sessionKey: "agent:main:conversation",
sandboxSessionKey: "agent:main:runtime-policy",
messageChannel: "discord",
messageProvider: "discord-voice",
currentChannelId: "discord:voice-room",
chatId: "native-voice-chat",
senderId: "sender-1",
channelContext: {
sender: { id: "sender-1", providerUserId: "discord-user-1" },
chat: { id: "native-voice-chat", guildId: "guild-1" },
},
}),
),
).resolves.toEqual({ text: "Tool answer." });
expect(beforeToolCall).toHaveBeenCalledTimes(1);
await vi.waitFor(() => expect(afterToolCall).toHaveBeenCalledTimes(1));
expect(mockCall(afterToolCall)[1]).toMatchObject({
sessionKey: "agent:main:runtime-policy",
messageProvider: "discord-voice",
channel: "discord",
channelId: "voice-room",
chatId: "native-voice-chat",
});
expect(createOpenClawCodingToolsMock).toHaveBeenCalledWith(
expect.objectContaining({ hookChannelId: "voice-room" }),
expect.objectContaining({
sessionKey: "agent:main:runtime-policy",
runSessionKey: "agent:main:conversation",
messageChannel: "discord",
messageProvider: "discord",
toolPolicyMessageProvider: "discord-voice",
hookChannelId: "voice-room",
chatId: "native-voice-chat",
hookChannelContext: {
sender: { id: "sender-1", providerUserId: "discord-user-1" },
chat: { id: "native-voice-chat", guildId: "guild-1" },
},
}),
);
expect(
(mockCall(createOpenClawCodingToolsMock)[0] as { channelContext?: unknown }).channelContext,
).toBeUndefined();
expect(toolExecuteMock).toHaveBeenCalledTimes(1);
});

View File

@@ -1,6 +1,5 @@
// Codex plugin module implements side question behavior.
import {
buildAgentHookContextChannelFields,
embeddedAgentLog,
formatErrorMessage,
resolveAgentDir,
@@ -16,6 +15,7 @@ import {
type EmbeddedRunAttemptParams,
type NativeHookRelayEvent,
type NativeHookRelayRegistrationHandle,
type ToolHookRunContext,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { loadExecApprovals } from "openclaw/plugin-sdk/exec-approvals-runtime";
import { resolveCodexAppServerForModelProvider } from "./app-server-policy.js";
@@ -89,6 +89,7 @@ import {
resolveCodexBindingModelProviderFallback,
resolveReasoningEffort,
} from "./thread-lifecycle.js";
import { buildCodexToolHookRunContext } from "./tool-hook-context.js";
import { filterToolsForVisionInputs } from "./vision-tools.js";
import {
resolveCodexWebSearchPlan,
@@ -206,9 +207,21 @@ export async function runCodexAppServerSideQuestion(
});
const cwd = binding.cwd || params.workspaceDir || process.cwd();
const sideRunParams = buildSideRunAttemptParams(params, { cwd, authProfileId });
const toolHookSessionKey =
sideRunParams.sandboxSessionKey?.trim() ||
sideRunParams.sessionKey?.trim() ||
sideRunParams.sessionId ||
sessionAgentId;
const toolHookRunContext = buildCodexToolHookRunContext({
attempt: sideRunParams,
agentId: sessionAgentId,
sessionId: sideRunParams.sessionId,
sessionKey: toolHookSessionKey,
runId: sideRunParams.runId,
});
const nativeExecutionBlock = resolveCodexNativeExecutionBlock({
config: sideRunParams.config,
sessionKey: sideRunParams.sandboxSessionKey?.trim() || sideRunParams.sessionKey,
sessionKey: toolHookSessionKey,
sessionId: sideRunParams.sessionId,
surface: "/btw side-question mode",
});
@@ -287,6 +300,7 @@ export async function runCodexAppServerSideQuestion(
nativeToolSurfaceEnabled,
nativeProviderWebSearchSupport,
signal: runAbortController.signal,
toolHookContext: toolHookRunContext,
});
removeRequestHandler = client.addRequestHandler(async (request) => {
if (request.method === "account/chatgptAuthTokens/refresh") {
@@ -319,19 +333,20 @@ export async function runCodexAppServerSideQuestion(
method: request.method,
requestParams: request.params,
paramsForRun: sideRunParams,
toolHookContext: toolHookRunContext,
threadId: childThreadId,
turnId,
nativeHookRelay,
execPolicy,
execReviewerAgentId: sessionAgentId,
internalExecAutoReview: modelScopedAppServer.approvalsReviewer === "user",
autoApprove: shouldAutoApproveCodexAppServerApprovals({
approvalPolicy,
networkProxy: modelScopedAppServer.networkProxy,
sandbox,
}),
signal: runAbortController.signal,
});
execPolicy,
execReviewerAgentId: sessionAgentId,
internalExecAutoReview: modelScopedAppServer.approvalsReviewer === "user",
autoApprove: shouldAutoApproveCodexAppServerApprovals({
approvalPolicy,
networkProxy: modelScopedAppServer.networkProxy,
sandbox,
}),
signal: runAbortController.signal,
});
}
if (request.method !== "item/tool/call") {
return undefined;
@@ -388,15 +403,11 @@ export async function runCodexAppServerSideQuestion(
events: nativeHookRelayEvents,
agentId: sessionAgentId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionKey: toolHookRunContext.sessionKey,
config: params.cfg,
runId: sideRunParams.runId,
channelId: buildAgentHookContextChannelFields({
sessionKey: params.sessionKey,
messageChannel: params.messageChannel,
messageProvider: params.messageProvider,
currentChannelId: params.currentChannelId,
}).channelId,
channelId: toolHookRunContext.channelId,
toolHookContext: toolHookRunContext,
requestTimeoutMs: appServer.requestTimeoutMs,
completionTimeoutMs: Math.max(
appServer.turnCompletionIdleTimeoutMs,
@@ -419,12 +430,12 @@ export async function runCodexAppServerSideQuestion(
nativeCodeModeEnabled: nativeToolSurfaceEnabled,
nativeCodeModeOnlyEnabled: appServer.codeModeOnly,
});
const threadConfig =
mergeCodexThreadConfigs(
nativeHookRelayConfig,
runtimeThreadConfig,
modelScopedAppServer.networkProxy?.configPatch,
) ?? runtimeThreadConfig;
const threadConfig =
mergeCodexThreadConfigs(
nativeHookRelayConfig,
runtimeThreadConfig,
modelScopedAppServer.networkProxy?.configPatch,
) ?? runtimeThreadConfig;
const forkResponse = assertCodexThreadForkResponse(
await forkCodexSideThread(
client,
@@ -436,7 +447,7 @@ export async function runCodexAppServerSideQuestion(
cwd,
approvalPolicy,
approvalsReviewer: modelScopedAppServer.approvalsReviewer,
...(modelScopedAppServer.networkProxy ? {} : { sandbox }),
...(modelScopedAppServer.networkProxy ? {} : { sandbox }),
...(serviceTier ? { serviceTier } : {}),
config: threadConfig,
developerInstructions: SIDE_DEVELOPER_INSTRUCTIONS,
@@ -542,6 +553,7 @@ function registerCodexSideNativeHookRelay(params: {
config: EmbeddedRunAttemptParams["config"];
runId: string;
channelId?: string;
toolHookContext?: ToolHookRunContext;
requestTimeoutMs: number;
completionTimeoutMs: number;
signal: AbortSignal;
@@ -557,6 +569,7 @@ function registerCodexSideNativeHookRelay(params: {
...(params.config ? { config: params.config } : {}),
runId: params.runId,
...(params.channelId ? { channelId: params.channelId } : {}),
...(params.toolHookContext ? { toolHookContext: params.toolHookContext } : {}),
allowedEvents: params.events,
ttlMs: resolveCodexSideNativeHookRelayTtlMs({
explicitTtlMs: params.options.ttlMs,
@@ -596,6 +609,7 @@ function buildSideRunAttemptParams(
provider: params.provider,
modelId: params.model,
model: params.runtimeModel ?? ({ id: params.model, provider: params.provider } as never),
trigger: "user" as const,
sessionId: params.sessionId,
sessionFile: params.sessionFile,
sessionKey: params.sessionKey,
@@ -616,6 +630,8 @@ function buildSideRunAttemptParams(
...(params.senderUsername !== undefined ? { senderUsername: params.senderUsername } : {}),
...(params.senderE164 !== undefined ? { senderE164: params.senderE164 } : {}),
...(params.senderIsOwner !== undefined ? { senderIsOwner: params.senderIsOwner } : {}),
...(params.chatId ? { chatId: params.chatId } : {}),
...(params.channelContext ? { channelContext: params.channelContext } : {}),
...(params.currentChannelId ? { currentChannelId: params.currentChannelId } : {}),
...(params.toolsAllow ? { toolsAllow: params.toolsAllow } : {}),
workspaceDir: options.cwd,
@@ -647,6 +663,7 @@ async function createCodexSideToolBridge(input: {
nativeToolSurfaceEnabled: boolean;
nativeProviderWebSearchSupport: CodexNativeWebSearchSupport;
signal: AbortSignal;
toolHookContext: ToolHookRunContext;
}): Promise<{ toolBridge: CodexDynamicToolBridge; webSearchPlan: CodexWebSearchPlan }> {
const runtimeModel =
input.params.runtimeModel ??
@@ -657,10 +674,7 @@ async function createCodexSideToolBridge(input: {
const createOpenClawCodingTools = (await import("openclaw/plugin-sdk/agent-harness"))
.createOpenClawCodingTools;
const sandboxSessionKey =
input.params.sandboxSessionKey?.trim() ||
input.params.sessionKey?.trim() ||
input.params.sessionId ||
input.sessionAgentId;
input.toolHookContext.sessionKey || input.params.sessionId || input.sessionAgentId;
const sandbox = await resolveSandboxContext({
config: input.params.cfg,
sessionKey: sandboxSessionKey,
@@ -696,6 +710,9 @@ async function createCodexSideToolBridge(input: {
workspaceDir: input.cwd,
}),
suppressManagedWebSearch: false,
trigger: input.toolHookContext.trigger,
jobId: input.toolHookContext.jobId,
messageChannel: input.params.messageChannel,
...(input.params.messageProvider || input.params.messageChannel
? {
messageProvider: messageToolProvider,
@@ -715,6 +732,8 @@ async function createCodexSideToolBridge(input: {
...(input.params.memberRoleIds ? { memberRoleIds: input.params.memberRoleIds } : {}),
...(input.params.spawnedBy !== undefined ? { spawnedBy: input.params.spawnedBy } : {}),
...(input.params.senderId !== undefined ? { senderId: input.params.senderId } : {}),
chatId: input.toolHookContext.chatId,
hookChannelContext: input.toolHookContext.channelContext,
...(input.params.senderName !== undefined ? { senderName: input.params.senderName } : {}),
...(input.params.senderUsername !== undefined
? { senderUsername: input.params.senderUsername }
@@ -724,12 +743,7 @@ async function createCodexSideToolBridge(input: {
? { senderIsOwner: input.params.senderIsOwner }
: {}),
...(input.params.currentChannelId ? { currentChannelId: input.params.currentChannelId } : {}),
hookChannelId: buildAgentHookContextChannelFields({
sessionKey: input.params.sessionKey,
messageChannel: input.params.messageChannel,
messageProvider: input.params.messageProvider,
currentChannelId: input.params.currentChannelId,
}).channelId,
hookChannelId: input.toolHookContext.channelId,
sandbox,
emitBeforeToolCallDiagnostics: false,
modelHasVision: runtimeModel.input?.includes("image") ?? false,
@@ -757,25 +771,15 @@ async function createCodexSideToolBridge(input: {
})
: requestedWebSearchPlan;
const exposedTools = tools.filter((tool) => tool.name !== "web_search");
const hookChannelFields = buildAgentHookContextChannelFields({
sessionKey: input.params.sessionKey,
messageChannel: input.params.messageChannel,
messageProvider: input.params.messageProvider,
currentChannelId: input.params.currentChannelId,
});
return {
toolBridge: createCodexDynamicToolBridge({
tools: exposedTools,
signal: input.signal,
loading: resolveCodexDynamicToolsLoading(input.pluginConfig),
hookContext: {
agentId: input.sessionAgentId,
...input.toolHookContext,
config: input.params.cfg,
sessionId: input.params.sessionId,
sessionKey: input.params.sessionKey,
runId: input.params.opts?.runId ?? `codex-btw:${input.params.sessionId}`,
currentChannelProvider: messageToolProvider,
...hookChannelFields,
},
}),
webSearchPlan,

View File

@@ -0,0 +1,41 @@
/** Builds one canonical requester-origin snapshot for Codex tool hook paths. */
import {
buildAgentHookContextOriginFields,
type EmbeddedRunAttemptParams,
type ToolHookRunContext,
} from "openclaw/plugin-sdk/agent-harness-runtime";
/** Build the plain run metadata shared by Codex before/after tool hook owners. */
export function buildCodexToolHookRunContext(params: {
attempt: EmbeddedRunAttemptParams;
agentId?: string;
sessionId?: string;
sessionKey?: string;
runId?: string;
channelId?: string;
}): ToolHookRunContext {
const attempt = params.attempt;
const agentId = params.agentId ?? attempt.agentId;
const sessionKey = params.sessionKey ?? attempt.sessionKey;
const sessionId = params.sessionId ?? attempt.sessionId;
const runId = params.runId ?? attempt.runId;
return {
...(agentId ? { agentId } : {}),
...(sessionKey ? { sessionKey } : {}),
...(sessionId ? { sessionId } : {}),
...(runId ? { runId } : {}),
...(attempt.jobId ? { jobId: attempt.jobId } : {}),
...(attempt.trigger ? { trigger: attempt.trigger } : {}),
...buildAgentHookContextOriginFields({
sessionKey,
messageChannel: attempt.messageChannel,
messageProvider: attempt.messageProvider ?? attempt.messageChannel,
currentChannelId: params.channelId ?? attempt.currentChannelId,
messageTo: attempt.currentMessagingTarget ?? attempt.messageTo,
trigger: attempt.trigger,
senderId: attempt.senderId,
chatId: attempt.chatId,
channelContext: attempt.channelContext,
}),
};
}

View File

@@ -338,7 +338,22 @@ describe("runCopilotAttempt", () => {
return { sdkTools: [], sourceTools: [] };
});
await runCopilotAttempt(makeParams(), {
const params = makeParams();
Object.assign(params, {
jobId: "job-1",
trigger: "user",
messageChannel: "slack",
messageProvider: "slack-voice",
currentChannelId: "C123",
chatId: "C123",
senderId: "U123",
channelContext: {
sender: { id: "U123", displayName: "Ada" },
chat: { id: "C123" },
},
});
await runCopilotAttempt(params, {
createToolBridge,
pool: makeFakePool(sdk),
});
@@ -385,7 +400,21 @@ describe("runCopilotAttempt", () => {
toolCallId: "tool-call-1",
toolName: "read",
}),
expect.objectContaining({ agentId: "agent-1", sessionId: "session-1" }),
expect.objectContaining({
agentId: "agent-1",
sessionId: "session-1",
jobId: "job-1",
trigger: "user",
messageProvider: "slack-voice",
channel: "slack",
chatId: "C123",
senderId: "U123",
channelId: "C123",
channelContext: {
sender: { id: "U123", displayName: "Ada" },
chat: { id: "C123" },
},
}),
);
});
@@ -1285,6 +1314,32 @@ describe("runCopilotAttempt", () => {
).toBe(sdkTools);
});
it("passes the session-resolved agent id to the tool bridge", async () => {
const sdk = makeFakeSdk();
const pool = makeFakePool(sdk);
const createToolBridge = vi.fn(async () => ({ sdkTools: [], sourceTools: [] }));
await runCopilotAttempt(
makeParams({
agentId: undefined,
sessionKey: "agent:beta:main",
config: {
agents: {
list: [{ id: "main" }, { id: "beta" }],
},
} as never,
}),
{ createToolBridge, pool },
);
expect(createToolBridge).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "beta",
sessionKey: "agent:beta:main",
}),
);
});
it("F6: sessionRef is populated after createSession so the tool bridge's onYield can abort the live SDK session", async () => {
const sdk = makeFakeSdk();
const pool = makeFakePool(sdk);

View File

@@ -9,6 +9,7 @@ import type {
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
buildAgentHookContextChannelFields,
buildAgentHookContextOriginFields,
detectAndLoadAgentHarnessPromptImages,
resolveAgentHarnessBeforePromptBuildResult,
resolveAttemptFsWorkspaceOnly,
@@ -367,6 +368,25 @@ export async function runCopilotAttempt(
...hookContextWindowFields,
...buildAgentHookContextChannelFields(input),
};
const toolHookRunContext = {
runId: input.runId,
jobId: input.jobId,
agentId: sessionAgentId,
sessionKey: sandboxSessionKey,
sessionId: input.sessionId,
trigger: input.trigger,
...buildAgentHookContextOriginFields({
sessionKey: sandboxSessionKey,
messageChannel: input.messageChannel,
messageProvider: input.messageProvider ?? input.messageChannel,
currentChannelId: input.currentChannelId,
messageTo: input.currentMessagingTarget ?? input.messageTo,
trigger: input.trigger,
senderId: input.senderId,
chatId: input.chatId,
channelContext: input.channelContext,
}),
};
const finishAttempt = (result: AgentHarnessAttemptResult) =>
finalizeCopilotAttempt(input, result, hookContext, attemptStartedAt, now);
@@ -564,7 +584,7 @@ export async function runCopilotAttempt(
const toolBridge = await createToolBridge({
modelProvider: modelRef.provider,
modelId: modelRef.id,
agentId: readString(params.agentId) ?? "copilot",
agentId: sessionAgentId,
sessionId: readString(input.sessionId) ?? "copilot-session",
sessionKey: readString((input as { sessionKey?: unknown }).sessionKey),
agentDir: readString(input.agentDir),
@@ -590,11 +610,7 @@ export async function runCopilotAttempt(
runAgentHarnessAfterToolCallHook({
toolName,
toolCallId,
runId: input.runId,
agentId: sessionAgentId,
sessionId: input.sessionId,
sessionKey: sandboxSessionKey,
channelId: hookContext.channelId,
...toolHookRunContext,
startArgs: args,
...(result !== undefined ? { result } : {}),
...(error ? { error } : {}),

View File

@@ -1,11 +1,17 @@
// Copilot tests cover tool bridge plugin behavior.
import type { Tool as SdkTool, ToolInvocation, ToolResultObject } from "@github/copilot-sdk";
import type { AnyAgentTool, SandboxContext } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
initializeGlobalHookRunner,
resetGlobalHookRunner,
} from "openclaw/plugin-sdk/hook-runtime";
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createCopilotToolBridge,
convertOpenClawToolToSdkTool,
supportsModelTools,
testing,
} from "./tool-bridge.js";
type FakeTool = AnyAgentTool & {
@@ -77,6 +83,7 @@ function runSdkTool(tool: SdkTool, args: unknown, invocation = makeInvocation())
}
afterEach(() => {
resetGlobalHookRunner();
vi.restoreAllMocks();
});
@@ -286,6 +293,79 @@ describe("createCopilotToolBridge", () => {
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["exec", "wait"]);
});
it("runs requester-aware policy before code-mode exec controls", async () => {
const beforeToolCall = vi.fn(() => ({
block: true,
blockReason: "blocked before code-mode execution",
}));
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "before_tool_call", handler: beforeToolCall }]),
);
const createOpenClawCodingTools = vi.fn(async () => [makeTool({ name: "read" })]);
const result = await createCopilotToolBridge({
agentId: "agent-1",
attemptParams: {
config: { tools: { codeMode: true } },
runId: "run-code-mode",
sessionId: "session-1",
sessionKey: "agent:main:main",
jobId: "job-1",
trigger: "user",
messageChannel: "slack",
messageProvider: "slack-voice",
currentChannelId: "slack:C123",
senderId: "U123",
channelContext: { sender: { id: "U123", displayName: "Ada" } },
} as never,
createOpenClawCodingTools,
modelId: "gpt-4o",
modelProvider: "github-copilot",
sessionId: "session-1",
});
const exec = result.sdkTools.find((tool) => tool.name === "exec");
if (!exec) {
throw new Error("missing code-mode exec control");
}
await runSdkTool(
exec,
{ code: "return 1;" },
makeInvocation({ toolCallId: "code-call-1", toolName: "exec" }),
);
expect(beforeToolCall).toHaveBeenCalledTimes(1);
expect(beforeToolCall).toHaveBeenCalledWith(
{
toolName: "exec",
params: { code: "return 1;", command: "return 1;" },
toolKind: "code_mode_exec",
toolInputKind: "javascript",
runId: "run-code-mode",
toolCallId: "code-call-1",
},
{
toolName: "exec",
toolKind: "code_mode_exec",
toolInputKind: "javascript",
agentId: "agent-1",
sessionKey: "agent:main:main",
sessionId: "session-1",
runId: "run-code-mode",
jobId: "job-1",
trigger: "user",
messageProvider: "slack-voice",
channel: "slack",
senderId: "U123",
toolCallId: "code-call-1",
channelId: "C123",
channelContext: {
sender: { id: "U123", displayName: "Ada" },
},
},
);
});
it("keeps code-mode controls visible when a narrow allowlist is active", async () => {
const createOpenClawCodingTools = vi.fn(async () => [
makeTool({ name: "fake_hidden" }),
@@ -420,7 +500,13 @@ describe("createCopilotToolBridge", () => {
currentMessagingTarget: "user:U123",
currentThreadTs: "1700000000.000100",
currentMessageId: "M-1",
messageProvider: "slack",
messageChannel: "slack",
messageProvider: "slack-voice",
chatId: "chat-1",
channelContext: {
sender: { id: "sender-1", displayName: "Ada" },
chat: { id: "chat-1", kind: "channel" },
},
messageTo: "U-1",
messageThreadId: "1700000000.000100",
replyToMode: "first",
@@ -454,7 +540,13 @@ describe("createCopilotToolBridge", () => {
currentMessagingTarget: "user:U123",
currentThreadTs: "1700000000.000100",
currentMessageId: "M-1",
messageProvider: "slack",
messageChannel: "slack",
messageProvider: "slack-voice",
chatId: "chat-1",
hookChannelContext: {
sender: { id: "sender-1", displayName: "Ada" },
chat: { id: "chat-1", kind: "channel" },
},
messageTo: "U-1",
messageThreadId: "1700000000.000100",
replyToMode: "first",
@@ -462,6 +554,7 @@ describe("createCopilotToolBridge", () => {
forceMessageTool: true,
enableHeartbeatTool: true,
});
expect(opts.channelContext).toBeUndefined();
});
it("falls back messageProvider to attemptParams.messageChannel when messageProvider is absent (codex parity)", async () => {
@@ -479,6 +572,63 @@ describe("createCopilotToolBridge", () => {
expect(getOpts().messageProvider).toBe("telegram");
});
it("uses messageTo when currentMessagingTarget is absent in tool hook routing", () => {
const context = testing.buildCopilotToolHookContext({
agentId: "agent-1",
messageChannel: "slack",
messageProvider: "slack",
messageTo: "user:U-only",
trigger: "user",
});
expect(context).toMatchObject({
channel: "slack",
messageProvider: "slack",
channelId: "U-only",
turnSourceChannel: "slack",
turnSourceTo: "user:U-only",
});
expect(context.chatId).toBeUndefined();
expect(context.channelContext).toBeUndefined();
});
it("resolves per-agent loop detection overrides for generated code-mode controls", () => {
const context = testing.buildCopilotToolHookContext({
agentId: "agent-1",
config: {
tools: {
loopDetection: {
enabled: true,
warningThreshold: 7,
detectors: { genericRepeat: true },
postCompactionGuard: { windowSize: 4 },
},
},
agents: {
list: [
{
id: "agent-1",
tools: {
loopDetection: {
enabled: false,
detectors: { pingPong: false },
postCompactionGuard: { windowSize: 2 },
},
},
},
],
},
},
});
expect(context.loopDetection).toEqual({
enabled: false,
warningThreshold: 7,
detectors: { genericRepeat: true, pingPong: false },
postCompactionGuard: { windowSize: 2 },
});
});
it("forwards authProfileStore, runId, config, and run hooks (onToolOutcome) from attemptParams", async () => {
const { createOpenClawCodingTools, getOpts } = captureCall();
const authProfileStore = { kind: "fake-store" } as never;

View File

@@ -7,15 +7,19 @@ import type {
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
applyEmbeddedAttemptToolsAllow,
buildAgentHookContextOriginFields,
buildEmbeddedAttemptToolRunContext,
extractToolErrorMessage,
getPluginToolMeta,
isSubagentSessionKey,
isToolWrappedWithBeforeToolCallHook,
isToolResultError,
resolveAttemptSpawnWorkspaceDir,
resolveEmbeddedAttemptToolConstructionPlan,
resolveModelAuthMode,
resolveToolLoopDetectionConfig,
sanitizeToolResult,
wrapToolWithBeforeToolCallHook,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { createAgentHarnessToolSurfaceRuntime } from "openclaw/plugin-sdk/agent-harness-tool-runtime";
@@ -143,6 +147,7 @@ export interface CopilotToolBridge {
export const SUPPORTED_TOOL_PROVIDERS: ReadonlySet<string> = new Set(["github-copilot"]);
const BASE_COPILOT_CODING_TOOL_NAMES = new Set(["edit", "read", "write"]);
const SHELL_COPILOT_CODING_TOOL_NAMES = new Set(["apply_patch", "exec", "process"]);
const CODE_MODE_CONTROL_TOOL_NAMES = new Set(["exec", "wait"]);
export function supportsModelTools(modelProvider: string): boolean {
return SUPPORTED_TOOL_PROVIDERS.has(modelProvider);
@@ -209,6 +214,7 @@ export async function createCopilotToolBridge(
},
toolSurfaceRuntime,
);
const toolHookContext = buildCopilotToolHookContext(toolOptions);
let sourceTools: unknown;
try {
@@ -230,9 +236,18 @@ export async function createCopilotToolBridge(
sourceTools as AnyAgentTool[],
toolSurfaceRuntime.runtimeToolAllowlist,
);
const compactedTools = toolSurfaceRuntime.compactTools(allowedSourceTools);
const compactedTools = toolSurfaceRuntime.compactTools(allowedSourceTools, {
hookContext: toolHookContext,
});
const hookedCompactedTools = compactedTools.tools.map((tool) =>
!toolSurfaceRuntime.codeModeControlsEnabled ||
!CODE_MODE_CONTROL_TOOL_NAMES.has(tool.name) ||
isToolWrappedWithBeforeToolCallHook(tool)
? tool
: wrapToolWithBeforeToolCallHook(tool, toolHookContext),
);
const plannedTools = filterCopilotToolsForConstructionPlan(
compactedTools.tools,
hookedCompactedTools,
effectiveToolPlan.codingToolConstructionPlan,
{ preserveToolNames: toolSurfaceRuntime.runtimeToolAllowlist },
);
@@ -263,6 +278,51 @@ export async function createCopilotToolBridge(
};
}
function buildCopilotToolHookContext(toolOptions: OpenClawCodingToolsOptions) {
const turnSourceChannel = toolOptions.messageChannel ?? toolOptions.messageProvider;
const messageTo = toolOptions.currentMessagingTarget ?? toolOptions.messageTo;
const turnSourceTo = messageTo ?? toolOptions.currentChannelId;
return {
agentId: toolOptions.agentId,
config: toolOptions.config,
cwd: toolOptions.cwd,
workspaceDir: toolOptions.workspaceDir,
sessionKey: toolOptions.sessionKey,
sessionId: toolOptions.sessionId,
runId: toolOptions.runId,
jobId: toolOptions.jobId,
trace: toolOptions.trace,
trigger: toolOptions.trigger,
...buildAgentHookContextOriginFields({
sessionKey: toolOptions.sessionKey,
messageChannel: toolOptions.messageChannel,
messageProvider: toolOptions.toolPolicyMessageProvider ?? toolOptions.messageProvider,
currentChannelId: toolOptions.hookChannelId ?? toolOptions.currentChannelId,
messageTo,
trigger: toolOptions.trigger,
senderId: toolOptions.senderId,
chatId: toolOptions.chatId,
channelContext: toolOptions.hookChannelContext ?? toolOptions.channelContext,
}),
...(turnSourceChannel ? { turnSourceChannel } : {}),
...(turnSourceTo ? { turnSourceTo } : {}),
...(toolOptions.agentAccountId ? { turnSourceAccountId: toolOptions.agentAccountId } : {}),
...(toolOptions.currentThreadTs ? { turnSourceThreadId: toolOptions.currentThreadTs } : {}),
loopDetection: resolveToolLoopDetectionConfig({
cfg: toolOptions.config,
agentId: toolOptions.agentId,
}),
onToolOutcome: toolOptions.onToolOutcome,
allocateToolOutcomeOrdinal: toolOptions.allocateToolOutcomeOrdinal,
};
}
/** Test-only access to requester-context construction. */
export const testing = {
buildCopilotToolHookContext: (toolOptions: unknown): Record<string, unknown> =>
buildCopilotToolHookContext(toolOptions as OpenClawCodingToolsOptions),
};
/**
* Builds the full `createOpenClawCodingTools` options bag mirroring the
* PI in-tree call at `src/agents/pi-embedded-runner/run/attempt.ts:1029-1117`.
@@ -349,7 +409,9 @@ function buildOpenClawCodingToolsOptions(
...a.execOverrides,
elevated: a.bashElevated,
},
messageChannel: a.messageChannel,
messageProvider: a.messageProvider ?? a.messageChannel,
toolPolicyMessageProvider: a.messageProvider ?? a.messageChannel,
agentAccountId: a.agentAccountId,
messageTo: a.messageTo,
messageThreadId: a.messageThreadId,
@@ -359,6 +421,7 @@ function buildOpenClawCodingToolsOptions(
memberRoleIds: a.memberRoleIds,
spawnedBy: a.spawnedBy,
senderId: a.senderId,
hookChannelContext: a.channelContext,
senderName: a.senderName,
senderUsername: a.senderUsername,
senderE164: a.senderE164,
@@ -394,6 +457,7 @@ function buildOpenClawCodingToolsOptions(
workspaceDir,
}),
currentChannelId: a.currentChannelId,
chatId: a.chatId,
currentMessagingTarget: a.currentMessagingTarget,
currentThreadTs: a.currentThreadTs,
currentMessageId: a.currentMessageId,

View File

@@ -89,7 +89,6 @@ async function expectPathMissing(targetPath: string): Promise<void> {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
resolveGlobalMap<string, unknown>(DREAMS_FILE_LOCKS_KEY).clear();
resolveGlobalMap<string, unknown>(NARRATIVE_SESSION_LOCKS_KEY).clear();
});
@@ -1193,7 +1192,6 @@ describe("generateAndAppendDreamNarrative", () => {
const storePath = path.join(sessionsDir, "sessions.json");
const orphanPath = path.join(sessionsDir, "orphan.jsonl");
const livePath = path.join(sessionsDir, "still-live.jsonl");
const normalTranscriptPath = path.join(sessionsDir, "normal-user-session.jsonl");
const updatedAt = Date.now();
await sessionStoreRuntimeModule.saveSessionStore(
storePath,
@@ -1210,25 +1208,25 @@ describe("generateAndAppendDreamNarrative", () => {
sessionId: "still-missing-non-dreaming",
updatedAt,
},
"agent:main:dreaming-narrative-corrupt-normal": {
sessionId: "normal-user-session",
updatedAt,
},
},
{ skipMaintenance: true },
);
await fs.writeFile(orphanPath, '{"runId":"dreaming-narrative-light-123"}\n', "utf-8");
await fs.writeFile(livePath, '{"runId":"dreaming-narrative-light-keep"}\n', "utf-8");
await fs.writeFile(normalTranscriptPath, '{"runId":"ordinary-user-session"}\n', "utf-8");
const oldDate = new Date(Date.now() - 600_000);
await fs.utimes(orphanPath, oldDate, oldDate);
await fs.utimes(livePath, oldDate, oldDate);
await fs.utimes(normalTranscriptPath, oldDate, oldDate);
vi.spyOn(runtimeConfigSnapshotModule, "getRuntimeConfig").mockReturnValue({
session: {},
} as never);
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
vi.spyOn(sessionStoreRuntimeModule, "resolveStorePath").mockImplementation(((
_store: string | undefined,
{ agentId }: { agentId: string },
) => {
expect(agentId).toBe("main");
return storePath;
}) as typeof sessionStoreRuntimeModule.resolveStorePath);
vi.spyOn(memoryCoreHostRuntimeCoreModule, "resolveStateDir").mockReturnValue(stateDir);
const subagent = createMockSubagent("The repository whispered of forgotten endpoints.");
@@ -1245,13 +1243,11 @@ describe("generateAndAppendDreamNarrative", () => {
skipCache: true,
}) as Record<string, unknown>;
expect(updatedStore).not.toHaveProperty("agent:main:dreaming-narrative-light-1");
expect(updatedStore).not.toHaveProperty("agent:main:dreaming-narrative-corrupt-normal");
expect(updatedStore).toHaveProperty("agent:main:kept-session");
expect(updatedStore).toHaveProperty("agent:main:telegram:group:dreaming-narrative-room");
const sessionFiles = await fs.readdir(sessionsDir);
expect(sessionFiles.filter((file) => file.startsWith("orphan.jsonl.deleted."))).not.toEqual([]);
expect(sessionFiles).toContain("still-live.jsonl");
expect(sessionFiles).toContain("normal-user-session.jsonl");
expectLogIncludes(logger.info, "dreaming cleanup scrubbed");
});
@@ -1297,7 +1293,13 @@ describe("generateAndAppendDreamNarrative", () => {
vi.spyOn(runtimeConfigSnapshotModule, "getRuntimeConfig").mockReturnValue({
session: {},
} as never);
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
vi.spyOn(sessionStoreRuntimeModule, "resolveStorePath").mockImplementation(((
_store: string | undefined,
{ agentId }: { agentId: string },
) => {
expect(agentId).toBe("main");
return storePath;
}) as typeof sessionStoreRuntimeModule.resolveStorePath);
vi.spyOn(memoryCoreHostRuntimeCoreModule, "resolveStateDir").mockReturnValue(stateDir);
const subagent = createMockSubagent("A forgotten endpoint hummed in the dark.");

View File

@@ -14,7 +14,12 @@ import {
import { resolveGlobalMap } from "openclaw/plugin-sdk/global-singleton";
import { resolveStateDir } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
import { cleanupSessionLifecycleArtifacts } from "openclaw/plugin-sdk/session-store-runtime";
import { pathExists } from "openclaw/plugin-sdk/security-runtime";
import {
loadSessionStore,
resolveStorePath,
updateSessionStore,
} from "openclaw/plugin-sdk/session-store-runtime";
import { readDreamsFile, resolveDreamsPath, updateDreamsFile } from "./dreaming-dreams-file.js";
// ── Types ──────────────────────────────────────────────────────────────
@@ -103,6 +108,7 @@ const NARRATIVE_MESSAGE_SETTLE_DELAYS_MS = [50, 150, 300, 750] as const;
const DREAMING_SESSION_KEY_PREFIX = "dreaming-narrative-";
const DREAMING_TRANSCRIPT_RUN_MARKER = '"runId":"dreaming-narrative-';
const DREAMING_ORPHAN_MIN_AGE_MS = 300_000;
const SAFE_SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i;
const DIARY_START_MARKER = "<!-- openclaw:dreaming:diary:start -->";
const DIARY_END_MARKER = "<!-- openclaw:dreaming:diary:end -->";
const BACKFILL_ENTRY_MARKER = "openclaw:dreaming:backfill-entry";
@@ -770,6 +776,80 @@ export async function appendNarrativeEntry(params: {
// ── Orchestrator ───────────────────────────────────────────────────────
function normalizeComparablePath(pathname: string): string {
return process.platform === "win32" ? pathname.toLowerCase() : pathname;
}
async function normalizeSessionFileForComparison(params: {
sessionsDir: string;
sessionFile: string;
}): Promise<string | null> {
const trimmed = params.sessionFile.trim();
if (!trimmed) {
return null;
}
const resolved = path.isAbsolute(trimmed) ? trimmed : path.resolve(params.sessionsDir, trimmed);
try {
return normalizeComparablePath(await fs.realpath(resolved));
} catch {
return normalizeComparablePath(path.resolve(resolved));
}
}
function isDreamingSessionStoreKey(sessionKey: string): boolean {
const firstSeparator = sessionKey.indexOf(":");
if (firstSeparator < 0) {
return sessionKey.startsWith(DREAMING_SESSION_KEY_PREFIX);
}
const secondSeparator = sessionKey.indexOf(":", firstSeparator + 1);
const sessionSegment = secondSeparator < 0 ? sessionKey : sessionKey.slice(secondSeparator + 1);
return sessionSegment.startsWith(DREAMING_SESSION_KEY_PREFIX);
}
// A dreaming store row is reclaimable once its narrative run is finished. The
// happy path deletes the session in `finally`, but when `deleteSession` throws
// (e.g. request-scoped subagent runtime) the row is left behind referencing a
// still-present transcript, so the missing-transcript check alone never reaps
// it and the session lingers in the sidebar forever (issue #88322). Reclaim a
// dreaming row when its transcript is missing, or when the transcript has aged
// past the orphan threshold (a live narrative refreshes its transcript well
// within that window, so active runs are never reaped).
async function isReclaimableDreamingStoreEntry(
normalizedSessionFile: string | null,
): Promise<boolean> {
if (!normalizedSessionFile || !(await pathExists(normalizedSessionFile))) {
return true;
}
try {
const stat = await fs.stat(normalizedSessionFile);
return Date.now() - stat.mtimeMs >= DREAMING_ORPHAN_MIN_AGE_MS;
} catch {
return true;
}
}
async function normalizeSessionEntryPathForComparison(params: {
sessionsDir: string;
entry: { sessionFile?: string; sessionId?: string } | undefined;
}): Promise<string | null> {
const sessionFile = typeof params.entry?.sessionFile === "string" ? params.entry.sessionFile : "";
if (sessionFile) {
return normalizeSessionFileForComparison({
sessionsDir: params.sessionsDir,
sessionFile,
});
}
const sessionId =
typeof params.entry?.sessionId === "string" ? params.entry.sessionId.trim() : "";
if (!SAFE_SESSION_ID_RE.test(sessionId)) {
return null;
}
return normalizeSessionFileForComparison({
sessionsDir: params.sessionsDir,
sessionFile: `${sessionId}.jsonl`,
});
}
async function scrubDreamingNarrativeArtifacts(logger: Logger): Promise<void> {
const cfg = getRuntimeConfig();
const agentsDir = path.join(resolveStateDir(), "agents");
@@ -788,20 +868,112 @@ async function scrubDreamingNarrativeArtifacts(logger: Logger): Promise<void> {
continue;
}
const storePath = resolveStorePath(cfg.session?.store, { agentId: agentEntry.name });
const sessionsDir = path.dirname(storePath);
let store: Record<string, { sessionFile?: string; sessionId?: string } | undefined>;
try {
const result = await cleanupSessionLifecycleArtifacts({
agentId: agentEntry.name,
archiveRemovedEntryTranscripts: false,
sessionStore: cfg.session?.store,
sessionKeySegmentPrefix: DREAMING_SESSION_KEY_PREFIX,
transcriptContentMarker: DREAMING_TRANSCRIPT_RUN_MARKER,
orphanTranscriptMinAgeMs: DREAMING_ORPHAN_MIN_AGE_MS,
});
prunedEntries += result.removedEntries;
archivedOrphans += result.archivedTranscriptArtifacts;
store = loadSessionStore(storePath) as Record<
string,
{ sessionFile?: string; sessionId?: string } | undefined
>;
} catch {
continue;
}
const referencedSessionFiles = new Set<string>();
let needsStoreUpdate = false;
for (const [key, entry] of Object.entries(store)) {
const normalizedSessionFile = await normalizeSessionEntryPathForComparison({
sessionsDir,
entry,
});
if (normalizedSessionFile) {
referencedSessionFiles.add(normalizedSessionFile);
}
if (!isDreamingSessionStoreKey(key)) {
continue;
}
if (await isReclaimableDreamingStoreEntry(normalizedSessionFile)) {
needsStoreUpdate = true;
}
}
if (needsStoreUpdate) {
referencedSessionFiles.clear();
prunedEntries += await updateSessionStore(storePath, async (lockedStore) => {
let prunedForAgent = 0;
for (const [key, entry] of Object.entries(lockedStore)) {
const normalizedSessionFile = await normalizeSessionEntryPathForComparison({
sessionsDir,
entry,
});
if (!isDreamingSessionStoreKey(key)) {
if (normalizedSessionFile) {
referencedSessionFiles.add(normalizedSessionFile);
}
continue;
}
if (await isReclaimableDreamingStoreEntry(normalizedSessionFile)) {
// Drop the row and leave the transcript unreferenced so the orphan
// transcript pass below archives the aged-out (or missing) file.
delete lockedStore[key];
prunedForAgent += 1;
continue;
}
if (normalizedSessionFile) {
referencedSessionFiles.add(normalizedSessionFile);
}
}
return prunedForAgent;
});
}
let sessionFiles: Dirent[];
try {
sessionFiles = await fs.readdir(sessionsDir, { withFileTypes: true });
} catch {
continue;
}
for (const fileEntry of sessionFiles) {
if (!fileEntry.isFile() || !fileEntry.name.endsWith(".jsonl")) {
continue;
}
const transcriptPath = path.join(sessionsDir, fileEntry.name);
const normalizedTranscriptPath =
(await normalizeSessionFileForComparison({
sessionsDir,
sessionFile: fileEntry.name,
})) ?? normalizeComparablePath(transcriptPath);
if (referencedSessionFiles.has(normalizedTranscriptPath)) {
continue;
}
let stat;
try {
stat = await fs.stat(transcriptPath);
} catch {
continue;
}
if (Date.now() - stat.mtimeMs < DREAMING_ORPHAN_MIN_AGE_MS) {
continue;
}
let content;
try {
content = await fs.readFile(transcriptPath, "utf-8");
} catch {
continue;
}
if (!content.includes(DREAMING_TRANSCRIPT_RUN_MARKER)) {
continue;
}
const archivedPath = `${transcriptPath}.deleted.${Date.now()}`;
try {
await fs.rename(transcriptPath, archivedPath);
archivedOrphans += 1;
} catch {
// best-effort scrubber
}
}
}
if (prunedEntries > 0 || archivedOrphans > 0) {

View File

@@ -74,14 +74,11 @@ export const allowedSessionStoreRuntimeFileBackedCompatExports = new Set([
export const migratedSessionAccessorFiles = new Set([
"packages/memory-host-sdk/src/host/session-files.ts",
"src/acp/runtime/session-meta.ts",
"src/agents/acp-spawn.ts",
"src/agents/embedded-agent-runner/compaction-successor-transcript.ts",
"src/agents/embedded-agent-runner/run/attempt.ts",
"src/agents/embedded-agent-runner/tool-result-truncation.ts",
"src/agents/embedded-agent-runner/transcript-rewrite.ts",
"src/agents/embedded-agent-runner/transcript-runtime-state.ts",
"src/auto-reply/reply/abort.ts",
"src/auto-reply/reply/agent-runner-helpers.ts",
"src/auto-reply/reply/agent-runner.ts",
"src/auto-reply/reply/commands-subagents/action-info.ts",
@@ -121,18 +118,15 @@ export const migratedSessionAccessorFiles = new Set([
export const migratedBundledPluginSessionAccessorFiles = new Set([
"extensions/discord/src/monitor/native-command-model-picker-apply.ts",
"extensions/discord/src/monitor/thread-session-close.ts",
"extensions/memory-core/src/dreaming-narrative.ts",
"extensions/telegram/src/bot-handlers.runtime.ts",
]);
export const migratedSessionAccessorWriteFiles = new Set([
"src/acp/runtime/session-meta.ts",
"src/agents/command/attempt-execution.shared.ts",
"src/agents/command/session-store.ts",
"src/agents/embedded-agent-runner/run.ts",
"src/agents/embedded-agent-runner/run/attempt.ts",
"src/agents/main-session-restart-recovery.ts",
"src/auto-reply/reply/abort.ts",
"src/auto-reply/reply/abort-cutoff.runtime.ts",
"src/auto-reply/reply/agent-runner-cli-dispatch.ts",
"src/auto-reply/reply/agent-runner-execution.ts",
@@ -536,9 +530,7 @@ export async function main() {
const readSourceRoots = resolveSourceRoots(repoRoot, [
"packages/memory-host-sdk/src/host",
"extensions/discord/src/monitor",
"extensions/memory-core/src",
"extensions/telegram/src",
"src/acp",
"src/agents",
"src/auto-reply",
"src/commands",
@@ -550,7 +542,6 @@ export async function main() {
"src/tui",
]);
const writeSourceRoots = resolveSourceRoots(repoRoot, [
"src/acp",
"src/agents",
"src/auto-reply",
"src/commands",

View File

@@ -202,8 +202,8 @@ let publicDeprecatedExportsByEntrypointBudget;
try {
budgets = {
publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 322),
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10379),
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5208),
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10376),
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5205),
publicDeprecatedExports: readBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
3247,

View File

@@ -152,24 +152,6 @@ describe("ACP session metadata SQLite store", () => {
})?.acp?.runtimeSessionName,
).toBe("codex-normalized");
expect(loadSessionStore(storePath)[storeSessionKey]?.acp).toBeUndefined();
const legacyEmbeddedEntry = loadSessionStore(storePath)[storeSessionKey];
expect(legacyEmbeddedEntry).toBeDefined();
if (!legacyEmbeddedEntry) {
throw new Error("expected normalized ACP session entry");
}
await writeSessionStoreForTestAsync(storePath, {
[storeSessionKey]: {
...legacyEmbeddedEntry,
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "legacy-embedded",
mode: "persistent",
state: "idle",
lastActivityAt: 120,
},
},
});
await upsertAcpSessionMeta({
cfg,
@@ -188,105 +170,6 @@ describe("ACP session metadata SQLite store", () => {
sessionKey: storeSessionKey,
})?.acp,
).toBeUndefined();
expect(loadSessionStore(storePath)[storeSessionKey]?.acp).toBeUndefined();
});
});
it("keeps SQLite ACP metadata visible when legacy store keys are canonicalized", async () => {
await withTempDir({ prefix: "openclaw-acp-meta-" }, async (dir) => {
const storePath = path.join(dir, "sessions.json");
const databasePath = path.join(dir, "state", "openclaw.sqlite");
const cfg = { session: { store: storePath } } as OpenClawConfig;
const legacyStoreSessionKey = "agent:CODEX:acp:legacy-runtime";
const canonicalSessionKey = "agent:codex:acp:legacy-runtime";
await fs.writeFile(
storePath,
JSON.stringify({
[legacyStoreSessionKey]: {
sessionId: "sess-acp",
updatedAt: 100,
},
}),
"utf8",
);
await upsertAcpSessionMeta({
cfg,
databasePath,
sessionKey: canonicalSessionKey,
now: () => 200,
mutate: () => ({
backend: "acpx",
agent: "codex",
runtimeSessionName: "codex-canonicalized",
mode: "persistent",
state: "idle",
lastActivityAt: 123,
}),
});
const store = loadSessionStore(storePath);
expect(store[legacyStoreSessionKey]).toBeUndefined();
expect(store[canonicalSessionKey]?.sessionId).toBe("sess-acp");
expect(
readAcpSessionEntry({
cfg,
databasePath,
sessionKey: canonicalSessionKey,
})?.acp?.runtimeSessionName,
).toBe("codex-canonicalized");
expect(await listAcpSessionEntries({ cfg, databasePath })).toHaveLength(1);
});
});
it("binds ACP metadata to the final accessor-selected entry for alias writes", async () => {
await withTempDir({ prefix: "openclaw-acp-meta-" }, async (dir) => {
const storePath = path.join(dir, "sessions.json");
const databasePath = path.join(dir, "state", "openclaw.sqlite");
const cfg = { session: { store: storePath } } as OpenClawConfig;
const canonicalSessionKey = "agent:codex:acp:alias-runtime";
const legacyStoreSessionKey = "agent:CODEX:acp:alias-runtime";
await fs.writeFile(
storePath,
JSON.stringify({
[canonicalSessionKey]: {
sessionId: "sess-canonical",
updatedAt: 100,
},
[legacyStoreSessionKey]: {
sessionId: "sess-legacy",
updatedAt: 150,
},
}),
"utf8",
);
await upsertAcpSessionMeta({
cfg,
databasePath,
sessionKey: legacyStoreSessionKey,
now: () => 200,
mutate: () => ({
backend: "acpx",
agent: "codex",
runtimeSessionName: "codex-alias",
mode: "persistent",
state: "idle",
lastActivityAt: 123,
}),
});
const store = loadSessionStore(storePath);
expect(store[legacyStoreSessionKey]).toBeUndefined();
expect(store[canonicalSessionKey]?.sessionId).toBe("sess-legacy");
expect(
readAcpSessionEntry({
cfg,
databasePath,
sessionKey: canonicalSessionKey,
})?.acp?.runtimeSessionName,
).toBe("codex-alias");
expect(await listAcpSessionEntries({ cfg, databasePath })).toHaveLength(1);
});
});

View File

@@ -4,11 +4,7 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st
import type { Insertable, Selectable } from "kysely";
import { getRuntimeConfig } from "../../config/config.js";
import { resolveStorePath } from "../../config/sessions/paths.js";
import {
listSessionEntries,
patchSessionEntryWithKey,
type SessionEntrySummary,
} from "../../config/sessions/session-accessor.js";
import { loadSessionStore } from "../../config/sessions/store-load.js";
import {
mergeSessionEntry,
type AcpSessionRuntimeOptions,
@@ -47,24 +43,30 @@ type AcpSessionsTable = OpenClawStateKyselyDatabase["acp_sessions"];
type AcpSessionMetaDatabase = Pick<OpenClawStateKyselyDatabase, "acp_sessions">;
type AcpSessionRow = Selectable<AcpSessionsTable>;
function resolveStoreSessionKey(
entries: readonly SessionEntrySummary[],
sessionKey: string,
): string {
let sessionStoreRuntimePromise:
| Promise<typeof import("../../config/sessions/store.runtime.js")>
| undefined;
function loadSessionStoreRuntime() {
sessionStoreRuntimePromise ??= import("../../config/sessions/store.runtime.js");
return sessionStoreRuntimePromise;
}
function resolveStoreSessionKey(store: Record<string, SessionEntry>, sessionKey: string): string {
const normalized = sessionKey.trim();
if (!normalized) {
return "";
}
if (entries.some((entry) => entry.sessionKey === normalized)) {
if (store[normalized]) {
return normalized;
}
const lower = normalizeLowercaseStringOrEmpty(normalized);
if (entries.some((entry) => entry.sessionKey === lower)) {
if (store[lower]) {
return lower;
}
for (const entry of entries) {
if (normalizeLowercaseStringOrEmpty(entry.sessionKey) === lower) {
return entry.sessionKey;
for (const key of Object.keys(store)) {
if (normalizeLowercaseStringOrEmpty(key) === lower) {
return key;
}
}
return lower;
@@ -369,13 +371,12 @@ function readSessionEntryFromStore(params: {
env: params.env,
});
try {
const entries = listSessionEntries({
const store = loadSessionStore(
storePath,
...(params.clone === false ? { clone: false } : {}),
});
const storeSessionKey = resolveStoreSessionKey(entries, params.sessionKey);
const entry = entries.find((candidate) => candidate.sessionKey === storeSessionKey)?.entry;
return { cfg, storePath, storeSessionKey, entry };
params.clone === false ? { clone: false } : undefined,
);
const storeSessionKey = resolveStoreSessionKey(store, params.sessionKey);
return { cfg, storePath, storeSessionKey, entry: store[storeSessionKey] };
} catch {
return {
cfg,
@@ -436,19 +437,14 @@ export async function listAcpSessionEntries(params: {
cfg,
env: params.env,
});
let sessionEntries: SessionEntrySummary[];
let store: Record<string, SessionEntry>;
try {
sessionEntries = listSessionEntries({
storePath,
...(params.clone === false ? { clone: false } : {}),
});
store = loadSessionStore(storePath, params.clone === false ? { clone: false } : undefined);
} catch {
continue;
}
const storeSessionKey = resolveStoreSessionKey(sessionEntries, sessionKey);
const entry = sessionEntries.find(
(candidate) => candidate.sessionKey === storeSessionKey,
)?.entry;
const storeSessionKey = resolveStoreSessionKey(store, sessionKey);
const entry = store[storeSessionKey];
if (!entry || !acpSessionRowMatchesEntry(row, entry)) {
continue;
}
@@ -522,86 +518,63 @@ export async function upsertAcpSessionMeta(params: {
current,
current ? mergeAcpForReturn(preparedEntry, current) : entry,
);
},
{ env: params.env, path: params.databasePath },
);
const metaToPersist = nextMeta;
if (metaToPersist === undefined) {
return current ? mergeAcpForReturn(entry, current) : (entry ?? null);
}
if (metaToPersist === null) {
const patched = entry
? await patchSessionEntryWithKey(
{ storePath: storeEntry.storePath, sessionKey: storageSessionKey },
(currentEntry) => {
const next = { ...currentEntry };
delete next.acp;
return next;
},
{
...sessionStoreUpdateOptions({ ...params, sessionKey: storageSessionKey }),
replaceEntry: true,
},
)
: null;
runOpenClawStateWriteTransaction(
(database) => {
const sessionKeysToDelete = new Set([storageSessionKey]);
if (patched?.sessionKey) {
sessionKeysToDelete.add(patched.sessionKey);
}
for (const key of sessionKeysToDelete) {
executeSqliteQuerySync(
database.db,
getAcpSessionKysely(database.db)
.deleteFrom("acp_sessions")
.where("session_key", "=", key),
);
}
},
{ env: params.env, path: params.databasePath },
);
return patched?.entry ?? null;
}
const persisted = await patchSessionEntryWithKey(
{ storePath: storeEntry.storePath, sessionKey: storageSessionKey },
(currentEntry) => {
const next = mergeSessionEntry(currentEntry, {
updatedAt,
});
delete next.acp;
return next;
},
{
...sessionStoreUpdateOptions({ ...params, sessionKey: storageSessionKey }),
fallbackEntry: preparedEntry,
replaceEntry: true,
},
);
if (!persisted) {
return null;
}
runOpenClawStateWriteTransaction(
(database) => {
upsertAcpSessionMetaRow(
database.db,
bindAcpSessionMeta({
sessionKey: persisted.sessionKey,
sessionId: persisted.entry.sessionId,
meta: metaToPersist,
updatedAt,
}),
);
if (persisted.sessionKey !== storageSessionKey) {
if (nextMeta === null) {
executeSqliteQuerySync(
database.db,
getAcpSessionKysely(database.db)
.deleteFrom("acp_sessions")
.where("session_key", "=", storageSessionKey),
);
return;
}
if (nextMeta !== undefined) {
upsertAcpSessionMetaRow(
database.db,
bindAcpSessionMeta({
sessionKey: storageSessionKey,
sessionId: preparedEntry.sessionId,
meta: nextMeta,
updatedAt,
}),
);
}
},
{ env: params.env, path: params.databasePath },
);
return mergeAcpForReturn(persisted.entry, metaToPersist);
if (nextMeta === undefined) {
return current ? mergeAcpForReturn(entry, current) : (entry ?? null);
}
if (nextMeta === null) {
if (!entry) {
return null;
}
const { updateSessionStore } = await loadSessionStoreRuntime();
return await updateSessionStore(
storeEntry.storePath,
(store) => {
const storeSessionKey = resolveStoreSessionKey(store, storageSessionKey);
const next = { ...(store[storeSessionKey] ?? entry) };
delete next.acp;
store[storeSessionKey] = next;
return next;
},
sessionStoreUpdateOptions({ ...params, sessionKey: storageSessionKey }),
);
}
const { updateSessionStore } = await loadSessionStoreRuntime();
const persisted = await updateSessionStore(
storeEntry.storePath,
(store) => {
const storeSessionKey = resolveStoreSessionKey(store, storageSessionKey);
const next = mergeSessionEntry(store[storeSessionKey], {
sessionId: preparedEntry?.sessionId,
updatedAt,
});
delete next.acp;
store[storeSessionKey] = next;
return next;
},
sessionStoreUpdateOptions({ ...params, sessionKey: storageSessionKey }),
);
return mergeAcpForReturn(persisted, nextMeta);
}

View File

@@ -71,68 +71,6 @@ const hoisted = vi.hoisted(() => {
const countActiveRunsForSessionMock = vi.fn();
const getSubagentRunByChildSessionKeyMock = vi.fn();
const listTasksForOwnerKeyMock = vi.fn();
const createSessionAccessorMock = () => {
const resolveMockStorePath = (scope: {
agentId?: string;
env?: NodeJS.ProcessEnv;
storePath?: string;
}): string =>
scope.storePath ??
resolveStorePathMock(undefined, {
agentId: scope.agentId,
env: scope.env,
});
const loadMockEntry = (scope: {
agentId?: string;
env?: NodeJS.ProcessEnv;
sessionKey: string;
storePath?: string;
}): SessionEntry | undefined => {
const store = loadSessionStoreMock(resolveMockStorePath(scope)) as Record<
string,
SessionEntry
>;
return store[scope.sessionKey];
};
return {
listSessionEntries: (
scope: {
agentId?: string;
env?: NodeJS.ProcessEnv;
storePath?: string;
} = {},
) => {
const store = loadSessionStoreMock(resolveMockStorePath(scope)) as Record<
string,
SessionEntry
>;
return Object.entries(store).map(([sessionKey, entry]) => ({ sessionKey, entry }));
},
loadSessionEntry: loadMockEntry,
resolveSessionTranscriptRuntimeTarget: async (scope: {
agentId: string;
sessionId: string;
sessionKey: string;
storePath?: string;
threadId?: string | number;
}) => {
const store = scope.storePath
? (loadSessionStoreMock(scope.storePath) as Record<string, SessionEntry>)
: undefined;
const resolved = await resolveSessionTranscriptFileMock({
...scope,
...(store ? { sessionStore: store } : {}),
sessionEntry: loadMockEntry(scope),
});
return {
agentId: scope.agentId,
sessionFile: resolved.sessionFile,
sessionId: scope.sessionId,
sessionKey: scope.sessionKey,
};
},
};
};
const state = {
cfg: createDefaultSpawnConfig(),
};
@@ -160,7 +98,6 @@ const hoisted = vi.hoisted(() => {
countActiveRunsForSessionMock,
getSubagentRunByChildSessionKeyMock,
listTasksForOwnerKeyMock,
createSessionAccessorMock,
state,
};
});
@@ -197,8 +134,6 @@ vi.mock("../config/sessions/store.js", () => ({
loadSessionStore: hoisted.loadSessionStoreMock,
}));
vi.mock("../config/sessions/session-accessor.js", () => hoisted.createSessionAccessorMock());
vi.mock("../config/sessions.js", () => ({
loadSessionStore: hoisted.loadSessionStoreMock,
resolveStorePath: hoisted.resolveStorePathMock,

View File

@@ -47,11 +47,8 @@ import {
} from "../config/agent-limits.js";
import { getRuntimeConfig } from "../config/config.js";
import { resolveStorePath } from "../config/sessions/paths.js";
import {
listSessionEntries,
loadSessionEntry,
resolveSessionTranscriptRuntimeTarget,
} from "../config/sessions/session-accessor.js";
import { loadSessionStore } from "../config/sessions/store.js";
import { resolveSessionTranscriptFile } from "../config/sessions/transcript.js";
import type { SessionAcpMeta, SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { callGateway } from "../gateway/call.js";
@@ -271,6 +268,7 @@ type AcpSpawnInitializedRuntime = {
runtimeCloseHandle: AcpSpawnRuntimeCloseHandle;
sessionId?: string;
sessionEntry: SessionEntry | undefined;
sessionStore: Record<string, SessionEntry>;
storePath: string;
};
@@ -447,11 +445,8 @@ function hasSessionLocalHeartbeatRelayRoute(params: {
const storePath = resolveStorePath(params.cfg.session?.store, {
agentId: params.requesterAgentId,
});
const parentEntry = loadSessionEntry({
storePath,
sessionKey: params.parentSessionKey,
clone: false,
});
const sessionStore = loadSessionStore(storePath);
const parentEntry = sessionStore[params.parentSessionKey];
const parentDeliveryContext = deliveryContextFromSession(parentEntry);
return Boolean(parentDeliveryContext?.channel && parentDeliveryContext.to);
}
@@ -600,26 +595,23 @@ async function persistAcpSpawnSessionFileBestEffort(params: {
sessionId: string;
sessionKey: string;
sessionEntry: SessionEntry | undefined;
sessionStore: Record<string, SessionEntry>;
storePath: string;
agentId: string;
threadId?: string | number;
stage: "spawn" | "thread-bind";
}): Promise<SessionEntry | undefined> {
try {
const resolvedSessionFile = await resolveSessionTranscriptRuntimeTarget({
const resolvedSessionFile = await resolveSessionTranscriptFile({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionEntry: params.sessionEntry,
sessionStore: params.sessionStore,
storePath: params.storePath,
agentId: params.agentId,
threadId: params.threadId,
});
return (
loadSessionEntry({
storePath: params.storePath,
sessionKey: resolvedSessionFile.sessionKey,
clone: false,
}) ?? params.sessionEntry
);
return resolvedSessionFile.sessionEntry;
} catch (error) {
log.warn(
`ACP session-file persistence failed during ${params.stage} for ${params.sessionKey}: ${summarizeError(error)}`,
@@ -972,8 +964,9 @@ function validateAcpResumeSessionOwnership(params: {
}
const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.targetAgentId });
for (const { sessionKey, entry } of listSessionEntries({ storePath, clone: false })) {
const acp = readAcpSessionMeta({ sessionKey, cfg: params.cfg });
const sessionStore = loadSessionStore(storePath);
for (const [sessionKey, entry] of Object.entries(sessionStore)) {
const acp = readAcpSessionMeta({ sessionKey });
if (!sessionEntryMatchesAcpResumeSessionId(acp, resumeSessionId)) {
continue;
}
@@ -1071,16 +1064,14 @@ async function initializeAcpSpawnRuntime(params: {
cwd?: string;
}): Promise<AcpSpawnInitializedRuntime> {
const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.targetAgentId });
let sessionEntry = loadSessionEntry({
storePath,
sessionKey: params.sessionKey,
clone: false,
});
const sessionStore = loadSessionStore(storePath);
let sessionEntry: SessionEntry | undefined = sessionStore[params.sessionKey];
const sessionId = sessionEntry?.sessionId;
if (sessionId) {
sessionEntry = await persistAcpSpawnSessionFileBestEffort({
sessionId,
sessionKey: params.sessionKey,
sessionStore,
storePath,
sessionEntry,
agentId: params.targetAgentId,
@@ -1107,6 +1098,7 @@ async function initializeAcpSpawnRuntime(params: {
},
sessionId,
sessionEntry,
sessionStore,
storePath,
};
}
@@ -1178,6 +1170,7 @@ async function bindPreparedAcpThread(params: {
sessionEntry = await persistAcpSpawnSessionFileBestEffort({
sessionId: params.initializedRuntime.sessionId,
sessionKey: params.sessionKey,
sessionStore: params.initializedRuntime.sessionStore,
storePath: params.initializedRuntime.storePath,
sessionEntry,
agentId: params.targetAgentId,
@@ -1539,19 +1532,16 @@ export async function spawnAcpDirect(
childSessionKey: sessionKey,
})
: undefined;
const parentAgentId = parentSessionKey
? resolveAgentIdFromSessionKey(parentSessionKey)
: undefined;
// Resolve parent session delivery context so system events route to the
// correct thread/topic instead of falling back to the main DM.
const parentDeliveryCtx =
effectiveStreamToParent && parentSessionKey
? deliveryContextFromSession(
loadSessionEntry({
sessionKey: parentSessionKey,
...(parentAgentId ? { agentId: parentAgentId } : {}),
clone: false,
}),
loadSessionStore(
resolveStorePath(cfg.session?.store, {
agentId: resolveAgentIdFromSessionKey(parentSessionKey),
}),
)[parentSessionKey],
)
: undefined;

View File

@@ -33,6 +33,13 @@ import { markCodeModeControlTool } from "./code-mode-control-tools.js";
import { CODE_MODE_EXEC_TOOL_NAME, createCodeModeTools } from "./code-mode.js";
import { splitSdkTools } from "./embedded-agent-runner.js";
import type { ExtensionContext } from "./sessions/index.js";
import {
addClientToolsToToolSearchCatalog,
applyToolSearchCatalog,
clearToolSearchCatalog,
createToolSearchTools,
TOOL_CALL_RAW_TOOL_NAME,
} from "./tool-search.js";
import { setToolTerminalPresentation } from "./tool-terminal-presentation.js";
type BeforeToolCallHandlerMock = ReturnType<typeof vi.fn>;
@@ -1200,6 +1207,143 @@ describe("before_tool_call hook integration for client tools", () => {
});
});
it("preserves requester origin context on adapted client tools", async () => {
const beforeToolCallHook = installBeforeToolCallHook();
const [tool] = toClientToolDefinitions(
[
{
type: "function",
function: {
name: "client_tool",
description: "Client tool",
parameters: { type: "object", properties: {} },
},
},
],
undefined,
{
agentId: "main",
sessionKey: "agent:main:client",
sessionId: "session-client",
runId: "run-client",
jobId: "job-client",
trigger: "user",
messageProvider: "slack-voice",
channel: "slack",
chatId: "C123",
senderId: "U123",
channelId: "C123",
channelContext: {
sender: { id: "U123", displayName: "Ada" },
chat: { id: "C123" },
},
},
);
const extensionContext = {} as Parameters<typeof tool.execute>[4];
await tool.execute("client-call-context", {}, undefined, undefined, extensionContext);
expect(beforeToolCallHook).toHaveBeenCalledWith(
{
toolName: "client_tool",
params: {},
runId: "run-client",
toolCallId: "client-call-context",
},
{
toolName: "client_tool",
agentId: "main",
sessionKey: "agent:main:client",
sessionId: "session-client",
runId: "run-client",
jobId: "job-client",
trigger: "user",
messageProvider: "slack-voice",
channel: "slack",
chatId: "C123",
senderId: "U123",
toolCallId: "client-call-context",
channelId: "C123",
channelContext: {
sender: { id: "U123", displayName: "Ada" },
chat: { id: "C123" },
},
},
);
});
it("preserves requester origin context when a client tool is cataloged", async () => {
const beforeToolCallHook = installBeforeToolCallHook();
const onClientToolCall = vi.fn();
const sessionId = "session-client-catalog";
const config = { tools: { toolSearch: { enabled: true, mode: "tools" } } } as never;
const [clientTool] = toClientToolDefinitions(
[
{
type: "function",
function: {
name: "client_tool",
description: "Client tool",
parameters: { type: "object", properties: {} },
},
},
],
onClientToolCall,
{
agentId: "main",
sessionKey: "agent:main:client",
sessionId,
runId: "run-client-catalog",
jobId: "job-client",
trigger: "user",
messageProvider: "slack-voice",
channel: "slack",
chatId: "C123",
senderId: "U123",
channelId: "C123",
channelContext: {
sender: { id: "U123", displayName: "Ada" },
chat: { id: "C123" },
},
},
);
if (!clientTool) {
throw new Error("missing client tool definition");
}
const controls = createToolSearchTools({ config, sessionId });
applyToolSearchCatalog({ tools: controls, config, sessionId });
addClientToolsToToolSearchCatalog({ tools: [clientTool], config, sessionId });
const toolCall = controls.find((tool) => tool.name === TOOL_CALL_RAW_TOOL_NAME);
if (!toolCall) {
throw new Error("missing tool_call control");
}
try {
await toolCall.execute("catalog-parent", {
id: "client:client:client_tool",
args: { value: "cataloged" },
});
expect(beforeToolCallHook).toHaveBeenCalledTimes(1);
expect(beforeToolCallHook.mock.calls[0]?.[1]).toMatchObject({
jobId: "job-client",
trigger: "user",
messageProvider: "slack-voice",
channel: "slack",
chatId: "C123",
senderId: "U123",
channelId: "C123",
channelContext: {
sender: { id: "U123", displayName: "Ada" },
chat: { id: "C123" },
},
});
expect(onClientToolCall).toHaveBeenCalledWith("client_tool", { value: "cataloged" });
} finally {
clearToolSearchCatalog({ sessionId });
}
});
it("preserves client tool source order when hooks resolve out of order", async () => {
let releaseFirstHook: (() => void) | undefined;
const firstHookGate = new Promise<void>((resolve) => {

View File

@@ -37,6 +37,7 @@ import {
import type { SessionState } from "../logging/diagnostic-session-state.js";
import { redactToolDetail } from "../logging/redact.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { buildAgentHookContextIdentityFields } from "../plugins/hook-agent-context.js";
import { getGlobalHookRunnerRegistry } from "../plugins/hook-runner-global-state.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import { deriveToolParams } from "../plugins/host-tool-param-parsers.js";
@@ -50,8 +51,10 @@ import {
PluginApprovalResolutions,
type PluginApprovalResolution,
type PluginHookBeforeToolCallResult,
type PluginHookChannelContext,
type PluginHookToolInputKind,
type PluginHookToolKind,
type PluginHookToolContext,
} from "../plugins/types.js";
import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js";
import {
@@ -110,8 +113,15 @@ export type HookContext = {
/** Ephemeral session UUID — regenerated on /new and /reset. */
sessionId?: string;
runId?: string;
jobId?: string;
trace?: DiagnosticTraceContext;
trigger?: string;
messageProvider?: string;
channel?: string;
chatId?: string;
senderId?: string;
channelId?: string;
channelContext?: PluginHookChannelContext;
/** Originating channel for approval delivery routing; mirrors exec approval turn-source fields. */
turnSourceChannel?: string;
turnSourceTo?: string;
@@ -133,6 +143,24 @@ export type HookContext = {
};
};
/** Plain run-owned metadata that can safely be projected onto tool hook contexts. */
export type ToolHookRunContext = Pick<
HookContext,
| "agentId"
| "sessionKey"
| "sessionId"
| "runId"
| "jobId"
| "trace"
| "trigger"
| "messageProvider"
| "channel"
| "chatId"
| "senderId"
| "channelId"
| "channelContext"
>;
type HookBlockedKind = "veto" | "failure";
type HookBlockedReason = "plugin-before-tool-call" | "plugin-approval" | "tool-loop";
type HookOutcome =
@@ -211,6 +239,38 @@ export function hasBeforeToolCallPolicy(): boolean {
return state.hasBeforeToolCallHook || state.trustedToolPolicies.length > 0;
}
/** Project the internal tool runtime context onto the public plugin hook contract. */
export function buildPluginHookToolContext(args: {
toolName: string;
toolKind?: PluginHookToolKind;
toolInputKind?: PluginHookToolInputKind;
toolCallId?: string;
ctx?: ToolHookRunContext;
}): PluginHookToolContext {
return {
toolName: args.toolName,
...(args.toolKind && { toolKind: args.toolKind }),
...(args.toolInputKind && { toolInputKind: args.toolInputKind }),
...(args.ctx?.agentId && { agentId: args.ctx.agentId }),
...(args.ctx?.sessionKey && { sessionKey: args.ctx.sessionKey }),
...(args.ctx?.sessionId && { sessionId: args.ctx.sessionId }),
...(args.ctx?.runId && { runId: args.ctx.runId }),
...(args.ctx?.jobId && { jobId: args.ctx.jobId }),
...(args.ctx?.trace && { trace: freezeDiagnosticTraceContext(args.ctx.trace) }),
...(args.ctx?.trigger && { trigger: args.ctx.trigger }),
...(args.ctx?.messageProvider && { messageProvider: args.ctx.messageProvider }),
...(args.ctx?.channel && { channel: args.ctx.channel }),
...(args.toolCallId && { toolCallId: args.toolCallId }),
...(args.ctx?.channelId && { channelId: args.ctx.channelId }),
...buildAgentHookContextIdentityFields({
trigger: args.ctx?.trigger,
senderId: args.ctx?.senderId,
chatId: args.ctx?.chatId,
channelContext: args.ctx?.channelContext,
}),
};
}
const log = createSubsystemLogger("agents/tools");
const BEFORE_TOOL_CALL_WRAPPED = Symbol("beforeToolCallWrapped");
const BEFORE_TOOL_CALL_DIAGNOSTIC_OPTIONS = Symbol("beforeToolCallDiagnosticOptions");
@@ -1143,17 +1203,13 @@ export async function runBeforeToolCallHook(args: {
...(args.toolKind && { toolKind: args.toolKind }),
...(args.toolInputKind && { toolInputKind: args.toolInputKind }),
};
const buildToolContext = (identity: typeof toolIdentity) => ({
toolName,
...identity,
...(args.ctx?.agentId && { agentId: args.ctx.agentId }),
...(args.ctx?.sessionKey && { sessionKey: args.ctx.sessionKey }),
...(args.ctx?.sessionId && { sessionId: args.ctx.sessionId }),
...(args.ctx?.runId && { runId: args.ctx.runId }),
...(args.ctx?.trace && { trace: freezeDiagnosticTraceContext(args.ctx.trace) }),
...(args.toolCallId && { toolCallId: args.toolCallId }),
...(args.ctx?.channelId && { channelId: args.ctx.channelId }),
});
const buildToolContext = (identity: typeof toolIdentity) =>
buildPluginHookToolContext({
toolName,
...identity,
...(args.toolCallId && { toolCallId: args.toolCallId }),
...(args.ctx ? { ctx: args.ctx } : {}),
});
const toolContext = buildToolContext(toolIdentity);
const trustedPolicyResult = shouldRunTrustedPolicies
? await runTrustedToolPolicies(

View File

@@ -201,7 +201,7 @@ describe("createOpenClawCodingTools", () => {
expect(names.has("tool_call")).toBe(false);
});
it("passes explicit hook channel ids to wrapped tool hooks", async () => {
it("passes requester origin context to wrapped tool hooks", async () => {
const beforeToolCall = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "before_tool_call", handler: beforeToolCall }]),
@@ -210,15 +210,36 @@ describe("createOpenClawCodingTools", () => {
await fs.writeFile(path.join(tmpDir, "note.txt"), "hello");
const tools = createOpenClawCodingTools({
workspaceDir: tmpDir,
currentChannelId: "telegram:-100123",
hookChannelId: "-100123",
messageChannel: "telegram",
messageProvider: "telegram",
toolPolicyMessageProvider: "telegram-voice",
messageTo: "telegram:-100123",
senderId: "speaker-1",
trigger: "user",
jobId: "job-1",
hookChannelContext: {
sender: { id: "transport-speaker" },
chat: { id: "transport-chat" },
},
});
const readTool = requireTool(tools, "read");
await requireToolExecute(readTool)("tool-hook-channel", { path: "note.txt" });
expect(beforeToolCall).toHaveBeenCalledTimes(1);
expect(beforeToolCall.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({ channelId: "-100123" }),
expect.objectContaining({
channel: "telegram",
messageProvider: "telegram-voice",
channelId: "-100123",
chatId: "transport-chat",
senderId: "speaker-1",
trigger: "user",
jobId: "job-1",
channelContext: {
sender: { id: "speaker-1" },
chat: { id: "transport-chat" },
},
}),
);
});

View File

@@ -19,6 +19,7 @@ import { resolveEventSessionRoutingPolicy } from "../infra/event-session-routing
import { applyExecPolicyLayer } from "../infra/exec-policy.js";
import { resolveMergedSafeBinProfileFixtures } from "../infra/exec-safe-bin-runtime-policy.js";
import { logWarn } from "../logger.js";
import { buildAgentHookContextOriginFields } from "../plugins/hook-agent-context.js";
import type { PluginHookChannelContext } from "../plugins/hook-types.js";
import { getPluginToolMeta } from "../plugins/tools.js";
import { createLazyImportLoader } from "../shared/lazy-promise.js";
@@ -470,8 +471,12 @@ export function createOpenClawCodingTools(options?: {
currentMessagingTarget?: string;
/** Normalized conversation id exposed to tool hooks. Defaults to currentChannelId. */
hookChannelId?: string;
/** Transport-native conversation id exposed to tool hooks when available. */
chatId?: string;
/** Channel-owned sender/chat metadata exposed to subprocess environments. */
channelContext?: PluginHookChannelContext;
/** Channel-owned sender/chat metadata exposed only to plugin tool hooks. */
hookChannelContext?: PluginHookChannelContext;
/** Current thread timestamp for auto-threading (Slack). */
currentThreadTs?: string;
/** Current inbound message id for action fallbacks (e.g. Telegram react). */
@@ -1187,7 +1192,8 @@ export function createOpenClawCodingTools(options?: {
);
options?.recordToolPrepStage?.("schema-normalization");
const turnSourceChannel = options?.messageChannel ?? options?.messageProvider;
const turnSourceTo = options?.currentMessagingTarget ?? options?.currentChannelId;
const turnSourceTo =
options?.currentMessagingTarget ?? options?.messageTo ?? options?.currentChannelId;
const hookContext = {
agentId,
...(options?.config ? { config: options.config } : {}),
@@ -1200,7 +1206,19 @@ export function createOpenClawCodingTools(options?: {
sessionKey: options?.sessionKey,
sessionId: options?.sessionId,
runId: options?.runId,
channelId: options?.hookChannelId ?? options?.currentChannelId,
jobId: options?.jobId,
trigger: options?.trigger,
...buildAgentHookContextOriginFields({
sessionKey: options?.sessionKey,
messageChannel: options?.messageChannel,
messageProvider: options?.toolPolicyMessageProvider ?? options?.messageProvider,
currentChannelId: options?.hookChannelId ?? options?.currentChannelId,
messageTo: options?.currentMessagingTarget ?? options?.messageTo,
trigger: options?.trigger,
senderId: options?.senderId,
chatId: options?.chatId,
channelContext: options?.hookChannelContext ?? options?.channelContext,
}),
...(turnSourceChannel ? { turnSourceChannel } : {}),
...(turnSourceTo ? { turnSourceTo } : {}),
...(options?.agentAccountId ? { turnSourceAccountId: options.agentAccountId } : {}),

View File

@@ -629,6 +629,11 @@ describe("runBtwSideQuestion", () => {
senderName: "Rosita",
senderUsername: "rosita",
senderE164: "+15550001",
chatId: "native-chat-1",
channelContext: {
sender: { id: "sender-1", providerUserId: "provider-user-1" },
chat: { id: "native-chat-1", providerThreadKey: "thread-key-1" },
},
});
expect(result).toEqual({ text: "Codex side answer." });
@@ -653,6 +658,11 @@ describe("runBtwSideQuestion", () => {
senderName?: string;
senderUsername?: string;
senderE164?: string;
chatId?: string;
channelContext?: {
sender?: Record<string, unknown>;
chat?: Record<string, unknown>;
};
toolsAllow?: string[];
},
]
@@ -675,6 +685,11 @@ describe("runBtwSideQuestion", () => {
senderName: "Rosita",
senderUsername: "rosita",
senderE164: "+15550001",
chatId: "native-chat-1",
channelContext: {
sender: { id: "sender-1", providerUserId: "provider-user-1" },
chat: { id: "native-chat-1", providerThreadKey: "thread-key-1" },
},
});
expect(
(mockArg(codexSideQuestionMock, 0, 0) as { sessionFile?: string }).sessionFile,

View File

@@ -18,6 +18,7 @@ import type {
Model,
TextContent,
} from "../llm/types.js";
import type { PluginHookChannelContext } from "../plugins/hook-types.js";
import { prepareProviderRuntimeAuth } from "../plugins/provider-runtime.js";
import { discoverAuthStorage, discoverModels } from "./agent-model-discovery.js";
import { resolveAgentWorkspaceDir, resolveSessionAgentId } from "./agent-scope.js";
@@ -388,6 +389,8 @@ type RunBtwSideQuestionParams = {
senderUsername?: string | null;
senderE164?: string | null;
senderIsOwner?: boolean;
chatId?: string;
channelContext?: PluginHookChannelContext;
currentChannelId?: string;
};

View File

@@ -73,7 +73,9 @@ describe("isDeliveredMessagingToolResult", () => {
result: [{ type: "text", text: JSON.stringify({ result: { messageId: "msg-1" } }) }],
}),
).toBe(true);
expect(isDeliveredMessagingToolResult({ result: { content: [{ text: "sent" }] } })).toBe(true);
expect(isDeliveredMessagingToolResult({ result: { content: [{ text: "sent" }] } })).toBe(
true,
);
expect(isDeliveredMessagingToolResult({ result: { status: "sent" } })).toBe(true);
});
@@ -332,47 +334,4 @@ describe("isDeliveredMessageToolOnlySourceReplyResult", () => {
}),
).toBe(false);
});
it("accepts confirmed explicit routes when the caller verified the source route", () => {
expect(
isDeliveredMessageToolOnlySourceReplyResult({
sourceReplyDeliveryMode: "message_tool_only",
toolName: "message",
args: {
action: "reply",
channel: "imessage",
target: "+12069106512",
message: "reply",
},
result: { ok: true, messageId: "imessage-853" },
allowExplicitSourceRoute: true,
}),
).toBe(true);
expect(
isDeliveredMessageToolOnlySourceReplyResult({
sourceReplyDeliveryMode: "message_tool_only",
toolName: "message",
args: {
action: "reply",
channel: "imessage",
target: "+12069106512",
message: "reply",
},
result: { ok: true, messageId: "imessage-853" },
}),
).toBe(false);
expect(
isDeliveredMessageToolOnlySourceReplyResult({
sourceReplyDeliveryMode: "message_tool_only",
toolName: "message",
args: {
action: "react",
channel: "imessage",
target: "+12069106512",
},
result: { ok: true },
allowExplicitSourceRoute: true,
}),
).toBe(false);
});
});

View File

@@ -50,13 +50,6 @@ function hasExplicitMessageRoute(args: Record<string, unknown>): boolean {
return Array.isArray(args.targets) && args.targets.some((value) => hasStringValue(value));
}
function isMessageToolSourceReplyActionName(action: unknown): boolean {
if (isMessageToolSendActionName(action)) {
return true;
}
return typeof action === "string" && action.trim().toLowerCase() === "reply";
}
function normalizeStatus(value: unknown): string | undefined {
return typeof value === "string" ? value.trim().toLowerCase() : undefined;
}
@@ -554,7 +547,6 @@ export function isDeliveredMessageToolOnlySourceReplyResult(params: {
result?: unknown;
hookResult?: unknown;
isError?: boolean;
allowExplicitSourceRoute?: boolean;
}): boolean {
if (params.sourceReplyDeliveryMode !== "message_tool_only") {
return false;
@@ -563,12 +555,7 @@ export function isDeliveredMessageToolOnlySourceReplyResult(params: {
return false;
}
const args = asRecord(params.args);
const sourceRouteReplyAction =
params.allowExplicitSourceRoute === true && isMessageToolSourceReplyActionName(args.action);
if (!isMessageToolSendActionName(args.action) && !sourceRouteReplyAction) {
return false;
}
if (hasExplicitMessageRoute(args) && params.allowExplicitSourceRoute !== true) {
if (!isMessageToolSendActionName(args.action) || hasExplicitMessageRoute(args)) {
return false;
}
return isDeliveredMessagingToolResult(params);

View File

@@ -52,6 +52,7 @@ import { getCurrentPluginMetadataSnapshot } from "../../../plugins/current-plugi
import {
buildAgentHookContextChannelFields,
buildAgentHookContextIdentityFields,
buildAgentHookContextOriginFields,
} from "../../../plugins/hook-agent-context.js";
import { resolveBlockMessage } from "../../../plugins/hook-decision-types.js";
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
@@ -1241,6 +1242,7 @@ export async function runEmbeddedAttempt(
toolConstructionPlan.constructTools ||
toolSearchControlsEnabledForRun ||
codeModeControlsEnabledForRun;
const toolPolicyMessageProvider = resolveAttemptToolPolicyMessageProvider(params);
let toolSearchCatalogExecutor: ToolSearchCatalogToolExecutor | undefined;
toolSearchCatalogRef =
toolSearchControlsEnabledForRun || codeModeControlsEnabledForRun
@@ -1261,7 +1263,7 @@ export async function runEmbeddedAttempt(
elevated: params.bashElevated,
},
sandbox,
messageProvider: resolveAttemptToolPolicyMessageProvider(params),
messageProvider: toolPolicyMessageProvider,
agentAccountId: params.agentAccountId,
messageTo: params.messageTo,
messageThreadId: params.messageThreadId,
@@ -1314,6 +1316,7 @@ export async function runEmbeddedAttempt(
}),
currentChannelId: params.currentChannelId,
currentMessagingTarget: params.currentMessagingTarget,
chatId: params.chatId,
currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
currentInboundAudio: params.currentInboundAudio,
@@ -1661,7 +1664,19 @@ export async function runEmbeddedAttempt(
sessionKey: sandboxSessionKey,
sessionId: params.sessionId,
runId: params.runId,
channelId: params.currentChannelId,
jobId: params.jobId,
trigger: params.trigger,
...buildAgentHookContextOriginFields({
sessionKey: sandboxSessionKey,
messageChannel: params.messageChannel,
messageProvider: toolPolicyMessageProvider,
currentChannelId: params.currentChannelId,
messageTo: params.currentMessagingTarget ?? params.messageTo,
trigger: params.trigger,
senderId: params.senderId,
chatId: params.chatId,
channelContext: params.channelContext,
}),
trace: runTrace,
loopDetection: resolveToolLoopDetectionConfig({
cfg: params.config,
@@ -2421,14 +2436,9 @@ export async function runEmbeddedAttempt(
},
},
{
agentId: sessionAgentId,
sessionKey: sandboxSessionKey,
...catalogToolHookContext,
config: toolSearchRuntimeConfig,
sessionId: params.sessionId,
runId: params.runId,
loopDetection: clientToolLoopDetection,
onToolOutcome: params.onToolOutcome,
allocateToolOutcomeOrdinal: params.allocateToolOutcomeOrdinal,
},
)
: [];
@@ -3555,6 +3565,7 @@ export async function runEmbeddedAttempt(
messageChannel: runtimeChannel,
initialReplayState: params.initialReplayState,
hookRunner: getGlobalHookRunner() ?? undefined,
toolHookContext: catalogToolHookContext,
verboseLevel: params.verboseLevel,
reasoningMode: params.reasoningLevel ?? "off",
thinkingLevel: params.thinkLevel,

View File

@@ -34,6 +34,7 @@ import type { PluginHookAfterToolCallEvent } from "../plugins/types.js";
import { createLazyImportLoader } from "../shared/lazy-promise.js";
import { truncateUtf16Safe } from "../utils.js";
import { normalizeAcceptedSessionSpawnResult } from "./accepted-session-spawn.js";
import { buildPluginHookToolContext } from "./agent-tools.before-tool-call.js";
import {
consumeAdjustedParamsForToolCall,
consumePreExecutionBlockedToolCall,
@@ -1573,14 +1574,20 @@ export async function handleToolExecutionEnd(
durationMs,
};
void hookRunnerAfter
.runAfterToolCall(hookEvent, {
toolName,
agentId: ctx.params.agentId,
sessionKey: ctx.params.sessionKey,
sessionId: ctx.params.sessionId,
runId,
toolCallId,
})
.runAfterToolCall(
hookEvent,
buildPluginHookToolContext({
toolName,
toolCallId,
ctx: {
...ctx.params.toolHookContext,
...(ctx.params.agentId ? { agentId: ctx.params.agentId } : {}),
...(ctx.params.sessionKey ? { sessionKey: ctx.params.sessionKey } : {}),
...(ctx.params.sessionId ? { sessionId: ctx.params.sessionId } : {}),
runId,
},
}),
)
.catch((err: unknown) => {
ctx.log.warn(`after_tool_call hook failed: tool=${toolName} error=${String(err)}`);
});

View File

@@ -272,6 +272,7 @@ export type EmbeddedAgentSubscribeContext = {
type ToolHandlerParams = Pick<
SubscribeEmbeddedAgentSessionParams,
| "runId"
| "toolHookContext"
| "onBlockReplyFlush"
| "onAgentEvent"
| "onToolStreamBoundary"

View File

@@ -10,6 +10,7 @@ import type { ReplyPayload } from "../auto-reply/reply-payload.js";
import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { HookRunner } from "../plugins/hooks.js";
import type { ToolHookRunContext } from "./agent-tools.before-tool-call.js";
import type { BlockReplyPayload } from "./embedded-agent-payloads.js";
import type { EmbeddedRunReplayState } from "./embedded-agent-runner/replay-state.js";
import type {
@@ -35,6 +36,8 @@ export type SubscribeEmbeddedAgentSessionParams = {
messageChannel?: string;
initialReplayState?: EmbeddedRunReplayState;
hookRunner?: HookRunner;
/** Internal run context projected onto before/after tool hook contracts. */
toolHookContext?: ToolHookRunContext;
verboseLevel?: VerboseLevel;
reasoningMode?: ReasoningLevel;
thinkingLevel?: ThinkLevel;

View File

@@ -6,25 +6,26 @@
*/
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { consumeAdjustedParamsForToolCall } from "../agent-tools.before-tool-call.js";
import {
buildPluginHookToolContext,
consumeAdjustedParamsForToolCall,
type ToolHookRunContext,
} from "../agent-tools.before-tool-call.js";
import type { AgentMessage } from "../runtime/index.js";
const log = createSubsystemLogger("agents/harness");
/** Runs best-effort after-tool-call hooks for a completed tool invocation. */
export async function runAgentHarnessAfterToolCallHook(params: {
toolName: string;
toolCallId: string;
runId?: string;
agentId?: string;
sessionId?: string;
sessionKey?: string;
channelId?: string;
startArgs: Record<string, unknown>;
result?: unknown;
error?: string;
startedAt?: number;
}): Promise<void> {
export async function runAgentHarnessAfterToolCallHook(
params: ToolHookRunContext & {
toolName: string;
toolCallId: string;
startArgs: Record<string, unknown>;
result?: unknown;
error?: string;
startedAt?: number;
},
): Promise<void> {
const adjustedArgs = consumeAdjustedParamsForToolCall(params.toolCallId, params.runId);
// Hooks should see adjusted tool params when before_tool_call rewrote them.
const resolvedArgs =
@@ -47,15 +48,11 @@ export async function runAgentHarnessAfterToolCallHook(params: {
...(params.error ? { error: params.error } : {}),
...(params.startedAt != null ? { durationMs: Date.now() - params.startedAt } : {}),
},
{
buildPluginHookToolContext({
toolName: params.toolName,
...(params.agentId ? { agentId: params.agentId } : {}),
...(params.sessionId ? { sessionId: params.sessionId } : {}),
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
...(params.runId ? { runId: params.runId } : {}),
...(params.channelId ? { channelId: params.channelId } : {}),
toolCallId: params.toolCallId,
},
ctx: params,
}),
);
} catch (error) {
log.warn(`after_tool_call hook failed: tool=${params.toolName} error=${String(error)}`);

View File

@@ -1637,6 +1637,19 @@ describe("native hook relay registry", () => {
sessionKey: "agent:main:session-1",
runId: "run-1",
channelId: "telegram",
toolHookContext: {
jobId: "job-1",
trigger: "user",
messageProvider: "telegram-voice",
channel: "telegram",
chatId: "chat-1",
senderId: "user-1",
channelId: "telegram",
channelContext: {
sender: { id: "user-1" },
chat: { id: "chat-1" },
},
},
});
const response = await invokeNativeHookRelay({
@@ -1674,7 +1687,17 @@ describe("native hook relay registry", () => {
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
jobId: "job-1",
trigger: "user",
messageProvider: "telegram-voice",
channel: "telegram",
chatId: "chat-1",
senderId: "user-1",
channelId: "telegram",
channelContext: {
sender: { id: "user-1" },
chat: { id: "chat-1" },
},
toolName: "exec",
toolCallId: "native-call-1",
});
@@ -1695,6 +1718,19 @@ describe("native hook relay registry", () => {
sessionKey: "agent:main:session-1",
runId: "run-1",
channelId: "telegram",
toolHookContext: {
jobId: "job-1",
trigger: "user",
messageProvider: "telegram-voice",
channel: "telegram",
chatId: "chat-1",
senderId: "user-1",
channelId: "telegram",
channelContext: {
sender: { id: "user-1" },
chat: { id: "chat-1" },
},
},
});
const response = await invokeNativeHookRelay({
@@ -2243,6 +2279,19 @@ describe("native hook relay registry", () => {
sessionKey: "agent:main:session-1",
runId: "run-1",
channelId: "telegram",
toolHookContext: {
jobId: "job-1",
trigger: "user",
messageProvider: "telegram-voice",
channel: "telegram",
chatId: "chat-1",
senderId: "user-1",
channelId: "telegram",
channelContext: {
sender: { id: "user-1" },
chat: { id: "chat-1" },
},
},
});
const response = await invokeNativeHookRelay({
@@ -2273,7 +2322,17 @@ describe("native hook relay registry", () => {
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
jobId: "job-1",
trigger: "user",
messageProvider: "telegram-voice",
channel: "telegram",
chatId: "chat-1",
senderId: "user-1",
channelId: "telegram",
channelContext: {
sender: { id: "user-1" },
chat: { id: "chat-1" },
},
toolName: "exec",
toolCallId: "native-call-1",
});

View File

@@ -37,6 +37,7 @@ import {
requestDeferredPluginToolApproval,
runBeforeToolCallHook,
type DeferredPluginToolApproval,
type ToolHookRunContext,
} from "../agent-tools.before-tool-call.js";
import { stableStringify } from "../stable-stringify.js";
import { resolveToolLoopDetectionConfig } from "../tool-loop-detection-config.js";
@@ -104,6 +105,7 @@ export type NativeHookRelayRegistration = {
config?: OpenClawConfig;
runId: string;
channelId?: string;
toolHookContext?: ToolHookRunContext;
allowedEvents: readonly NativeHookRelayEvent[];
expiresAtMs: number;
signal?: AbortSignal;
@@ -131,6 +133,7 @@ export type RegisterNativeHookRelayParams = {
config?: OpenClawConfig;
runId: string;
channelId?: string;
toolHookContext?: ToolHookRunContext;
allowedEvents?: readonly NativeHookRelayEvent[];
ttlMs?: number;
command?: NativeHookRelayCommandOptions;
@@ -435,6 +438,7 @@ export function registerNativeHookRelay(
...(params.config ? { config: params.config } : {}),
runId: params.runId,
...(params.channelId ? { channelId: params.channelId } : {}),
...(params.toolHookContext ? { toolHookContext: params.toolHookContext } : {}),
allowedEvents,
expiresAtMs,
...(params.signal ? { signal: params.signal } : {}),
@@ -1398,6 +1402,7 @@ async function runNativeHookRelayPreToolUse(params: {
...(approvalMode === "report" ? { approvalMode: "defer" } : {}),
signal: params.registration.signal,
ctx: {
...params.registration.toolHookContext,
...(params.registration.agentId ? { agentId: params.registration.agentId } : {}),
sessionId: params.registration.sessionId,
...(params.registration.sessionKey ? { sessionKey: params.registration.sessionKey } : {}),
@@ -1447,6 +1452,7 @@ async function runNativeHookRelayPostToolUse(params: {
await runAgentHarnessAfterToolCallHook({
toolName,
toolCallId,
...params.registration.toolHookContext,
runId: params.registration.runId,
...(params.registration.agentId ? { agentId: params.registration.agentId } : {}),
sessionId: params.registration.sessionId,

View File

@@ -52,6 +52,8 @@ export type AgentHarnessSideQuestionParams = {
senderUsername?: string | null;
senderE164?: string | null;
senderIsOwner?: boolean;
chatId?: string;
channelContext?: import("../../plugins/hook-types.js").PluginHookChannelContext;
currentChannelId?: string;
toolsAllow?: string[];
authProfileId?: string;

View File

@@ -5,7 +5,7 @@
*/
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ToolLoopDetectionConfig } from "../config/types.tools.js";
import { resolveAgentConfig } from "./agent-scope.js";
import { resolveAgentConfig } from "./agent-scope-config.js";
/** Resolves effective tool loop-detection config by overlaying agent settings on globals. */
export function resolveToolLoopDetectionConfig(params: {

View File

@@ -5,7 +5,6 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { SubagentRunRecord } from "../../agents/subagent-registry.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionAbortTargetResult } from "../../config/sessions/session-accessor.js";
import {
testing as abortTesting,
getAbortMemory,
@@ -14,6 +13,7 @@ import {
isAbortTrigger,
resetAbortMemoryForTest,
resolveAbortCutoffFromContext,
resolveSessionEntryForKey,
setAbortMemory,
stopSubagentsForRequester,
shouldSkipMessageByAbortCutoff,
@@ -382,6 +382,32 @@ describe("abort detection", () => {
).toBe(false);
});
it("resolves session entry when key exists in store", () => {
const store = {
"session-1": { sessionId: "abc", updatedAt: 0 },
} as const;
expect(resolveSessionEntryForKey(store, "session-1")).toEqual({
entry: store["session-1"],
key: "session-1",
});
expect(resolveSessionEntryForKey(store, "session-2")).toStrictEqual({});
expect(resolveSessionEntryForKey(undefined, "session-1")).toStrictEqual({});
});
it("resolves Telegram forum topic session when lookup key has different casing than store", () => {
// Store normalizes keys to lowercase; caller may pass mixed-case. /stop in topic must find entry.
const storeKey = "agent:main:telegram:group:-1001234567890:topic:99";
const lookupKey = "Agent:Main:Telegram:Group:-1001234567890:Topic:99";
const store = {
[storeKey]: { sessionId: "agent-topic-99", updatedAt: 0 },
} as Record<string, { sessionId: string; updatedAt: number }>;
// Direct lookup fails (store uses lowercase keys); normalization fallback must succeed.
expect(store[lookupKey]).toBeUndefined();
const result = resolveSessionEntryForKey(store, lookupKey);
expect(result.entry?.sessionId).toBe("agent-topic-99");
expect(result.key).toBe(storeKey);
});
it("fast-aborts even when text commands are disabled", async () => {
const { cfg } = await createAbortConfig({ commandsTextEnabled: false });
@@ -447,240 +473,6 @@ describe("abort detection", () => {
expectSessionLaneCleared(sessionKey);
});
it("fast-abort resolves canonical stored session identity before metadata persistence", async () => {
const storeKey = "agent:main:telegram:group:-1001234567890:topic:99";
const lookupKey = "Agent:Main:Telegram:Group:-1001234567890:Topic:99";
const sessionId = "agent-topic-99";
const { root, cfg } = await createAbortConfig({
sessionIdsByKey: { [storeKey]: sessionId },
});
enqueueQueuedFollowupRun({ root, cfg, sessionId, sessionKey: storeKey });
const result = await runStopCommand({
cfg,
sessionKey: lookupKey,
from: "telegram:123",
to: "telegram:123",
});
expect(result.handled).toBe(true);
expect(runtimeAbortMocks.abortEmbeddedAgentRun).toHaveBeenCalledWith(sessionId);
expect(getFollowupQueueDepth(storeKey)).toBe(0);
expectSessionLaneCleared(storeKey);
});
it("fast-abort still stops active runs when abort metadata persistence fails", async () => {
const sessionKey = "telegram:persistence-failure";
const sessionId = "session-persistence-failure";
const activeSessionId = "active-persistence-failure";
const { root, cfg } = await createAbortConfig({
sessionIdsByKey: { [sessionKey]: sessionId },
});
runtimeAbortMocks.resolveActiveEmbeddedRunSessionId.mockReturnValue(activeSessionId);
abortTesting.setDepsForTests({
getAcpSessionManager: (() =>
({
resolveSession: acpManagerMocks.resolveSession,
cancelSession: acpManagerMocks.cancelSession,
}) as never) as never,
abortEmbeddedAgentRun: runtimeAbortMocks.abortEmbeddedAgentRun,
resolveActiveEmbeddedRunSessionId: runtimeAbortMocks.resolveActiveEmbeddedRunSessionId,
markSessionAbortTarget: vi.fn(async () => {
throw new Error("simulated persistence failure");
}),
getLatestSubagentRunByChildSessionKey:
subagentRegistryMocks.getLatestSubagentRunByChildSessionKey,
listSubagentRunsForController: subagentRegistryMocks.listSubagentRunsForRequester,
markSubagentRunTerminated: subagentRegistryMocks.markSubagentRunTerminated,
});
enqueueQueuedFollowupRun({ root, cfg, sessionId, sessionKey });
const result = await runStopCommand({
cfg,
sessionKey,
from: "telegram:123",
to: "telegram:123",
});
expect(result.handled).toBe(true);
expect(runtimeAbortMocks.abortEmbeddedAgentRun).toHaveBeenCalledWith(activeSessionId);
expect(getFollowupQueueDepth(sessionKey)).toBe(0);
expectSessionLaneCleared(sessionKey);
expect(getAbortMemory(sessionKey)).toBeUndefined();
});
it("fast-abort uses resolved target identity when abort metadata save fails", async () => {
const requestedKey = "Agent:Main:Telegram:Group:-1001234567890:Topic:99";
const canonicalKey = "agent:main:telegram:group:-1001234567890:topic:99";
const sessionId = "resolved-persistence-failure";
const { root, cfg } = await createAbortConfig();
abortTesting.setDepsForTests({
getAcpSessionManager: (() =>
({
resolveSession: acpManagerMocks.resolveSession,
cancelSession: acpManagerMocks.cancelSession,
}) as never) as never,
abortEmbeddedAgentRun: runtimeAbortMocks.abortEmbeddedAgentRun,
resolveActiveEmbeddedRunSessionId: runtimeAbortMocks.resolveActiveEmbeddedRunSessionId,
markSessionAbortTarget: vi.fn(async () => ({
entry: {
sessionId,
updatedAt: 10,
},
persisted: false,
persistenceError: "simulated persistence failure",
sessionId,
sessionKey: canonicalKey,
})),
resolveSessionAbortTarget: vi.fn(() => ({
entry: {
sessionId,
updatedAt: 10,
},
sessionId,
sessionKey: canonicalKey,
})),
getLatestSubagentRunByChildSessionKey:
subagentRegistryMocks.getLatestSubagentRunByChildSessionKey,
listSubagentRunsForController: subagentRegistryMocks.listSubagentRunsForRequester,
markSubagentRunTerminated: subagentRegistryMocks.markSubagentRunTerminated,
});
enqueueQueuedFollowupRun({ root, cfg, sessionId, sessionKey: canonicalKey });
const result = await runStopCommand({
cfg,
sessionKey: requestedKey,
from: "telegram:123",
to: "telegram:123",
});
expect(result.handled).toBe(true);
expect(runtimeAbortMocks.abortEmbeddedAgentRun).toHaveBeenCalledWith(sessionId);
expect(getFollowupQueueDepth(canonicalKey)).toBe(0);
expectSessionLaneCleared(canonicalKey);
expect(getAbortMemory(canonicalKey)).toBeUndefined();
});
it("fast-abort uses abort memory when no persisted target entry exists", async () => {
const sessionKey = "telegram:missing-persistence-target";
const { cfg } = await createAbortConfig();
abortTesting.setDepsForTests({
getAcpSessionManager: (() =>
({
resolveSession: acpManagerMocks.resolveSession,
cancelSession: acpManagerMocks.cancelSession,
}) as never) as never,
abortEmbeddedAgentRun: runtimeAbortMocks.abortEmbeddedAgentRun,
resolveActiveEmbeddedRunSessionId: runtimeAbortMocks.resolveActiveEmbeddedRunSessionId,
markSessionAbortTarget: vi.fn(async () => null),
resolveSessionAbortTarget: vi.fn(() => null),
getLatestSubagentRunByChildSessionKey:
subagentRegistryMocks.getLatestSubagentRunByChildSessionKey,
listSubagentRunsForController: subagentRegistryMocks.listSubagentRunsForRequester,
markSubagentRunTerminated: subagentRegistryMocks.markSubagentRunTerminated,
});
const result = await runStopCommand({
cfg,
sessionKey,
from: "telegram:123",
to: "telegram:123",
});
expect(result.handled).toBe(true);
expect(getAbortMemory(sessionKey)).toBe(true);
});
it("fast-abort does not wait for abort metadata persistence before stopping runs", async () => {
const sessionKey = "telegram:slow-persistence";
const childKey = "agent:main:subagent:slow-persistence-child";
const sessionId = "session-slow-persistence";
const childSessionId = "session-slow-persistence-child";
const { root, cfg } = await createAbortConfig({
sessionIdsByKey: {
[childKey]: childSessionId,
[sessionKey]: sessionId,
},
});
let finishPersistence: (() => void) | undefined;
const persistenceStarted = new Promise<void>((resolveStarted) => {
abortTesting.setDepsForTests({
getAcpSessionManager: (() =>
({
resolveSession: acpManagerMocks.resolveSession,
cancelSession: acpManagerMocks.cancelSession,
}) as never) as never,
abortEmbeddedAgentRun: runtimeAbortMocks.abortEmbeddedAgentRun,
resolveActiveEmbeddedRunSessionId: runtimeAbortMocks.resolveActiveEmbeddedRunSessionId,
markSessionAbortTarget: vi.fn(
() =>
new Promise<SessionAbortTargetResult | null>((resolvePersistence) => {
resolveStarted();
finishPersistence = () => {
resolvePersistence({
entry: {
sessionId,
updatedAt: 10,
},
persisted: true,
sessionId,
sessionKey,
});
};
}),
),
resolveSessionAbortTarget: vi.fn(() => ({
entry: {
sessionId,
updatedAt: 10,
},
sessionId,
sessionKey,
})),
getLatestSubagentRunByChildSessionKey:
subagentRegistryMocks.getLatestSubagentRunByChildSessionKey,
listSubagentRunsForController: subagentRegistryMocks.listSubagentRunsForRequester,
markSubagentRunTerminated: subagentRegistryMocks.markSubagentRunTerminated,
});
});
enqueueQueuedFollowupRun({ root, cfg, sessionId, sessionKey });
subagentRegistryMocks.listSubagentRunsForRequester.mockReturnValueOnce([
{
runId: "slow-child-run",
childSessionKey: childKey,
requesterSessionKey: sessionKey,
requesterDisplayKey: sessionKey,
task: "slow child",
cleanup: "keep",
createdAt: Date.now(),
},
]);
const resultPromise = runStopCommand({
cfg,
sessionKey,
from: "telegram:123",
to: "telegram:123",
});
await persistenceStarted;
expect(runtimeAbortMocks.abortEmbeddedAgentRun).toHaveBeenCalledWith(sessionId);
expect(runtimeAbortMocks.abortEmbeddedAgentRun).toHaveBeenCalledWith(childSessionId);
expect(subagentRegistryMocks.markSubagentRunTerminated).toHaveBeenCalledWith({
childSessionKey: childKey,
reason: "killed",
runId: "slow-child-run",
});
expect(getFollowupQueueDepth(sessionKey)).toBe(0);
expectSessionLaneCleared(sessionKey);
finishPersistence?.();
await expect(resultPromise).resolves.toMatchObject({
aborted: true,
handled: true,
});
});
it("plain-language stop on ACP-bound session triggers ACP cancel", async () => {
const sessionKey = "agent:codex:acp:test-1";
const sessionId = "session-123";

View File

@@ -19,15 +19,13 @@ import {
resolveInternalSessionKey,
resolveMainSessionAlias,
} from "../../agents/tools/sessions-helpers.js";
import { resolveStorePath } from "../../config/sessions.js";
import {
loadSessionEntry,
markSessionAbortTarget,
resolveSessionAbortTarget,
type SessionAbortTargetContext,
type SessionAbortTargetIdentity,
type SessionAbortTargetResult,
} from "../../config/sessions/session-accessor.js";
loadSessionStore,
resolveSessionStoreEntry,
resolveStorePath,
type SessionEntry,
updateSessionStore,
} from "../../config/sessions.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { logVerbose } from "../../globals.js";
import { formatErrorMessage } from "../../infra/errors.js";
@@ -35,7 +33,7 @@ import { isAcpSessionKey, parseAgentSessionKey } from "../../routing/session-key
import { resolveCommandAuthorization } from "../command-auth.js";
import type { FinalizedMsgContext } from "../templating.js";
import {
type AbortCutoff,
applyAbortCutoffToSessionEntry,
resolveAbortCutoffFromContext,
shouldPersistAbortCutoff,
} from "./abort-cutoff.js";
@@ -67,8 +65,6 @@ const defaultAbortDeps = {
getAcpSessionManager,
abortEmbeddedAgentRun,
resolveActiveEmbeddedRunSessionId,
markSessionAbortTarget,
resolveSessionAbortTarget,
getLatestSubagentRunByChildSessionKey,
listSubagentRunsForController,
markSubagentRunTerminated,
@@ -86,10 +82,6 @@ export const testing = {
deps?.abortEmbeddedAgentRun ?? defaultAbortDeps.abortEmbeddedAgentRun;
abortDeps.resolveActiveEmbeddedRunSessionId =
deps?.resolveActiveEmbeddedRunSessionId ?? defaultAbortDeps.resolveActiveEmbeddedRunSessionId;
abortDeps.markSessionAbortTarget =
deps?.markSessionAbortTarget ?? defaultAbortDeps.markSessionAbortTarget;
abortDeps.resolveSessionAbortTarget =
deps?.resolveSessionAbortTarget ?? defaultAbortDeps.resolveSessionAbortTarget;
abortDeps.getLatestSubagentRunByChildSessionKey =
deps?.getLatestSubagentRunByChildSessionKey ??
defaultAbortDeps.getLatestSubagentRunByChildSessionKey;
@@ -103,8 +95,6 @@ export const testing = {
abortDeps.abortEmbeddedAgentRun = defaultAbortDeps.abortEmbeddedAgentRun;
abortDeps.resolveActiveEmbeddedRunSessionId =
defaultAbortDeps.resolveActiveEmbeddedRunSessionId;
abortDeps.markSessionAbortTarget = defaultAbortDeps.markSessionAbortTarget;
abortDeps.resolveSessionAbortTarget = defaultAbortDeps.resolveSessionAbortTarget;
abortDeps.getLatestSubagentRunByChildSessionKey =
defaultAbortDeps.getLatestSubagentRunByChildSessionKey;
abortDeps.listSubagentRunsForController = defaultAbortDeps.listSubagentRunsForController;
@@ -141,6 +131,29 @@ export function formatAbortReplyText(stoppedSubagents?: number): string {
return `⚙️ Agent was aborted. Stopped ${stoppedSubagents} ${label}.`;
}
export function resolveSessionEntryForKey(
store: Record<string, SessionEntry> | undefined,
sessionKey: string | undefined,
): { entry?: SessionEntry; key?: string; legacyKeys?: string[] } {
if (!store || !sessionKey) {
return {};
}
const resolved = resolveSessionStoreEntry({ store, sessionKey });
if (resolved.existing) {
return resolved.legacyKeys.length > 0
? {
entry: resolved.existing,
key: resolved.normalizedKey,
legacyKeys: resolved.legacyKeys,
}
: {
entry: resolved.existing,
key: resolved.normalizedKey,
};
}
return {};
}
function resolveStoredSessionId(params: {
cfg: OpenClawConfig;
sessionKey: string;
@@ -151,12 +164,8 @@ function resolveStoredSessionId(params: {
});
const storePath = resolveStorePath(params.cfg.session?.store, { agentId });
try {
return loadSessionEntry({
agentId,
clone: false,
sessionKey: params.sessionKey,
storePath,
})?.sessionId;
const store = loadSessionStore(storePath);
return resolveSessionEntryForKey(store, params.sessionKey).entry?.sessionId;
} catch {
return undefined;
}
@@ -236,6 +245,7 @@ export function stopSubagentsForRequester(params: {
return { stopped: 0 };
}
const storeCache = new Map<string, Record<string, SessionEntry>>();
const seenChildKeys = new Set<string>();
let stopped = 0;
@@ -250,14 +260,13 @@ export function stopSubagentsForRequester(params: {
const cleared = clearSessionQueues([childKey]);
const parsed = parseAgentSessionKey(childKey);
const storePath = resolveStorePath(params.cfg.session?.store, { agentId: parsed?.agentId });
const sessionId =
replyRunRegistry.resolveSessionId(childKey) ??
loadSessionEntry({
agentId: parsed?.agentId,
clone: false,
sessionKey: childKey,
storePath,
})?.sessionId;
let store = storeCache.get(storePath);
if (!store) {
store = loadSessionStore(storePath);
storeCache.set(storePath, store);
}
const entry = store[childKey];
const sessionId = replyRunRegistry.resolveSessionId(childKey) ?? entry?.sessionId;
const aborted = abortSessionRunTarget({ key: childKey, sessionId });
const markedTerminated =
abortDeps.markSubagentRunTerminated({
@@ -331,26 +340,9 @@ export async function tryFastAbortFromMessage(params: {
if (targetKey) {
const storePath = resolveStorePath(cfg.session?.store, { agentId });
const abortCutoffForTarget = (target: SessionAbortTargetContext): AbortCutoff | undefined =>
shouldPersistAbortCutoff({
commandSessionKey,
targetSessionKey: target.sessionKey,
})
? resolveAbortCutoffFromContext(ctx)
: undefined;
let resolvedAbortTarget: SessionAbortTargetIdentity | null = null;
try {
resolvedAbortTarget = abortDeps.resolveSessionAbortTarget({
agentId,
sessionKey: targetKey,
storePath,
});
} catch (error) {
logVerbose(
`abort: failed to resolve abort metadata for ${targetKey}: ${formatErrorMessage(error)}`,
);
}
const resolvedTargetKey = resolvedAbortTarget?.sessionKey ?? targetKey;
const store = loadSessionStore(storePath);
const { entry, key, legacyKeys } = resolveSessionEntryForKey(store, targetKey);
const resolvedTargetKey = key ?? targetKey;
const conversationBoundAcpTargetKey = commandSessionKey
? resolveBoundAcpAbortTargetSessionKey({
ctx,
@@ -396,7 +388,7 @@ export async function tryFastAbortFromMessage(params: {
abortTargetKey,
replyRunRegistry.resolveSessionId(abortTargetKey) ??
(abortTargetKey === resolvedTargetKey
? resolvedAbortTarget?.sessionId
? entry?.sessionId
: resolveStoredSessionId({ cfg, sessionKey: abortTargetKey })),
]),
);
@@ -426,33 +418,44 @@ export async function tryFastAbortFromMessage(params: {
`abort: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
);
}
const { stopped } = stopSubagentsForRequester({ cfg, requesterSessionKey });
let persistedAbortTarget: SessionAbortTargetResult | null = null;
try {
persistedAbortTarget = await abortDeps.markSessionAbortTarget({
scope: {
agentId,
sessionKey: targetKey,
storePath,
},
resolveAbortCutoff: abortCutoffForTarget,
// Cutoff metadata is only safe in the command session's message id/time
// space. Bound ACP/source stops may abort extra lanes, but those lanes do
// not necessarily share the source conversation's message ordering.
const abortCutoff = shouldPersistAbortCutoff({
commandSessionKey,
targetSessionKey: resolvedTargetKey,
})
? resolveAbortCutoffFromContext(ctx)
: undefined;
if (entry && key) {
entry.abortedLastRun = true;
applyAbortCutoffToSessionEntry(entry, abortCutoff);
entry.updatedAt = Date.now();
store[key] = entry;
for (const legacyKey of legacyKeys ?? []) {
if (legacyKey !== key) {
delete store[legacyKey];
}
}
await updateSessionStore(storePath, (nextStore) => {
const nextEntry = nextStore[key] ?? entry;
if (!nextEntry) {
return;
}
nextEntry.abortedLastRun = true;
applyAbortCutoffToSessionEntry(nextEntry, abortCutoff);
nextEntry.updatedAt = Date.now();
nextStore[key] = nextEntry;
for (const legacyKey of legacyKeys ?? []) {
if (legacyKey !== key) {
delete nextStore[legacyKey];
}
}
});
} catch (error) {
logVerbose(
`abort: failed to persist abort metadata for ${targetKey}: ${formatErrorMessage(error)}`,
);
}
if (persistedAbortTarget?.persisted === false) {
logVerbose(
`abort: failed to persist abort metadata for ${targetKey}: ${persistedAbortTarget.persistenceError ?? "unknown error"}`,
);
}
const abortMemoryKey =
persistedAbortTarget?.sessionKey ?? resolvedAbortTarget?.sessionKey ?? abortKey;
const hasAbortTargetEntry = Boolean(persistedAbortTarget?.entry ?? resolvedAbortTarget?.entry);
if (persistedAbortTarget?.persisted !== true && abortMemoryKey && !hasAbortTargetEntry) {
setAbortMemory(abortMemoryKey, true);
} else if (abortKey) {
setAbortMemory(abortKey, true);
}
const { stopped } = stopSubagentsForRequester({ cfg, requesterSessionKey });
return { handled: true, aborted, stoppedSubagents: stopped };
}

View File

@@ -7,9 +7,6 @@ import type { HandleCommandsParams } from "./commands-types.js";
const abortEmbeddedAgentRunMock = vi.hoisted(() => vi.fn());
const persistAbortTargetEntryMock = vi.hoisted(() => vi.fn());
const resolveCommandSessionEntryForKeyMock = vi.hoisted(() =>
vi.fn(() => ({ entry: undefined, key: "agent:main:main" })),
);
const setAbortMemoryMock = vi.hoisted(() => vi.fn());
const abortSessionRunTargetMock = vi.hoisted(() => vi.fn());
@@ -35,13 +32,13 @@ vi.mock("./abort.js", () => ({
abortSessionRunTarget: abortSessionRunTargetMock,
formatAbortReplyText: vi.fn(() => "⚙️ Agent was aborted."),
isAbortTrigger: vi.fn((raw: string) => raw === "stop"),
resolveSessionEntryForKey: vi.fn(() => ({ entry: undefined, key: "agent:main:main" })),
setAbortMemory: setAbortMemoryMock,
stopSubagentsForRequester: vi.fn(() => ({ stopped: 0 })),
}));
vi.mock("./commands-session-store.js", () => ({
persistAbortTargetEntry: persistAbortTargetEntryMock,
resolveCommandSessionEntryForKey: resolveCommandSessionEntryForKeyMock,
}));
vi.mock("./reply-run-registry.js", () => ({

View File

@@ -130,6 +130,12 @@ describe("handleBtwCommand", () => {
params.ctx.SenderUsername = "rosita";
params.ctx.SenderE164 = "+15550001";
params.ctx.MessageThreadId = "thread-1";
params.ctx.NativeChannelId = "native-chat-1";
params.ctx.ChatId = "legacy-chat";
params.ctx.ChannelContext = {
sender: { id: "sender-1", providerUserId: "provider-user-1" },
chat: { id: "native-chat-1", providerThreadKey: "thread-key-1" },
};
params.agentDir = "/tmp/agent";
params.sessionEntry = {
sessionId: "session-1",
@@ -161,6 +167,11 @@ describe("handleBtwCommand", () => {
senderUsername: "rosita",
senderE164: "+15550001",
senderIsOwner: true,
chatId: "native-chat-1",
channelContext: {
sender: { id: "sender-1", providerUserId: "provider-user-1" },
chat: { id: "native-chat-1", providerThreadKey: "thread-key-1" },
},
});
expect(String(runnerArgs.agentDir)).toContain("/agents/main/agent");
expect(result).toEqual({

View File

@@ -1,4 +1,5 @@
/** Handles /btw side-question commands against the active session context. */
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js";
import { runBtwSideQuestion } from "../../agents/btw.js";
import { resolveGroupSessionKey } from "../../config/sessions/group.js";
@@ -57,6 +58,12 @@ export const handleBtwCommand: CommandHandler = async (params, allowTextCommands
await params.typing?.startTypingLoop();
const currentChannelId =
params.ctx.OriginatingTo?.trim() || params.command.to || params.command.channelId;
const chatId =
normalizeOptionalString(params.ctx.NativeChannelId) ??
normalizeOptionalString(params.ctx.ChatId) ??
normalizeOptionalString(params.rootCtx?.NativeChannelId) ??
normalizeOptionalString(params.rootCtx?.ChatId);
const channelContext = params.ctx.ChannelContext ?? params.rootCtx?.ChannelContext;
const groupId = resolveGroupSessionKey(params.ctx)?.id ?? targetSessionEntry.groupId;
const reply = await runBtwSideQuestion({
cfg: params.cfg,
@@ -109,6 +116,8 @@ export const handleBtwCommand: CommandHandler = async (params, allowTextCommands
...(params.ctx.SenderUsername ? { senderUsername: params.ctx.SenderUsername } : {}),
...(params.ctx.SenderE164 ? { senderE164: params.ctx.SenderE164 } : {}),
senderIsOwner: params.command.senderIsOwner,
...(chatId ? { chatId } : {}),
...(channelContext ? { channelContext } : {}),
...(currentChannelId ? { currentChannelId } : {}),
});
return {

View File

@@ -12,14 +12,12 @@ import {
abortSessionRunTarget,
formatAbortReplyText,
isAbortTrigger,
resolveSessionEntryForKey,
setAbortMemory,
stopSubagentsForRequester,
} from "./abort.js";
import { rejectUnauthorizedCommand } from "./command-gates.js";
import {
persistAbortTargetEntry,
resolveCommandSessionEntryForKey,
} from "./commands-session-store.js";
import { persistAbortTargetEntry } from "./commands-session-store.js";
import type { CommandHandler } from "./commands-types.js";
import { clearSessionQueues } from "./queue.js";
import { replyRunRegistry } from "./reply-run-registry.js";
@@ -38,7 +36,7 @@ function resolveAbortTarget(params: {
}): AbortTarget {
const targetSessionKey =
normalizeOptionalString(params.ctx.CommandTargetSessionKey) || params.sessionKey;
const { entry, key } = resolveCommandSessionEntryForKey(params.sessionStore, targetSessionKey);
const { entry, key } = resolveSessionEntryForKey(params.sessionStore, targetSessionKey);
if (entry && key) {
return {
entry,

View File

@@ -1,29 +1,11 @@
// Shared session-store helpers for command handlers that mutate sessions.
import { resolveSessionStoreEntry, type SessionEntry } from "../../config/sessions.js";
import type { SessionEntry } from "../../config/sessions.js";
import { updateSessionStore } from "../../config/sessions.js";
import { applyAbortCutoffToSessionEntry, type AbortCutoff } from "./abort-cutoff.js";
import type { CommandHandler } from "./commands-types.js";
type CommandParams = Parameters<CommandHandler>[0];
/** Resolves a command target entry through canonical and legacy session keys. */
export function resolveCommandSessionEntryForKey(
store: Record<string, SessionEntry> | undefined,
sessionKey: string | undefined,
): { entry?: SessionEntry; key?: string } {
if (!store || !sessionKey) {
return {};
}
const resolved = resolveSessionStoreEntry({ store, sessionKey });
if (!resolved.existing) {
return {};
}
return {
entry: resolved.existing,
key: resolved.normalizedKey,
};
}
export async function persistSessionEntry(params: CommandParams): Promise<boolean> {
if (!params.sessionEntry || !params.sessionStore || !params.sessionKey) {
return false;

View File

@@ -16,9 +16,6 @@ import type { HandleCommandsParams } from "./commands-types.js";
const abortEmbeddedAgentRunMock = vi.hoisted(() => vi.fn());
const createInternalHookEventMock = vi.hoisted(() => vi.fn(() => ({})));
const persistAbortTargetEntryMock = vi.hoisted(() => vi.fn(async () => true));
const resolveCommandSessionEntryForKeyMock = vi.hoisted(() =>
vi.fn(() => ({ entry: undefined, key: undefined })),
);
const resolveSessionIdMock = vi.hoisted(() => vi.fn(() => undefined));
const stopSubagentsForRequesterMock = vi.hoisted(() => vi.fn(() => ({ stopped: 0 })));
const abortSessionRunTargetMock = vi.hoisted(() => vi.fn());
@@ -45,13 +42,13 @@ vi.mock("./abort.js", () => ({
abortSessionRunTarget: abortSessionRunTargetMock,
formatAbortReplyText: vi.fn(() => "⚙️ Agent was aborted."),
isAbortTrigger: vi.fn(() => false),
resolveSessionEntryForKey: vi.fn(() => ({ entry: undefined, key: undefined })),
setAbortMemory: vi.fn(),
stopSubagentsForRequester: stopSubagentsForRequesterMock,
}));
vi.mock("./commands-session-store.js", () => ({
persistAbortTargetEntry: persistAbortTargetEntryMock,
resolveCommandSessionEntryForKey: resolveCommandSessionEntryForKeyMock,
}));
vi.mock("./reply-run-registry.js", () => ({

View File

@@ -268,15 +268,6 @@ export function resolveSessionTranscriptPath(
return resolveSessionTranscriptPathInDir(sessionId, resolveAgentSessionsDir(agentId), topicId);
}
export function resolveExplicitSessionFilePath(
sessionFile: string,
opts?: SessionFilePathOptions,
): string {
return resolvePathWithinSessionsDir(resolveSessionsDir(opts), sessionFile, {
agentId: opts?.agentId,
});
}
export function resolveSessionFilePath(
sessionId: string,
entry?: { sessionFile?: string },

View File

@@ -15,7 +15,6 @@ import {
createSessionEntryWithTranscript,
listSessionEntries,
loadSessionEntry,
markSessionAbortTarget,
patchSessionEntry,
persistSessionResetLifecycle,
persistSessionRolloverLifecycle,
@@ -33,7 +32,7 @@ import {
updateSessionEntry,
upsertSessionEntry,
} from "./session-accessor.js";
import { loadSessionStore, saveSessionStore, updateSessionStoreEntry } from "./store.js";
import { loadSessionStore, updateSessionStoreEntry } from "./store.js";
import { withOwnedSessionTranscriptWrites } from "./transcript-write-context.js";
import type { SessionEntry } from "./types.js";
@@ -90,75 +89,6 @@ describe("session accessor file-backed seam", () => {
});
});
it("marks abort targets while canonicalizing legacy session keys", async () => {
fs.writeFileSync(
storePath,
JSON.stringify({
"agent:main:telegram:group:-1001234567890:topic:99": {
sessionId: "canonical-session",
updatedAt: 10,
},
"Agent:Main:Telegram:Group:-1001234567890:Topic:99": {
sessionId: "legacy-session",
updatedAt: 20,
},
} satisfies Record<string, SessionEntry>),
"utf8",
);
expect(loadSessionStore(storePath)).toHaveProperty(
"Agent:Main:Telegram:Group:-1001234567890:Topic:99",
);
const result = await markSessionAbortTarget({
scope: {
sessionKey: "Agent:Main:Telegram:Group:-1001234567890:Topic:99",
storePath,
},
now: () => 30,
resolveAbortCutoff: ({ sessionKey }) => {
expect(sessionKey).toBe("agent:main:telegram:group:-1001234567890:topic:99");
return {
messageSid: "55",
timestamp: 1234567890000,
};
},
});
expect(result).toMatchObject({
sessionId: "legacy-session",
sessionKey: "agent:main:telegram:group:-1001234567890:topic:99",
entry: {
abortedLastRun: true,
abortCutoffMessageSid: "55",
abortCutoffTimestamp: 1234567890000,
sessionId: "legacy-session",
updatedAt: 30,
},
});
expect(loadSessionStore(storePath)).toEqual({
"agent:main:telegram:group:-1001234567890:topic:99": expect.objectContaining({
abortedLastRun: true,
abortCutoffMessageSid: "55",
abortCutoffTimestamp: 1234567890000,
sessionId: "legacy-session",
updatedAt: 30,
}),
});
});
it("does not persist abort target changes when the entry is absent", async () => {
const result = await markSessionAbortTarget({
scope: {
sessionKey: "agent:main:missing",
storePath,
},
resolveAbortCutoff: () => ({ messageSid: "unused" }),
});
expect(result).toBeNull();
expect(fs.existsSync(storePath)).toBe(false);
});
it("purges deleted-agent entries from the current locked store", async () => {
const cfg = {
session: { store: storePath },
@@ -750,84 +680,6 @@ describe("session accessor file-backed seam", () => {
expect(fs.readdirSync(siblingDir)).toEqual(["sibling-lifecycle.jsonl"]);
});
it("preserves fresh lifecycle entries that only have explicit sessionFile metadata", async () => {
const nowMs = Date.now();
const lifecycleSessionsDir = path.join(tempDir, "state", "agents", "main", "sessions");
const lifecycleStorePath = path.join(lifecycleSessionsDir, "sessions.json");
const freshTranscriptPath = path.join(lifecycleSessionsDir, "session-file-only.jsonl");
fs.mkdirSync(lifecycleSessionsDir, { recursive: true });
await saveSessionStore(
lifecycleStorePath,
{
"agent:main:lifecycle-cleanup-file-only": {
sessionFile: freshTranscriptPath,
updatedAt: nowMs,
} as SessionEntry,
},
{ skipMaintenance: true },
);
fs.writeFileSync(freshTranscriptPath, '{"runId":"lifecycle-marker-file-only"}\n', "utf-8");
const result = await cleanupSessionLifecycleArtifacts({
storePath: lifecycleStorePath,
sessionKeySegmentPrefix: "lifecycle-cleanup-",
transcriptContentMarker: "lifecycle-marker-",
orphanTranscriptMinAgeMs: 300_000,
nowMs,
});
expect(result).toEqual({ removedEntries: 0, archivedTranscriptArtifacts: 0 });
expect(loadSessionStore(lifecycleStorePath, { skipCache: true })).toHaveProperty(
"agent:main:lifecycle-cleanup-file-only",
);
expect(fs.existsSync(freshTranscriptPath)).toBe(true);
});
it("prefers current generated lifecycle transcripts over stale generated sessionFile metadata", async () => {
const nowMs = Date.now();
const oldDate = new Date(nowMs - 600_000);
const currentSessionId = "11111111-1111-4111-8111-111111111111";
const staleSessionId = "22222222-2222-4222-8222-222222222222";
const lifecycleSessionsDir = path.join(tempDir, "state", "agents", "main", "sessions");
const lifecycleStorePath = path.join(lifecycleSessionsDir, "sessions.json");
const currentTranscriptPath = path.join(lifecycleSessionsDir, `${currentSessionId}.jsonl`);
const staleTranscriptPath = path.join(lifecycleSessionsDir, `${staleSessionId}.jsonl`);
fs.mkdirSync(lifecycleSessionsDir, { recursive: true });
await saveSessionStore(
lifecycleStorePath,
{
"agent:main:lifecycle-cleanup-current": {
sessionFile: staleTranscriptPath,
sessionId: currentSessionId,
updatedAt: nowMs,
},
},
{ skipMaintenance: true },
);
fs.writeFileSync(currentTranscriptPath, '{"runId":"lifecycle-marker-current"}\n', "utf-8");
fs.writeFileSync(staleTranscriptPath, '{"runId":"lifecycle-marker-stale"}\n', "utf-8");
fs.utimesSync(staleTranscriptPath, oldDate, oldDate);
const result = await cleanupSessionLifecycleArtifacts({
storePath: lifecycleStorePath,
sessionKeySegmentPrefix: "lifecycle-cleanup-",
transcriptContentMarker: "lifecycle-marker-",
orphanTranscriptMinAgeMs: 300_000,
nowMs,
});
expect(result).toEqual({ removedEntries: 0, archivedTranscriptArtifacts: 1 });
expect(loadSessionStore(lifecycleStorePath, { skipCache: true })).toHaveProperty(
"agent:main:lifecycle-cleanup-current",
);
expect(fs.existsSync(currentTranscriptPath)).toBe(true);
expect(
fs
.readdirSync(lifecycleSessionsDir)
.filter((file) => file.startsWith(`${staleSessionId}.jsonl.deleted.`)),
).toHaveLength(1);
});
it("persists reset lifecycle entry changes with transcript replay and cleanup", async () => {
const now = Date.now();
const sessionKey = "agent:main:main";

View File

@@ -49,7 +49,6 @@ import {
loadSessionStore,
applySessionEntryPatchProjection as applyFileSessionEntryPatchProjection,
patchSessionEntry as patchFileSessionEntry,
patchSessionEntryWithKey as patchFileSessionEntryWithKey,
purgeDeletedAgentSessionEntries as purgeFileDeletedAgentSessionEntries,
readSessionUpdatedAt as readFileSessionUpdatedAt,
resolveSessionStoreEntry,
@@ -345,25 +344,6 @@ export type SessionEntryUpdateOptions = {
requireWriteSuccess?: boolean;
};
export type SessionAbortTargetCutoff = {
messageSid?: string;
timestamp?: number;
};
export type SessionAbortTargetContext = {
entry: SessionEntry;
sessionKey: string;
};
export type SessionAbortTargetIdentity = SessionAbortTargetContext & {
sessionId?: string;
};
export type SessionAbortTargetResult = SessionAbortTargetIdentity & {
persisted: boolean;
persistenceError?: string;
};
export type SessionLifecycleTranscriptInfo = {
sessionFile?: string;
transcriptArchived?: boolean;
@@ -395,14 +375,8 @@ export type SessionEntryPatchOptions = {
maintenanceConfig?: ResolvedSessionMaintenanceConfig;
/** Keep the previous updatedAt value when the patch should not count as activity. */
preserveActivity?: boolean;
/** Throw when best-effort store recovery cannot confirm the requested write. */
requireWriteSuccess?: boolean;
/** Replace the whole entry instead of merging the returned patch. */
replaceEntry?: boolean;
/** Skip prune/cap/rotation maintenance for specialized internal updates. */
skipMaintenance?: boolean;
/** Let the writer cache retain the updated object without cloning. */
takeCacheOwnership?: boolean;
};
export type SessionEntryPatchContext = {
@@ -410,13 +384,6 @@ export type SessionEntryPatchContext = {
existingEntry?: SessionEntry;
};
export type SessionEntryPatchResult = {
/** Exact persisted key for the patched entry after alias normalization. */
sessionKey: string;
/** Persisted entry returned by the backing store. */
entry: SessionEntry;
};
export type RestartRecoveryLifecycleEntry = {
/** Exact persisted key for the restart recovery candidate row. */
sessionKey: string;
@@ -768,35 +735,7 @@ export async function patchSessionEntry(
fallbackEntry: options.fallbackEntry,
maintenanceConfig: options.maintenanceConfig,
preserveActivity: options.preserveActivity,
requireWriteSuccess: options.requireWriteSuccess,
replaceEntry: options.replaceEntry,
skipMaintenance: options.skipMaintenance,
takeCacheOwnership: options.takeCacheOwnership,
update,
});
}
/**
* Applies an atomic patch and returns the persisted key selected by the backing
* store. Use when a caller must keep sidecar state keyed to the final row.
*/
export async function patchSessionEntryWithKey(
scope: SessionAccessScope,
update: (
entry: SessionEntry,
context: SessionEntryPatchContext,
) => Promise<Partial<SessionEntry> | null> | Partial<SessionEntry> | null,
options: SessionEntryPatchOptions = {},
): Promise<SessionEntryPatchResult | null> {
return await patchFileSessionEntryWithKey({
...scope,
fallbackEntry: options.fallbackEntry,
maintenanceConfig: options.maintenanceConfig,
preserveActivity: options.preserveActivity,
requireWriteSuccess: options.requireWriteSuccess,
replaceEntry: options.replaceEntry,
skipMaintenance: options.skipMaintenance,
takeCacheOwnership: options.takeCacheOwnership,
update,
});
}
@@ -913,109 +852,6 @@ export async function updateSessionEntry(
});
}
/** Resolves one abort target identity without exposing the mutable store. */
export function resolveSessionAbortTarget(
scope: SessionAccessScope,
): SessionAbortTargetIdentity | null {
const store = loadSessionStore(resolveAccessStorePath(scope));
const resolved = resolveSessionStoreEntry({ store, sessionKey: scope.sessionKey });
if (!resolved.existing) {
return null;
}
return {
entry: { ...resolved.existing },
sessionId: resolved.existing.sessionId,
sessionKey: resolved.normalizedKey,
};
}
/**
* Resolves, marks, touches, and canonicalizes one abort target entry as a
* storage-sized operation. Runtime abort side effects remain with callers.
*/
export async function markSessionAbortTarget(params: {
resolveAbortCutoff?: (context: SessionAbortTargetContext) => SessionAbortTargetCutoff | undefined;
scope: SessionAccessScope;
now?: () => number;
}): Promise<SessionAbortTargetResult | null> {
const storePath = resolveAccessStorePath(params.scope);
let canPersistSingleEntry = false;
let resolvedTarget: SessionAbortTargetResult | null = null;
try {
return await updateSessionStore(
storePath,
(store) => {
const resolved = resolveSessionStoreEntry({
store,
sessionKey: params.scope.sessionKey,
});
if (!resolved.existing) {
return null;
}
const sessionKey = resolved.normalizedKey;
resolvedTarget = {
entry: { ...resolved.existing },
persisted: false,
sessionId: resolved.existing.sessionId,
sessionKey,
};
const entry = {
...resolved.existing,
abortedLastRun: true,
updatedAt: params.now?.() ?? Date.now(),
};
applySessionAbortCutoff(
entry,
params.resolveAbortCutoff?.({
entry: { ...resolved.existing },
sessionKey,
}),
);
store[sessionKey] = entry;
canPersistSingleEntry = resolved.legacyKeys.length === 0;
for (const legacyKey of resolved.legacyKeys) {
if (legacyKey !== sessionKey) {
delete store[legacyKey];
}
}
return {
entry: { ...entry },
persisted: true,
sessionId: entry.sessionId,
sessionKey,
};
},
{
resolveSingleEntryPersistence: (result) =>
result && result.sessionKey && canPersistSingleEntry
? { sessionKey: result.sessionKey, entry: result.entry }
: null,
skipSaveWhenResult: (result) => result === null,
},
);
} catch (error) {
const fallbackTarget = resolvedTarget as unknown as SessionAbortTargetResult | null;
if (fallbackTarget) {
return {
entry: fallbackTarget.entry,
persisted: fallbackTarget.persisted,
sessionId: fallbackTarget.sessionId,
sessionKey: fallbackTarget.sessionKey,
persistenceError: formatErrorMessage(error),
};
}
throw error;
}
}
function applySessionAbortCutoff(
entry: Pick<SessionEntry, "abortCutoffMessageSid" | "abortCutoffTimestamp">,
cutoff: SessionAbortTargetCutoff | undefined,
): void {
entry.abortCutoffMessageSid = cutoff?.messageSid;
entry.abortCutoffTimestamp = cutoff?.timestamp;
}
/**
* Applies a session patch projection through the accessor boundary.
* The resolver sees a read-only snapshot and names the persisted key set; the

View File

@@ -10,7 +10,6 @@ import type { OpenClawConfig } from "../config.js";
import type { SessionConfig } from "../types.base.js";
import { resolveSessionLifecycleTimestamps } from "./lifecycle.js";
import {
resolveExplicitSessionFilePath,
resolveSessionFilePath,
resolveSessionFilePathOptions,
resolveSessionTranscriptPathInDir,
@@ -73,16 +72,6 @@ describe("session path safety", () => {
expect(resolved).toBe(path.resolve(sessionsDir, "sess-1.jsonl"));
});
it("rejects explicit sessionFile paths without derived fallback", () => {
const sessionsDir = "/tmp/openclaw/agents/main/sessions";
expect(() =>
resolveExplicitSessionFilePath("/tmp/openclaw/agents/work/not-sessions/abc-123.jsonl", {
sessionsDir,
}),
).toThrow(/within sessions directory/);
});
it("ignores multi-store sentinel paths when deriving session file options", () => {
expect(resolveSessionFilePathOptions({ agentId: "worker", storePath: "(multiple)" })).toEqual({
agentId: "worker",

View File

@@ -24,9 +24,8 @@ import {
pruneUnreferencedSessionArtifacts,
type SessionUnreferencedArtifactSweepResult,
} from "./disk-budget.js";
import { extractGeneratedTranscriptSessionId } from "./generated-transcript-session-id.js";
import { deriveSessionMetaPatch } from "./metadata.js";
import { resolveExplicitSessionFilePath, resolveSessionFilePath, resolveStorePath } from "./paths.js";
import { resolveSessionFilePath, resolveStorePath } from "./paths.js";
import {
ensureSessionStorePromptBlobsForPersistence,
isSessionSkillPromptBlobReadable,
@@ -221,8 +220,6 @@ type SessionEntryWorkflowOptions = {
export type SessionLifecycleArtifactCleanupParams = {
/** Session store to clean. */
storePath: string;
/** Archive exact transcripts referenced by removed entries before the orphan marker scan. */
archiveRemovedEntryTranscripts?: boolean;
/** Matches the persisted session-key segment after `agent:<id>:`. */
sessionKeySegmentPrefix: string;
/** Marker that identifies transcript artifacts owned by this lifecycle. */
@@ -757,20 +754,11 @@ function resolveLifecycleTranscriptPath(params: {
sessionsDir: string;
}): string | null {
const sessionId = params.entry?.sessionId?.trim();
const sessionFile = params.entry?.sessionFile?.trim();
const generatedSessionId = extractGeneratedTranscriptSessionId(sessionFile);
if (sessionFile && (!sessionId || !generatedSessionId || generatedSessionId === sessionId)) {
try {
return resolveExplicitSessionFilePath(sessionFile, { sessionsDir: params.sessionsDir });
} catch {
return null;
}
}
if (!sessionId) {
return null;
}
try {
return resolveSessionFilePath(sessionId, undefined, { sessionsDir: params.sessionsDir });
return resolveSessionFilePath(sessionId, params.entry, { sessionsDir: params.sessionsDir });
} catch {
return null;
}
@@ -1524,7 +1512,6 @@ export async function cleanupSessionLifecycleArtifacts(
const sessionsDir = path.dirname(storePath);
const removedSessionFiles = new Map<string, string | undefined>();
const removedTranscriptPaths: Array<{ sessionId: string; transcriptPath: string }> = [];
const archiveRemovedEntryTranscripts = params.archiveRemovedEntryTranscripts !== false;
let removedEntries = 0;
let archivedTranscriptArtifacts = 0;
@@ -1543,11 +1530,9 @@ export async function cleanupSessionLifecycleArtifacts(
orphanTranscriptMinAgeMs: params.orphanTranscriptMinAgeMs,
})
) {
if (archiveRemovedEntryTranscripts) {
rememberRemovedSessionFile(removedSessionFiles, entry);
if (entry.sessionId && transcriptPath && fs.existsSync(transcriptPath)) {
removedTranscriptPaths.push({ sessionId: entry.sessionId, transcriptPath });
}
rememberRemovedSessionFile(removedSessionFiles, entry);
if (entry.sessionId && transcriptPath && fs.existsSync(transcriptPath)) {
removedTranscriptPaths.push({ sessionId: entry.sessionId, transcriptPath });
}
delete store[sessionKey];
removedEntries += 1;
@@ -1773,29 +1758,18 @@ export async function applySessionStoreEntryPatch(params: {
});
}
type SessionEntryPatchParams = SessionEntryWorkflowOptions & {
sessionKey: string;
fallbackEntry?: SessionEntry;
preserveActivity?: boolean;
requireWriteSuccess?: boolean;
replaceEntry?: boolean;
skipMaintenance?: boolean;
takeCacheOwnership?: boolean;
update: (
entry: SessionEntry,
context: { existingEntry?: SessionEntry },
) => Promise<Partial<SessionEntry> | null> | Partial<SessionEntry> | null;
};
export async function patchSessionEntry(
params: SessionEntryPatchParams,
params: SessionEntryWorkflowOptions & {
sessionKey: string;
fallbackEntry?: SessionEntry;
preserveActivity?: boolean;
replaceEntry?: boolean;
update: (
entry: SessionEntry,
context: { existingEntry?: SessionEntry },
) => Promise<Partial<SessionEntry> | null> | Partial<SessionEntry> | null;
},
): Promise<SessionEntry | null> {
return (await patchSessionEntryWithKey(params))?.entry ?? null;
}
export async function patchSessionEntryWithKey(
params: SessionEntryPatchParams,
): Promise<{ sessionKey: string; entry: SessionEntry } | null> {
const storePath = resolveSessionWorkflowStorePath(params);
return await runExclusiveSessionStoreWrite(storePath, async () => {
const store = loadMutableSessionStoreForWriter(storePath);
@@ -1808,27 +1782,22 @@ export async function patchSessionEntryWithKey(
existingEntry: resolved.existing ? cloneSessionEntry(resolved.existing) : undefined,
});
if (!patch) {
return { sessionKey: resolved.normalizedKey, entry: existing };
return existing;
}
const next = params.replaceEntry
? cloneSessionEntry(patch as SessionEntry)
: params.preserveActivity
? mergeSessionEntryPreserveActivity(existing, patch)
: mergeSessionEntry(existing, patch);
return {
sessionKey: resolved.normalizedKey,
entry: await persistResolvedSessionEntry({
storePath,
store,
resolved,
next,
maintenanceConfig: params.maintenanceConfig,
requireWriteSuccess: params.requireWriteSuccess,
skipMaintenance: params.skipMaintenance,
takeCacheOwnership: params.takeCacheOwnership ?? true,
returnDetached: params.takeCacheOwnership !== true,
}),
};
return await persistResolvedSessionEntry({
storePath,
store,
resolved,
next,
maintenanceConfig: params.maintenanceConfig,
takeCacheOwnership: true,
returnDetached: true,
});
});
}

View File

@@ -47,6 +47,16 @@ type BeforeToolCallHookInput = {
agentId?: string;
config?: unknown;
sessionKey?: string;
sessionId?: string;
messageProvider?: string;
channel?: string;
channelId?: string;
chatId?: string;
senderId?: string;
channelContext?: {
sender?: { id?: string };
chat?: { id?: string };
};
};
signal?: unknown;
};
@@ -1439,6 +1449,37 @@ describe("mcp loopback server", () => {
expectMcpResultText(payload, "blocked by hook", true);
});
it("passes canonical prefixed channel origin into loopback before-tool hooks", async () => {
const execute = vi.fn<MockGatewayTool["execute"]>(async () => ({
content: [{ type: "text", text: "EXECUTED" }],
}));
mockScopedTools([makeMessageTool({ execute })]);
const { runtime } = await startLoopbackServerForTest();
const response = await sendLoopbackToolCall({
token: runtime.ownerToken,
name: "message",
args: { body: "hello" },
headers: {
"x-session-key": "agent:main:main",
"x-openclaw-session-id": "session-origin-1",
"x-openclaw-message-channel": "telegram",
"x-openclaw-current-channel-id": "telegram:chat123",
},
});
expectMcpResultText(await readOkMcpPayload(response), "EXECUTED", false);
expect(getBeforeToolCallHookInput(0).ctx).toEqual({
agentId: "main",
config: { session: { mainKey: "main" } },
sessionKey: "agent:main:main",
sessionId: "session-origin-1",
messageProvider: "telegram",
channel: "telegram",
channelId: "chat123",
});
});
it("forwards the request abort signal to loopback tool execution", async () => {
const execute = vi.fn<MockGatewayTool["execute"]>(async () => ({
content: [{ type: "text", text: "EXECUTED" }],

View File

@@ -11,6 +11,7 @@ import { getRuntimeConfig } from "../config/io.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { formatErrorMessage } from "../infra/errors.js";
import { logDebug, logWarn } from "../logger.js";
import { buildAgentHookContextOriginFields } from "../plugins/hook-agent-context.js";
import { handleMcpJsonRpc } from "./mcp-http.handlers.js";
import {
clearActiveMcpLoopbackRuntimeByOwnerToken,
@@ -273,6 +274,12 @@ export async function startMcpLoopbackServer(port = 0): Promise<{
agentId: scopedTools.agentId,
config: cfg,
sessionKey: requestContext.sessionKey,
...(requestContext.sessionId ? { sessionId: requestContext.sessionId } : {}),
...buildAgentHookContextOriginFields({
sessionKey: requestContext.sessionKey,
messageProvider: requestContext.messageProvider,
currentChannelId: requestContext.currentChannelId,
}),
},
signal: requestAbort.signal,
onToolCallPrepared: cliCaptureHandle

View File

@@ -684,6 +684,7 @@ describe("POST /tools/invoke", () => {
port: sharedPort,
headers: {
...gatewayAuthHeaders(),
"x-openclaw-message-channel": "telegram",
"x-openclaw-message-to": "channel:24514",
"x-openclaw-thread-id": "thread-24514",
},
@@ -696,6 +697,14 @@ describe("POST /tools/invoke", () => {
agentTo: "channel:24514",
agentThreadId: "thread-24514",
});
expect(firstHookCallArg().ctx).toMatchObject({
messageProvider: "telegram",
channel: "telegram",
channelId: "24514",
});
expect(firstHookCallArg().ctx?.senderId).toBeUndefined();
expect(firstHookCallArg().ctx?.chatId).toBeUndefined();
expect(firstHookCallArg().ctx?.channelContext).toBeUndefined();
});
it("propagates owner-only HTTP denies into spawned session inheritance", async () => {

View File

@@ -13,6 +13,7 @@ import { resolveMainSessionKey } from "../config/sessions.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { logWarn } from "../logger.js";
import { isTestDefaultMemorySlotDisabled } from "../plugins/config-state.js";
import { buildAgentHookContextOriginFields } from "../plugins/hook-agent-context.js";
import { defaultSlotIdForKey } from "../plugins/slots.js";
import { getPluginToolMeta } from "../plugins/tools.js";
import { canonicalizeSessionKeyForAgent } from "./session-store-key.js";
@@ -257,6 +258,12 @@ export async function invokeGatewayTool(params: {
agentId,
config: params.cfg,
sessionKey,
...buildAgentHookContextOriginFields({
sessionKey,
messageChannel: params.messageChannel,
messageProvider: params.messageChannel,
messageTo: params.agentTo,
}),
loopDetection: resolveToolLoopDetectionConfig({ cfg: params.cfg, agentId }),
},
approvalMode: params.approvalMode,

View File

@@ -28,7 +28,6 @@ import { truncateUtf16Safe } from "../utils.js";
export const TOOL_PROGRESS_OUTPUT_MAX_CHARS = 8_000;
export { FAST_MODE_AUTO_PROGRESS_KIND } from "../auto-reply/reply-payload.js";
export { isDeliveredMessageToolOnlySourceReplyResult } from "../agents/embedded-agent-message-tool-source-reply.js";
export { formatFastModeAutoProgressText, resolveFastModeForElapsed } from "../shared/fast-mode.js";
export type { AgentMessage } from "../agents/runtime/index.js";
export type { FastModeAutoProgressState } from "../shared/fast-mode.js";
@@ -109,11 +108,16 @@ export type {
NativeHookRelayProvider,
NativeHookRelayRegistrationHandle,
} from "../agents/harness/native-hook-relay.js";
export type { ToolHookRunContext } from "../agents/agent-tools.before-tool-call.js";
export { VERSION as OPENCLAW_VERSION } from "../version.js";
export { formatErrorMessage } from "../infra/errors.js";
export { formatApprovalDisplayPath } from "../infra/approval-display-paths.js";
export { buildAgentHookContextChannelFields } from "../plugins/hook-agent-context.js";
export {
buildAgentHookContextChannelFields,
buildAgentHookContextOriginFields,
} from "../plugins/hook-agent-context.js";
export { resolveToolLoopDetectionConfig } from "../agents/tool-loop-detection-config.js";
export { emitAgentEvent, onAgentEvent, resetAgentEventsForTest } from "../infra/agent-events.js";
export { runAgentCleanupStep } from "../agents/run-cleanup-timeout.js";
export { log as embeddedAgentLog } from "../agents/embedded-agent-runner/logger.js";

View File

@@ -4,7 +4,6 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as jsonFiles from "../infra/json-files.js";
import {
cleanupSessionLifecycleArtifacts,
getSessionEntry,
listSessionEntries,
patchSessionEntry,
@@ -252,47 +251,4 @@ describe("session-store-runtime compatibility surface", () => {
writeSpy.mockRestore();
}
});
it("cleans lifecycle artifacts through the accessor-backed SDK wrapper", async () => {
const sessionKey = "agent:main:lifecycle-owned-old";
const transcriptPath = path.join(tempDir, "lifecycle-owned-old.jsonl");
await saveSessionStore(
storePath,
{
[sessionKey]: {
sessionFile: transcriptPath,
sessionId: "lifecycle-owned-old",
updatedAt: 10,
},
"agent:main:regular": {
sessionId: "regular",
updatedAt: 20,
},
},
{ skipMaintenance: true },
);
fs.writeFileSync(transcriptPath, '{"runId":"lifecycle-owned-old"}\n', "utf-8");
const oldDate = new Date(Date.now() - 600_000);
fs.utimesSync(transcriptPath, oldDate, oldDate);
await expect(
cleanupSessionLifecycleArtifacts({
storePath,
sessionKeySegmentPrefix: "lifecycle-owned-",
transcriptContentMarker: '"runId":"lifecycle-owned-',
orphanTranscriptMinAgeMs: 300_000,
}),
).resolves.toEqual({
archivedTranscriptArtifacts: 1,
removedEntries: 1,
});
expect(getSessionEntry({ sessionKey, storePath })).toBeUndefined();
expect(getSessionEntry({ sessionKey: "agent:main:regular", storePath })).toMatchObject({
sessionId: "regular",
});
expect(
fs.readdirSync(tempDir).filter((file) => file.startsWith("lifecycle-owned-old.jsonl.deleted.")),
).toHaveLength(1);
});
});

View File

@@ -1,7 +1,6 @@
// Narrow session-store helpers for channel hot paths.
import {
cleanupSessionLifecycleArtifacts as cleanupAccessorSessionLifecycleArtifacts,
listSessionEntries as listAccessorSessionEntries,
loadSessionEntry,
patchSessionEntry as patchAccessorSessionEntry,
@@ -10,7 +9,6 @@ import {
type SessionAccessScope,
updateSessionEntry,
} from "../config/sessions/session-accessor.js";
import { resolveStorePath as resolveSessionStorePath } from "../config/sessions/paths.js";
import { loadSessionStore as loadSessionStoreImpl } from "../config/sessions/store-load.js";
import type { ResolvedSessionMaintenanceConfig } from "../config/sessions/store.js";
import type { SessionEntry } from "../config/sessions/types.js";
@@ -62,23 +60,6 @@ type UpsertSessionEntryParams = SessionStoreReadParams & {
entry: SessionEntry;
};
type SessionLifecycleArtifactsCleanupParams = {
agentId?: string;
archiveRemovedEntryTranscripts?: boolean;
env?: NodeJS.ProcessEnv;
orphanTranscriptMinAgeMs: number;
sessionStore?: string;
sessionKeySegmentPrefix: string;
storePath?: string;
transcriptContentMarker: string;
nowMs?: number;
};
type SessionLifecycleArtifactsCleanupResult = {
archivedTranscriptArtifacts: number;
removedEntries: number;
};
function toSessionAccessScope(params: SessionStoreReadParams): SessionAccessScope {
// Maintainer note: keep this adapter narrow so plugin callers retain the
// object-parameter API while internal accessor-only options stay private.
@@ -160,26 +141,6 @@ export async function upsertSessionEntry(params: UpsertSessionEntryParams): Prom
await replaceSessionEntry(toSessionAccessScope(params), params.entry);
}
/** Cleans stale lifecycle-owned session entries and orphan transcripts for one agent store. */
export async function cleanupSessionLifecycleArtifacts(
params: SessionLifecycleArtifactsCleanupParams,
): Promise<SessionLifecycleArtifactsCleanupResult> {
const storePath =
params.storePath ??
resolveSessionStorePath(params.sessionStore, {
agentId: params.agentId,
env: params.env,
});
return await cleanupAccessorSessionLifecycleArtifacts({
storePath,
archiveRemovedEntryTranscripts: params.archiveRemovedEntryTranscripts,
sessionKeySegmentPrefix: params.sessionKeySegmentPrefix,
transcriptContentMarker: params.transcriptContentMarker,
orphanTranscriptMinAgeMs: params.orphanTranscriptMinAgeMs,
nowMs: params.nowMs,
});
}
export { resolveSessionStoreEntry } from "../config/sessions/store-entry.js";
export { resolveSessionTranscriptPathInDir, resolveStorePath } from "../config/sessions/paths.js";
/**

View File

@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
buildAgentHookContextChannelFields,
buildAgentHookContextIdentityFields,
buildAgentHookContextOriginFields,
resolveAgentHookChannelId,
} from "./hook-agent-context.js";
@@ -135,3 +136,63 @@ describe("buildAgentHookContextIdentityFields", () => {
).toEqual({});
});
});
describe("buildAgentHookContextOriginFields", () => {
it("infers a canonical channel while preserving native chat identity", () => {
expect(
buildAgentHookContextOriginFields({
sessionKey: "agent:main:main",
messageProvider: "discord-voice",
currentChannelId: "discord:1472750640760623226",
trigger: "user",
channelContext: {
sender: { id: "user-123" },
chat: { id: "native-chat-1" },
},
}),
).toEqual({
channel: "discord",
messageProvider: "discord-voice",
channelId: "1472750640760623226",
chatId: "native-chat-1",
senderId: "user-123",
channelContext: {
sender: { id: "user-123" },
chat: { id: "native-chat-1" },
},
});
});
it("does not infer native chat identity from a routing target", () => {
expect(
buildAgentHookContextOriginFields({
messageChannel: "telegram",
messageProvider: "telegram",
currentChannelId: "telegram:-100123:topic:7",
trigger: "user",
}),
).toEqual({
channel: "telegram",
messageProvider: "telegram",
channelId: "-100123:topic:7",
});
});
it("keeps routing fields but omits requester identity for system triggers", () => {
expect(
buildAgentHookContextOriginFields({
messageChannel: "telegram",
messageProvider: "telegram",
currentChannelId: "telegram:-100123",
trigger: "cron",
senderId: "stale-user",
chatId: "stale-chat",
channelContext: { sender: { id: "stale-user" }, chat: { id: "stale-chat" } },
}),
).toEqual({
channel: "telegram",
messageProvider: "telegram",
channelId: "-100123",
});
});
});

View File

@@ -38,18 +38,48 @@ function stripConversationPrefix(
return text;
}
function inferNarrowProviderChannel(params: {
messageProvider?: string | null;
currentChannelId?: string | null;
messageTo?: string | null;
}): string | undefined {
const providerKey = normalizeKey(normalizeOptionalString(params.messageProvider));
if (!providerKey) {
return undefined;
}
for (const value of [params.currentChannelId, params.messageTo]) {
const text = normalizeOptionalString(value);
const separatorIndex = text?.indexOf(":") ?? -1;
if (!text || separatorIndex <= 0) {
continue;
}
const prefix = normalizeOptionalString(text.slice(0, separatorIndex));
const prefixKey = normalizeKey(prefix);
if (prefix && providerKey.startsWith(`${prefixKey}-`)) {
return prefix;
}
}
return undefined;
}
function resolveAgentHookChannel(params: {
messageChannel?: string | null;
messageProvider?: string | null;
currentChannelId?: string | null;
messageTo?: string | null;
}): string | undefined {
const messageChannel = normalizeOptionalString(params.messageChannel);
const provider = normalizeOptionalString(params.messageProvider);
const inferredProviderChannel = inferNarrowProviderChannel(params);
if (!messageChannel) {
return provider;
return inferredProviderChannel ?? provider;
}
const separatorIndex = messageChannel.indexOf(":");
if (separatorIndex === -1) {
if (inferredProviderChannel && normalizeKey(messageChannel) === normalizeKey(provider)) {
return inferredProviderChannel;
}
return messageChannel;
}
@@ -61,7 +91,7 @@ function resolveAgentHookChannel(params: {
TARGET_PREFIXES.has(normalizeKey(prefix)) ||
normalizeKey(prefix) === normalizeKey(provider)
) {
return provider;
return inferredProviderChannel ?? provider;
}
return prefix;
}
@@ -76,14 +106,19 @@ export function resolveAgentHookChannelId(params: {
}): string | undefined {
const provider = normalizeOptionalString(params.messageProvider);
const messageChannel = normalizeOptionalString(params.messageChannel);
const channel = resolveAgentHookChannel(params);
const parsed = parseRawSessionConversationRef(params.sessionKey);
if (parsed?.rawId) {
return parsed.rawId;
}
const metadataChannel =
stripConversationPrefix(params.currentChannelId ?? undefined, provider, messageChannel) ??
stripConversationPrefix(params.messageTo ?? undefined, provider, messageChannel);
stripConversationPrefix(
params.currentChannelId ?? undefined,
provider,
messageChannel,
channel,
) ?? stripConversationPrefix(params.messageTo ?? undefined, provider, messageChannel, channel);
if (metadataChannel && normalizeKey(metadataChannel) !== normalizeKey(provider)) {
return metadataChannel;
}
@@ -156,3 +191,38 @@ export function buildAgentHookContextIdentityFields(params: {
...(channelContext ? { channelContext } : {}),
};
}
/** Builds canonical channel and requester fields shared by agent and tool hooks. */
export function buildAgentHookContextOriginFields(params: {
sessionKey?: string | null;
messageChannel?: string | null;
messageProvider?: string | null;
currentChannelId?: string | null;
messageTo?: string | null;
trigger?: string | null;
senderId?: string | null;
chatId?: string | null;
channelContext?: PluginHookChannelContext;
}): Pick<
PluginHookAgentContext,
"channel" | "messageProvider" | "channelId" | "chatId" | "senderId" | "channelContext"
> {
const channelFields = buildAgentHookContextChannelFields({
sessionKey: params.sessionKey,
messageChannel: params.messageChannel,
messageProvider: params.messageProvider,
currentChannelId: params.currentChannelId,
messageTo: params.messageTo,
});
return {
...(channelFields.channel ? { channel: channelFields.channel } : {}),
...(channelFields.messageProvider ? { messageProvider: channelFields.messageProvider } : {}),
...(channelFields.channelId ? { channelId: channelFields.channelId } : {}),
...buildAgentHookContextIdentityFields({
trigger: params.trigger,
senderId: params.senderId ?? params.channelContext?.sender?.id,
chatId: params.chatId ?? params.channelContext?.chat?.id,
channelContext: params.channelContext,
}),
};
}

View File

@@ -609,12 +609,22 @@ export type PluginHookReplyPayloadSendingResult = {
export type PluginHookToolKind = "code_mode_exec";
export type PluginHookToolInputKind = "javascript" | "typescript";
export type PluginHookToolContext = {
agentId?: string;
sessionKey?: string;
sessionId?: string;
runId?: string;
trace?: DiagnosticTraceContext;
export type PluginHookToolContext = Pick<
PluginHookAgentContext,
| "agentId"
| "sessionKey"
| "sessionId"
| "runId"
| "jobId"
| "trace"
| "trigger"
| "messageProvider"
| "channel"
| "chatId"
| "senderId"
| "channelId"
| "channelContext"
> & {
toolName: string;
/** Host-authoritative discriminator for tools that intentionally share names. */
toolKind?: PluginHookToolKind;
@@ -622,7 +632,6 @@ export type PluginHookToolContext = {
toolInputKind?: PluginHookToolInputKind;
toolCallId?: string;
getSessionExtension?: (namespace: string) => PluginJsonValue | undefined;
channelId?: string;
};
export type PluginHookBeforeToolCallEvent = {

View File

@@ -29,6 +29,19 @@ function createToolHandlerCtx(params: {
sessionKey?: string;
sessionId?: string;
agentId?: string;
toolHookContext?: {
jobId?: string;
trigger?: string;
messageProvider?: string;
channel?: string;
chatId?: string;
senderId?: string;
channelId?: string;
channelContext?: {
sender?: { id?: string; displayName?: string };
chat?: { id?: string };
};
};
onBlockReplyFlush?: unknown;
}) {
return {
@@ -38,6 +51,7 @@ function createToolHandlerCtx(params: {
agentId: params.agentId,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
toolHookContext: params.toolHookContext,
onBlockReplyFlush: params.onBlockReplyFlush,
},
hookRunner: hookMocks.runner,
@@ -74,7 +88,15 @@ function getAfterToolCallCall(index = 0) {
sessionKey?: string;
sessionId?: string;
runId?: string;
jobId?: string;
trigger?: string;
messageProvider?: string;
channel?: string;
chatId?: string;
senderId?: string;
toolCallId?: string;
channelId?: string;
channelContext?: unknown;
}
| undefined,
};
@@ -126,6 +148,19 @@ describe("after_tool_call hook wiring", () => {
agentId: "main",
sessionKey: "test-session",
sessionId: "test-ephemeral-session",
toolHookContext: {
jobId: "job-1",
trigger: "user",
messageProvider: "slack-voice",
channel: "slack",
chatId: "C123",
senderId: "U123",
channelId: "C123",
channelContext: {
sender: { id: "U123", displayName: "Ada" },
chat: { id: "C123" },
},
},
});
await handleToolExecutionStart(
@@ -166,6 +201,17 @@ describe("after_tool_call hook wiring", () => {
sessionKey: "test-session",
sessionId: "test-ephemeral-session",
runId: "test-run-1",
jobId: "job-1",
trigger: "user",
messageProvider: "slack-voice",
channel: "slack",
chatId: "C123",
senderId: "U123",
channelId: "C123",
channelContext: {
sender: { id: "U123", displayName: "Ada" },
chat: { id: "C123" },
},
toolCallId: "wired-hook-call-1",
},
});

View File

@@ -24,14 +24,11 @@ describe("session accessor boundary guard", () => {
expect(migratedSessionAccessorFiles).toEqual(
new Set([
"packages/memory-host-sdk/src/host/session-files.ts",
"src/acp/runtime/session-meta.ts",
"src/agents/acp-spawn.ts",
"src/agents/embedded-agent-runner/compaction-successor-transcript.ts",
"src/agents/embedded-agent-runner/run/attempt.ts",
"src/agents/embedded-agent-runner/tool-result-truncation.ts",
"src/agents/embedded-agent-runner/transcript-rewrite.ts",
"src/agents/embedded-agent-runner/transcript-runtime-state.ts",
"src/auto-reply/reply/abort.ts",
"src/auto-reply/reply/agent-runner-helpers.ts",
"src/auto-reply/reply/agent-runner.ts",
"src/auto-reply/reply/commands-subagents/action-info.ts",
@@ -75,7 +72,6 @@ describe("session accessor boundary guard", () => {
new Set([
"extensions/discord/src/monitor/native-command-model-picker-apply.ts",
"extensions/discord/src/monitor/thread-session-close.ts",
"extensions/memory-core/src/dreaming-narrative.ts",
"extensions/telegram/src/bot-handlers.runtime.ts",
]),
);
@@ -84,13 +80,11 @@ describe("session accessor boundary guard", () => {
it("ratchets only files migrated to session accessor writes", () => {
expect(migratedSessionAccessorWriteFiles).toEqual(
new Set([
"src/acp/runtime/session-meta.ts",
"src/agents/command/attempt-execution.shared.ts",
"src/agents/command/session-store.ts",
"src/agents/embedded-agent-runner/run.ts",
"src/agents/embedded-agent-runner/run/attempt.ts",
"src/agents/main-session-restart-recovery.ts",
"src/auto-reply/reply/abort.ts",
"src/auto-reply/reply/abort-cutoff.runtime.ts",
"src/auto-reply/reply/agent-runner-cli-dispatch.ts",
"src/auto-reply/reply/agent-runner-execution.ts",