mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:00:43 +00:00
fix: align claude-cli prompt hooks (#70625)
Merged via squash.
Prepared head SHA: 3de89da38f
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
@@ -2,6 +2,14 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI/Claude: run the same prompt-build hooks and trigger/channel context on `claude-cli` turns as on direct embedded runs, keeping Claude Code sessions aligned with OpenClaw workspace identity, routing, and hook-driven prompt mutations. (#70625) Thanks @mbelinky.
|
||||
|
||||
## 2026.4.22
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -71,6 +71,24 @@ For long-lived gateway hosts, an Anthropic API key is still the most predictable
|
||||
setup. If you want to reuse an existing Claude login on the same host, use the
|
||||
Anthropic Claude CLI path in onboarding/configure.
|
||||
|
||||
Recommended host setup for Claude CLI reuse:
|
||||
|
||||
```bash
|
||||
# Run on the gateway host
|
||||
claude auth login
|
||||
claude auth status --text
|
||||
openclaw models auth login --provider anthropic --method cli --set-default
|
||||
```
|
||||
|
||||
This is a two-step setup:
|
||||
|
||||
1. Log Claude Code itself into Anthropic on the gateway host.
|
||||
2. Tell OpenClaw to switch Anthropic model selection to the local `claude-cli`
|
||||
backend and store the matching OpenClaw auth profile.
|
||||
|
||||
If `claude` is not on `PATH`, either install Claude Code first or set
|
||||
`agents.defaults.cliBackends.claude-cli.command` to the real binary path.
|
||||
|
||||
Manual token entry (any provider; writes `auth-profiles.json` + updates config):
|
||||
|
||||
```bash
|
||||
|
||||
@@ -169,6 +169,18 @@ resolver sees the same filtered set that OpenClaw would otherwise advertise in
|
||||
the prompt. Skill env/API key overrides are still applied by OpenClaw to the
|
||||
child process environment for the run.
|
||||
|
||||
Before OpenClaw can use the bundled `claude-cli` backend, Claude Code itself
|
||||
must already be logged in on the same host:
|
||||
|
||||
```bash
|
||||
claude auth login
|
||||
claude auth status --text
|
||||
openclaw models auth login --provider anthropic --method cli --set-default
|
||||
```
|
||||
|
||||
Use `agents.defaults.cliBackends.claude-cli.command` only when the `claude`
|
||||
binary is not already on `PATH`.
|
||||
|
||||
## Sessions
|
||||
|
||||
- If the CLI supports sessions, set `sessionArg` (e.g. `--session-id`) or
|
||||
|
||||
@@ -431,6 +431,22 @@ describe("runCliAgent spawn path", () => {
|
||||
expect(params.senderIsOwner).toBe(false);
|
||||
});
|
||||
|
||||
it("forwards channel context through the compat wrapper", () => {
|
||||
const params = buildRunClaudeCliAgentParams({
|
||||
sessionId: "openclaw-session",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
prompt: "hi",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-claude-channel-wrapper",
|
||||
messageChannel: "telegram",
|
||||
messageProvider: "acp",
|
||||
});
|
||||
|
||||
expect(params.messageChannel).toBe("telegram");
|
||||
expect(params.messageProvider).toBe("acp");
|
||||
});
|
||||
|
||||
it("forwards static extra system prompt through the compat wrapper", () => {
|
||||
const params = buildRunClaudeCliAgentParams({
|
||||
sessionId: "openclaw-session",
|
||||
|
||||
@@ -140,6 +140,7 @@ export function buildRunClaudeCliAgentParams(params: RunClaudeCliAgentParams): R
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: params.agentId,
|
||||
trigger: params.trigger,
|
||||
sessionFile: params.sessionFile,
|
||||
workspaceDir: params.workspaceDir,
|
||||
config: params.config,
|
||||
@@ -156,6 +157,8 @@ export function buildRunClaudeCliAgentParams(params: RunClaudeCliAgentParams): R
|
||||
// Ignore it here so the compatibility wrapper does not accidentally resume
|
||||
// an incompatible Claude session on the generic runner path.
|
||||
images: params.images,
|
||||
messageChannel: params.messageChannel,
|
||||
messageProvider: params.messageProvider,
|
||||
senderIsOwner: params.senderIsOwner,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,114 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldSkipLocalCliCredentialEpoch } from "./prepare.js";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||
import { buildActiveMusicGenerationTaskPromptContextForSession } from "../music-generation-task-status.js";
|
||||
import { buildActiveVideoGenerationTaskPromptContextForSession } from "../video-generation-task-status.js";
|
||||
import {
|
||||
prepareCliRunContext,
|
||||
setCliRunnerPrepareTestDeps,
|
||||
shouldSkipLocalCliCredentialEpoch,
|
||||
} from "./prepare.js";
|
||||
|
||||
vi.mock("../../plugins/hook-runner-global.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../plugins/hook-runner-global.js")>(
|
||||
"../../plugins/hook-runner-global.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getGlobalHookRunner: vi.fn(() => null),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../video-generation-task-status.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../video-generation-task-status.js")>(
|
||||
"../video-generation-task-status.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
buildActiveVideoGenerationTaskPromptContextForSession: vi.fn(() => undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../music-generation-task-status.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../music-generation-task-status.js")>(
|
||||
"../music-generation-task-status.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
buildActiveMusicGenerationTaskPromptContextForSession: vi.fn(() => undefined),
|
||||
};
|
||||
});
|
||||
|
||||
const mockGetGlobalHookRunner = vi.mocked(getGlobalHookRunner);
|
||||
const mockBuildActiveVideoGenerationTaskPromptContextForSession = vi.mocked(
|
||||
buildActiveVideoGenerationTaskPromptContextForSession,
|
||||
);
|
||||
const mockBuildActiveMusicGenerationTaskPromptContextForSession = vi.mocked(
|
||||
buildActiveMusicGenerationTaskPromptContextForSession,
|
||||
);
|
||||
|
||||
function createCliBackendConfig(): OpenClawConfig {
|
||||
return {
|
||||
agents: {
|
||||
defaults: {
|
||||
cliBackends: {
|
||||
"test-cli": {
|
||||
command: "test-cli",
|
||||
args: ["--print"],
|
||||
systemPromptArg: "--system-prompt",
|
||||
systemPromptWhen: "first",
|
||||
sessionMode: "existing",
|
||||
output: "text",
|
||||
input: "arg",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
}
|
||||
|
||||
function createSessionFile() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-prepare-"));
|
||||
const sessionFile = path.join(dir, "session.jsonl");
|
||||
fs.writeFileSync(
|
||||
sessionFile,
|
||||
`${JSON.stringify({
|
||||
type: "session",
|
||||
version: CURRENT_SESSION_VERSION,
|
||||
id: "session-test",
|
||||
timestamp: new Date(0).toISOString(),
|
||||
cwd: dir,
|
||||
})}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
return { dir, sessionFile };
|
||||
}
|
||||
|
||||
describe("shouldSkipLocalCliCredentialEpoch", () => {
|
||||
beforeEach(() => {
|
||||
setCliRunnerPrepareTestDeps({
|
||||
makeBootstrapWarn: vi.fn(() => () => undefined),
|
||||
resolveBootstrapContextForRun: vi.fn(async () => ({
|
||||
bootstrapFiles: [],
|
||||
contextFiles: [],
|
||||
})),
|
||||
resolveOpenClawDocsPath: vi.fn(async () => null),
|
||||
});
|
||||
mockGetGlobalHookRunner.mockReturnValue(null);
|
||||
mockBuildActiveVideoGenerationTaskPromptContextForSession.mockReturnValue(undefined);
|
||||
mockBuildActiveMusicGenerationTaskPromptContextForSession.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockGetGlobalHookRunner.mockReset();
|
||||
mockBuildActiveVideoGenerationTaskPromptContextForSession.mockReset();
|
||||
mockBuildActiveMusicGenerationTaskPromptContextForSession.mockReset();
|
||||
});
|
||||
|
||||
it("skips local cli auth only when a profile-owned execution was prepared", () => {
|
||||
expect(
|
||||
shouldSkipLocalCliCredentialEpoch({
|
||||
@@ -33,4 +140,217 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("applies prompt-build hook context to Claude-style CLI preparation", async () => {
|
||||
const { dir, sessionFile } = createSessionFile();
|
||||
try {
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
sessionManager.appendMessage({ role: "user", content: "earlier context", timestamp: 1 });
|
||||
sessionManager.appendMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "earlier reply" }],
|
||||
api: "responses",
|
||||
provider: "test-cli",
|
||||
model: "test-model",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: 2,
|
||||
});
|
||||
const hookRunner = {
|
||||
hasHooks: vi.fn((hookName: string) => hookName === "before_prompt_build"),
|
||||
runBeforePromptBuild: vi.fn(async ({ messages }: { messages: unknown[] }) => ({
|
||||
prependContext: `history:${messages.length}`,
|
||||
systemPrompt: "hook system",
|
||||
prependSystemContext: "prepend system",
|
||||
appendSystemContext: "append system",
|
||||
})),
|
||||
runBeforeAgentStart: vi.fn(),
|
||||
};
|
||||
mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
|
||||
|
||||
const context = await prepareCliRunContext({
|
||||
sessionId: "session-test",
|
||||
sessionKey: "agent:main:test",
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionFile,
|
||||
workspaceDir: dir,
|
||||
prompt: "latest ask",
|
||||
provider: "test-cli",
|
||||
model: "test-model",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-test",
|
||||
messageChannel: "telegram",
|
||||
messageProvider: "acp",
|
||||
config: {
|
||||
...createCliBackendConfig(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(context.params.prompt).toBe("history:2\n\nlatest ask");
|
||||
expect(context.systemPrompt).toBe("prepend system\n\nhook system\n\nappend system");
|
||||
expect(hookRunner.runBeforePromptBuild).toHaveBeenCalledWith(
|
||||
{
|
||||
prompt: "latest ask",
|
||||
messages: [
|
||||
{ role: "user", content: "earlier context", timestamp: 1 },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "earlier reply" }],
|
||||
api: "responses",
|
||||
provider: "test-cli",
|
||||
model: "test-model",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
expect.objectContaining({
|
||||
runId: "run-test",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:test",
|
||||
sessionId: "session-test",
|
||||
workspaceDir: dir,
|
||||
modelProviderId: "test-cli",
|
||||
modelId: "test-model",
|
||||
messageProvider: "acp",
|
||||
trigger: "user",
|
||||
channelId: "telegram",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("merges before_prompt_build and legacy before_agent_start hook context for CLI preparation", async () => {
|
||||
const { dir, sessionFile } = createSessionFile();
|
||||
try {
|
||||
const hookRunner = {
|
||||
hasHooks: vi.fn((_hookName: string) => true),
|
||||
runBeforePromptBuild: vi.fn(async () => ({
|
||||
prependContext: "prompt prepend",
|
||||
systemPrompt: "prompt system",
|
||||
prependSystemContext: "prompt prepend system",
|
||||
appendSystemContext: "prompt append system",
|
||||
})),
|
||||
runBeforeAgentStart: vi.fn(async () => ({
|
||||
prependContext: "legacy prepend",
|
||||
systemPrompt: "legacy system",
|
||||
prependSystemContext: "legacy prepend system",
|
||||
appendSystemContext: "legacy append system",
|
||||
})),
|
||||
};
|
||||
mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
|
||||
|
||||
const context = await prepareCliRunContext({
|
||||
sessionId: "session-test",
|
||||
sessionFile,
|
||||
workspaceDir: dir,
|
||||
prompt: "latest ask",
|
||||
provider: "test-cli",
|
||||
model: "test-model",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-test-legacy-merge",
|
||||
config: createCliBackendConfig(),
|
||||
});
|
||||
|
||||
expect(context.params.prompt).toBe("prompt prepend\n\nlegacy prepend\n\nlatest ask");
|
||||
expect(context.systemPrompt).toBe(
|
||||
"prompt prepend system\n\nlegacy prepend system\n\nprompt system\n\nprompt append system\n\nlegacy append system",
|
||||
);
|
||||
expect(hookRunner.runBeforePromptBuild).toHaveBeenCalledOnce();
|
||||
expect(hookRunner.runBeforeAgentStart).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves the base prompt when prompt-build hooks fail", async () => {
|
||||
const { dir, sessionFile } = createSessionFile();
|
||||
try {
|
||||
const hookRunner = {
|
||||
hasHooks: vi.fn((hookName: string) => hookName === "before_prompt_build"),
|
||||
runBeforePromptBuild: vi.fn(async () => {
|
||||
throw new Error("hook exploded");
|
||||
}),
|
||||
runBeforeAgentStart: vi.fn(),
|
||||
};
|
||||
mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
|
||||
|
||||
const context = await prepareCliRunContext({
|
||||
sessionId: "session-test",
|
||||
sessionFile,
|
||||
workspaceDir: dir,
|
||||
prompt: "latest ask",
|
||||
provider: "test-cli",
|
||||
model: "test-model",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-test-hook-failure",
|
||||
extraSystemPrompt: "base extra system",
|
||||
config: createCliBackendConfig(),
|
||||
});
|
||||
|
||||
expect(context.params.prompt).toBe("latest ask");
|
||||
expect(context.systemPrompt).toContain("base extra system");
|
||||
expect(context.systemPrompt).not.toContain("hook exploded");
|
||||
expect(hookRunner.runBeforePromptBuild).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("applies direct-run prepend system context helpers on the CLI path", async () => {
|
||||
const { dir, sessionFile } = createSessionFile();
|
||||
try {
|
||||
mockBuildActiveVideoGenerationTaskPromptContextForSession.mockReturnValue(
|
||||
"active video task",
|
||||
);
|
||||
const hookRunner = {
|
||||
hasHooks: vi.fn((hookName: string) => hookName === "before_prompt_build"),
|
||||
runBeforePromptBuild: vi.fn(async () => ({
|
||||
systemPrompt: "hook system",
|
||||
prependSystemContext: "hook prepend system",
|
||||
})),
|
||||
runBeforeAgentStart: vi.fn(),
|
||||
};
|
||||
mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
|
||||
|
||||
const context = await prepareCliRunContext({
|
||||
sessionId: "session-test",
|
||||
sessionKey: "agent:main:test",
|
||||
trigger: "user",
|
||||
sessionFile,
|
||||
workspaceDir: dir,
|
||||
prompt: "latest ask",
|
||||
provider: "test-cli",
|
||||
model: "test-model",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-test-prepend-helper",
|
||||
config: createCliBackendConfig(),
|
||||
});
|
||||
|
||||
expect(context.systemPrompt).toBe("active video task\n\nhook prepend system\n\nhook system");
|
||||
expect(mockBuildActiveVideoGenerationTaskPromptContextForSession).toHaveBeenCalledWith(
|
||||
"agent:main:test",
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
import { ensureMcpLoopbackServer } from "../../gateway/mcp-http.js";
|
||||
import {
|
||||
createMcpLoopbackServerConfig,
|
||||
@@ -7,6 +8,7 @@ import type {
|
||||
CliBackendAuthEpochMode,
|
||||
CliBackendPreparedExecution,
|
||||
} from "../../plugins/cli-backend.types.js";
|
||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||
import { resolveOpenClawAgentDir } from "../agent-paths.js";
|
||||
import { resolveSessionAgentIds } from "../agent-scope.js";
|
||||
import { loadAuthProfileStoreForRuntime } from "../auth-profiles/store.js";
|
||||
@@ -30,6 +32,9 @@ import {
|
||||
resolveBootstrapPromptTruncationWarningMode,
|
||||
resolveBootstrapTotalMaxChars,
|
||||
} from "../pi-embedded-helpers.js";
|
||||
import { resolvePromptBuildHookResult } from "../pi-embedded-runner/run/attempt.prompt-helpers.js";
|
||||
import { resolveAttemptPrependSystemContext } from "../pi-embedded-runner/run/attempt.prompt-helpers.js";
|
||||
import { composeSystemPromptWithHookContext } from "../pi-embedded-runner/run/attempt.thread-helpers.js";
|
||||
import { applyPluginTextReplacements } from "../plugin-text-transforms.js";
|
||||
import { resolveSkillsPromptForRun } from "../skills.js";
|
||||
import { resolveSystemPromptOverride } from "../system-prompt-override.js";
|
||||
@@ -51,6 +56,11 @@ const prepareDeps = {
|
||||
) => (await import("../docs-path.js")).resolveOpenClawDocsPath(params),
|
||||
};
|
||||
|
||||
function loadCliPromptBuildMessages(sessionFile: string): unknown[] {
|
||||
const entries = SessionManager.open(sessionFile).getEntries();
|
||||
return entries.flatMap((entry) => (entry.type === "message" ? [entry.message as unknown] : []));
|
||||
}
|
||||
|
||||
export function setCliRunnerPrepareTestDeps(overrides: Partial<typeof prepareDeps>): void {
|
||||
Object.assign(prepareDeps, overrides);
|
||||
}
|
||||
@@ -181,7 +191,7 @@ export async function prepareCliRunContext(
|
||||
OPENCLAW_MCP_AGENT_ID: sessionAgentId ?? "",
|
||||
OPENCLAW_MCP_ACCOUNT_ID: params.agentAccountId ?? "",
|
||||
OPENCLAW_MCP_SESSION_KEY: params.sessionKey ?? "",
|
||||
OPENCLAW_MCP_MESSAGE_CHANNEL: params.messageProvider ?? "",
|
||||
OPENCLAW_MCP_MESSAGE_CHANNEL: params.messageChannel ?? params.messageProvider ?? "",
|
||||
}
|
||||
: undefined,
|
||||
warn: (message) => cliBackendLog.warn(message),
|
||||
@@ -298,10 +308,50 @@ export async function prepareCliRunContext(
|
||||
agentId: sessionAgentId,
|
||||
systemPrompt: builtSystemPrompt,
|
||||
}) ?? builtSystemPrompt;
|
||||
const systemPrompt = applyPluginTextReplacements(
|
||||
transformedSystemPrompt,
|
||||
backendResolved.textTransforms?.input,
|
||||
);
|
||||
let systemPrompt = transformedSystemPrompt;
|
||||
let preparedPrompt = params.prompt;
|
||||
const hookRunner = getGlobalHookRunner();
|
||||
if (hookRunner?.hasHooks("before_prompt_build") || hookRunner?.hasHooks("before_agent_start")) {
|
||||
try {
|
||||
const hookResult = await resolvePromptBuildHookResult({
|
||||
prompt: params.prompt,
|
||||
messages: loadCliPromptBuildMessages(params.sessionFile),
|
||||
hookCtx: {
|
||||
runId: params.runId,
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
workspaceDir,
|
||||
modelProviderId: params.provider,
|
||||
modelId,
|
||||
messageProvider: params.messageProvider,
|
||||
trigger: params.trigger,
|
||||
channelId: params.messageChannel ?? params.messageProvider,
|
||||
},
|
||||
hookRunner,
|
||||
});
|
||||
if (hookResult.prependContext) {
|
||||
preparedPrompt = `${hookResult.prependContext}\n\n${preparedPrompt}`;
|
||||
}
|
||||
const hookSystemPrompt = hookResult.systemPrompt?.trim();
|
||||
if (hookSystemPrompt) {
|
||||
systemPrompt = hookSystemPrompt;
|
||||
}
|
||||
systemPrompt =
|
||||
composeSystemPromptWithHookContext({
|
||||
baseSystemPrompt: systemPrompt,
|
||||
prependSystemContext: resolveAttemptPrependSystemContext({
|
||||
sessionKey: params.sessionKey,
|
||||
trigger: params.trigger,
|
||||
hookPrependSystemContext: hookResult.prependSystemContext,
|
||||
}),
|
||||
appendSystemContext: hookResult.appendSystemContext,
|
||||
}) ?? systemPrompt;
|
||||
} catch (error) {
|
||||
cliBackendLog.warn(`cli prompt-build hook preparation failed: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
systemPrompt = applyPluginTextReplacements(systemPrompt, backendResolved.textTransforms?.input);
|
||||
const systemPromptReport = buildSystemPromptReport({
|
||||
source: "run",
|
||||
generatedAt: Date.now(),
|
||||
@@ -326,7 +376,7 @@ export async function prepareCliRunContext(
|
||||
});
|
||||
|
||||
return {
|
||||
params,
|
||||
params: preparedPrompt === params.prompt ? params : { ...params, prompt: preparedPrompt },
|
||||
effectiveAuthProfileId,
|
||||
started,
|
||||
workspaceDir,
|
||||
|
||||
@@ -7,12 +7,14 @@ import type { CliBackendConfig } from "../../config/types.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import type { PromptImageOrderEntry } from "../../media/prompt-image-order.js";
|
||||
import type { ResolvedCliBackend } from "../cli-backends.js";
|
||||
import type { EmbeddedRunTrigger } from "../pi-embedded-runner/run/params.js";
|
||||
import type { SkillSnapshot } from "../skills.js";
|
||||
|
||||
export type RunCliAgentParams = {
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
trigger?: EmbeddedRunTrigger;
|
||||
sessionFile: string;
|
||||
workspaceDir: string;
|
||||
config?: OpenClawConfig;
|
||||
@@ -35,6 +37,7 @@ export type RunCliAgentParams = {
|
||||
images?: ImageContent[];
|
||||
imageOrder?: PromptImageOrderEntry[];
|
||||
skillsSnapshot?: SkillSnapshot;
|
||||
messageChannel?: string;
|
||||
messageProvider?: string;
|
||||
agentAccountId?: string;
|
||||
senderIsOwner?: boolean;
|
||||
|
||||
@@ -336,4 +336,53 @@ describe("CLI attempt execution", () => {
|
||||
content: [{ type: "text", text: "hello from cli" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards user trigger and channel context to CLI runs", async () => {
|
||||
const sessionKey = "agent:main:direct:claude-channel-context";
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId: "openclaw-session-channel",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
|
||||
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
|
||||
runCliAgentMock.mockResolvedValueOnce(makeCliResult("channel aware"));
|
||||
|
||||
await runAgentAttempt({
|
||||
providerOverride: "claude-cli",
|
||||
modelOverride: "opus",
|
||||
cfg: {} as OpenClawConfig,
|
||||
sessionEntry,
|
||||
sessionId: sessionEntry.sessionId,
|
||||
sessionKey,
|
||||
sessionAgentId: "main",
|
||||
sessionFile: path.join(tmpDir, "session.jsonl"),
|
||||
workspaceDir: tmpDir,
|
||||
body: "route this",
|
||||
isFallbackRetry: false,
|
||||
resolvedThinkLevel: "medium",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-cli-channel-context",
|
||||
opts: { senderIsOwner: false } as Parameters<typeof runAgentAttempt>[0]["opts"],
|
||||
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
|
||||
spawnedBy: undefined,
|
||||
messageChannel: "telegram",
|
||||
skillsSnapshot: undefined,
|
||||
resolvedVerboseLevel: undefined,
|
||||
agentDir: tmpDir,
|
||||
onAgentEvent: vi.fn(),
|
||||
authProfileProvider: "claude-cli",
|
||||
sessionStore,
|
||||
storePath,
|
||||
sessionHasHistory: false,
|
||||
});
|
||||
|
||||
expect(runCliAgentMock).toHaveBeenCalledTimes(1);
|
||||
expect(runCliAgentMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
trigger: "user",
|
||||
messageChannel: "telegram",
|
||||
messageProvider: "telegram",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -301,6 +301,7 @@ export function runAgentAttempt(params: {
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: params.sessionAgentId,
|
||||
trigger: "user",
|
||||
sessionFile: params.sessionFile,
|
||||
workspaceDir: params.workspaceDir,
|
||||
config: params.cfg,
|
||||
@@ -322,6 +323,7 @@ export function runAgentAttempt(params: {
|
||||
images: params.isFallbackRetry ? undefined : params.opts.images,
|
||||
imageOrder: params.isFallbackRetry ? undefined : params.opts.imageOrder,
|
||||
skillsSnapshot: params.skillsSnapshot,
|
||||
messageChannel: params.messageChannel,
|
||||
streamParams: params.opts.streamParams,
|
||||
messageProvider: params.messageChannel,
|
||||
agentAccountId: params.runContext.accountId,
|
||||
|
||||
@@ -274,6 +274,7 @@ describe("runAgentTurnWithFallback", () => {
|
||||
followupRun.run.model = "gpt-5.4";
|
||||
followupRun.run.extraSystemPrompt = "dynamic inbound metadata\n\nstable group prompt";
|
||||
followupRun.run.extraSystemPromptStatic = "stable group prompt";
|
||||
followupRun.originatingChannel = "telegram";
|
||||
|
||||
const result = await runAgentTurnWithFallback({
|
||||
commandBody: "hello",
|
||||
@@ -304,6 +305,60 @@ describe("runAgentTurnWithFallback", () => {
|
||||
expect.objectContaining({
|
||||
extraSystemPrompt: "dynamic inbound metadata\n\nstable group prompt",
|
||||
extraSystemPromptStatic: "stable group prompt",
|
||||
trigger: "user",
|
||||
messageChannel: "telegram",
|
||||
messageProvider: "telegram",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves CLI messageProvider from the live session surface when no origin channel is set", async () => {
|
||||
state.isCliProviderMock.mockReturnValue(true);
|
||||
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
|
||||
result: await params.run("codex-cli", "gpt-5.4"),
|
||||
provider: "codex-cli",
|
||||
model: "gpt-5.4",
|
||||
attempts: [],
|
||||
}));
|
||||
state.runCliAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: "final" }],
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
|
||||
const followupRun = createFollowupRun();
|
||||
followupRun.run.provider = "codex-cli";
|
||||
followupRun.run.model = "gpt-5.4";
|
||||
followupRun.run.messageProvider = "stale-provider";
|
||||
|
||||
await runAgentTurnWithFallback({
|
||||
commandBody: "hello",
|
||||
followupRun,
|
||||
sessionCtx: {
|
||||
Provider: "discord",
|
||||
MessageSid: "msg",
|
||||
} as unknown as TemplateContext,
|
||||
opts: {},
|
||||
typingSignals: createMockTypingSignaler(),
|
||||
blockReplyPipeline: null,
|
||||
blockStreamingEnabled: false,
|
||||
resolvedBlockStreamingBreak: "message_end",
|
||||
applyReplyToMode: (payload) => payload,
|
||||
shouldEmitToolResult: () => true,
|
||||
shouldEmitToolOutput: () => false,
|
||||
pendingToolTasks: new Set(),
|
||||
resetSessionAfterCompactionFailure: async () => false,
|
||||
resetSessionAfterRoleOrderingConflict: async () => false,
|
||||
isHeartbeat: false,
|
||||
sessionKey: "main",
|
||||
getActiveSessionEntry: () => undefined,
|
||||
resolvedVerboseLevel: "off",
|
||||
});
|
||||
|
||||
expect(state.runCliAgentMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messageChannel: undefined,
|
||||
messageProvider: "discord",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -69,6 +69,7 @@ import {
|
||||
resolveModelFallbackOptions,
|
||||
} from "./agent-runner-utils.js";
|
||||
import { type BlockReplyPipeline } from "./block-reply-pipeline.js";
|
||||
import { resolveOriginMessageProvider } from "./origin-routing.js";
|
||||
import type { FollowupRun } from "./queue.js";
|
||||
import { createBlockReplyDeliveryHandler } from "./reply-delivery.js";
|
||||
import type { ReplyMediaContext } from "./reply-media-paths.js";
|
||||
@@ -896,6 +897,10 @@ export async function runAgentTurnWithFallback(params: {
|
||||
provider === params.followupRun.run.provider
|
||||
? params.followupRun.run.authProfileId
|
||||
: undefined;
|
||||
const hookMessageProvider = resolveOriginMessageProvider({
|
||||
originatingChannel: params.followupRun.originatingChannel,
|
||||
provider: params.sessionCtx.Provider,
|
||||
});
|
||||
return (async () => {
|
||||
let lifecycleTerminalEmitted = false;
|
||||
try {
|
||||
@@ -903,6 +908,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
sessionId: params.followupRun.run.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: params.followupRun.run.agentId,
|
||||
trigger: params.isHeartbeat ? "heartbeat" : "user",
|
||||
sessionFile: params.followupRun.run.sessionFile,
|
||||
workspaceDir: params.followupRun.run.workspaceDir,
|
||||
config: runtimeConfig,
|
||||
@@ -926,7 +932,8 @@ export async function runAgentTurnWithFallback(params: {
|
||||
images: params.opts?.images,
|
||||
imageOrder: params.opts?.imageOrder,
|
||||
skillsSnapshot: params.followupRun.run.skillsSnapshot,
|
||||
messageProvider: params.followupRun.run.messageProvider,
|
||||
messageChannel: params.followupRun.originatingChannel ?? undefined,
|
||||
messageProvider: hookMessageProvider,
|
||||
agentAccountId: params.followupRun.run.agentAccountId,
|
||||
senderIsOwner: params.followupRun.run.senderIsOwner,
|
||||
abortSignal: params.replyOperation?.abortSignal ?? params.opts?.abortSignal,
|
||||
|
||||
@@ -128,6 +128,7 @@ export function createCronPromptExecutor(params: {
|
||||
sessionId: params.cronSession.sessionEntry.sessionId,
|
||||
sessionKey: params.agentSessionKey,
|
||||
agentId: params.agentId,
|
||||
trigger: "cron",
|
||||
sessionFile,
|
||||
workspaceDir: params.workspaceDir,
|
||||
config: params.cfgWithAgentDefaults,
|
||||
@@ -139,6 +140,7 @@ export function createCronPromptExecutor(params: {
|
||||
runId: params.cronSession.sessionEntry.sessionId,
|
||||
cliSessionId,
|
||||
skillsSnapshot: params.skillsSnapshot,
|
||||
messageChannel: params.messageChannel,
|
||||
bootstrapPromptWarningSignaturesSeen,
|
||||
bootstrapPromptWarningSignature,
|
||||
senderIsOwner: true,
|
||||
|
||||
Reference in New Issue
Block a user