mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-24 16:23:54 +08:00
Compare commits
1 Commits
codex/mess
...
codex/tool
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fabf62d0f7 |
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
41
extensions/codex/src/app-server/tool-hook-context.ts
Normal file
41
extensions/codex/src/app-server/tool-hook-context.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}`);
|
||||
});
|
||||
|
||||
@@ -272,6 +272,7 @@ export type EmbeddedAgentSubscribeContext = {
|
||||
type ToolHandlerParams = Pick<
|
||||
SubscribeEmbeddedAgentSessionParams,
|
||||
| "runId"
|
||||
| "toolHookContext"
|
||||
| "onBlockReplyFlush"
|
||||
| "onAgentEvent"
|
||||
| "onToolStreamBoundary"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user