diff --git a/CHANGELOG.md b/CHANGELOG.md index f41dc18261f..9892a1bdcdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/gateway/authentication.md b/docs/gateway/authentication.md index 21d6000d860..0fd1f941032 100644 --- a/docs/gateway/authentication.md +++ b/docs/gateway/authentication.md @@ -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 diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index db90e9dcfa3..dc5d2b7c1bd 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -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 diff --git a/src/agents/cli-runner.spawn.test.ts b/src/agents/cli-runner.spawn.test.ts index 065fa704b00..096639a4e00 100644 --- a/src/agents/cli-runner.spawn.test.ts +++ b/src/agents/cli-runner.spawn.test.ts @@ -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", diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index 7b8acbefd86..c26e3aaced6 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -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, }; } diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index 62a008d7204..aa5aa4b2dd6 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -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( + "../../plugins/hook-runner-global.js", + ); + return { + ...actual, + getGlobalHookRunner: vi.fn(() => null), + }; +}); + +vi.mock("../video-generation-task-status.js", async () => { + const actual = await vi.importActual( + "../video-generation-task-status.js", + ); + return { + ...actual, + buildActiveVideoGenerationTaskPromptContextForSession: vi.fn(() => undefined), + }; +}); + +vi.mock("../music-generation-task-status.js", async () => { + const actual = await vi.importActual( + "../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 }); + } + }); }); diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 2414b4d09d1..900d27982e4 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -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): 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, diff --git a/src/agents/cli-runner/types.ts b/src/agents/cli-runner/types.ts index aa3bfa13322..a164157539b 100644 --- a/src/agents/cli-runner/types.ts +++ b/src/agents/cli-runner/types.ts @@ -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; diff --git a/src/agents/command/attempt-execution.cli.test.ts b/src/agents/command/attempt-execution.cli.test.ts index 01765c3a27a..a4061e8139d 100644 --- a/src/agents/command/attempt-execution.cli.test.ts +++ b/src/agents/command/attempt-execution.cli.test.ts @@ -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 = { [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[0]["opts"], + runContext: {} as Parameters[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", + }), + ); + }); }); diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index 59779b7cbd1..ef5e0b2ac4c 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -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, diff --git a/src/auto-reply/reply/agent-runner-execution.test.ts b/src/auto-reply/reply/agent-runner-execution.test.ts index 84b8f009fdd..be7a4ebc31a 100644 --- a/src/auto-reply/reply/agent-runner-execution.test.ts +++ b/src/auto-reply/reply/agent-runner-execution.test.ts @@ -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", }), ); }); diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 33984583c50..cf74be9a135 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -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, diff --git a/src/cron/isolated-agent/run-executor.ts b/src/cron/isolated-agent/run-executor.ts index 2831d4d1602..932dbce77a1 100644 --- a/src/cron/isolated-agent/run-executor.ts +++ b/src/cron/isolated-agent/run-executor.ts @@ -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,