diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index acd34fc497c..375123e11df 100644 --- a/.github/workflows/openclaw-release-checks.yml +++ b/.github/workflows/openclaw-release-checks.yml @@ -462,7 +462,7 @@ jobs: published_upgrade_survivor_baselines: all-since-2026.4.23 published_upgrade_survivor_scenarios: reported-issues telegram_mode: mock-openai - telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-context-command,telegram-mention-gating + telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-context-command,telegram-current-session-status-tool,telegram-mention-gating secrets: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }} diff --git a/CHANGELOG.md b/CHANGELOG.md index a841f734a3c..3334835f960 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -135,6 +135,7 @@ Docs: https://docs.openclaw.ai - Channels/streaming: expose `streaming.progress.label`, `labels`, `maxLines`, and `toolProgress` in bundled channel config metadata so progress draft settings appear in config, docs, and control surfaces. Thanks @vincentkoc. - Channels/streaming: normalize whitespace and case for `streaming.progress.label: "auto"` so progress draft labels keep using the built-in label pool instead of rendering a literal `auto` title. Thanks @vincentkoc. - Plugins/Codex: preserve Codex-native OAuth routing for `/codex bind` app-server turns so bound sessions keep the selected Codex auth profile instead of falling back to public OpenAI credentials. (#76714) Thanks @keshavbotagent. +- Telegram: keep status checks pointed at the active chat so asking for the current session no longer reports an old direct-message conversation. (#76708) Thanks @amknight. - Gateway/install: prefer supported system Node over nvm/fnm/volta/asdf/mise when regenerating managed gateway services, so `gateway install --force` no longer recreates service definitions that doctor immediately flags as version-manager-backed. Fixes #76339. Thanks @brokemac79. - Cron/status: render explicit `delivery.mode: "none"` jobs as no-delivery previews and label cron session history distinctly instead of showing fallback delivery or direct-session rows. Fixes #76945. - Gateway/usage: serve `usage.cost` and `sessions.usage` from a durable transcript aggregate cache with lock-safe background refreshes and localized stale-cache status, so large usage views avoid repeated full scans. (#76650) Thanks @Marvinthebored. diff --git a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts index 71bb3f6460a..c821f526b8e 100644 --- a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts +++ b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts @@ -331,6 +331,7 @@ describe("telegram live qa runtime", () => { "telegram-tools-compact-command", "telegram-whoami-command", "telegram-context-command", + "telegram-current-session-status-tool", "telegram-mentioned-message-reply", "telegram-mention-gating", ]); @@ -340,9 +341,15 @@ describe("telegram live qa runtime", () => { "telegram-tools-compact-command", "telegram-whoami-command", "telegram-context-command", + "telegram-current-session-status-tool", "telegram-mentioned-message-reply", "telegram-mention-gating", ]); + expect( + scenarios + .find((scenario) => scenario.id === "telegram-current-session-status-tool") + ?.buildRun("sut_bot").expectedTextIncludes, + ).toEqual(["QA-TELEGRAM-CURRENT-SESSION-OK", ":telegram:group:"]); expect( scenarios .find((scenario) => scenario.id === "telegram-mentioned-message-reply") diff --git a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts index 70958d0793a..8fcd104c8ae 100644 --- a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts +++ b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts @@ -47,6 +47,7 @@ type TelegramQaScenarioId = | "telegram-tools-compact-command" | "telegram-whoami-command" | "telegram-context-command" + | "telegram-current-session-status-tool" | "telegram-mentioned-message-reply" | "telegram-mention-gating"; @@ -208,6 +209,7 @@ type TelegramMessage = { type TelegramUpdate = { update_id: number; + edited_message?: TelegramMessage; message?: TelegramMessage; }; @@ -270,6 +272,17 @@ const TELEGRAM_QA_SCENARIOS: TelegramQaScenarioDefinition[] = [ expectedTextIncludes: ["/context list", "Inline shortcut"], }), }, + { + id: "telegram-current-session-status-tool", + title: "Telegram current session_status tool call", + defaultEnabled: false, + timeoutMs: 60_000, + buildRun: (sutUsername) => ({ + expectReply: true, + input: `@${sutUsername} Telegram current session_status QA check. Call session_status with sessionKey set to current, then reply with the exact QA marker and resolved session key.`, + expectedTextIncludes: ["QA-TELEGRAM-CURRENT-SESSION-OK", ":telegram:group:"], + }), + }, { id: "telegram-mentioned-message-reply", title: "Telegram mentioned message gets a reply", @@ -471,7 +484,7 @@ function detectMediaKinds(message: TelegramMessage) { } function normalizeTelegramObservedMessage(update: TelegramUpdate): TelegramObservedMessage | null { - const message = update.message; + const message = update.message ?? update.edited_message; if (!message?.from?.id) { return null; } @@ -608,7 +621,7 @@ async function flushTelegramUpdates(token: string) { { offset, timeout: 0, - allowed_updates: ["message"], + allowed_updates: ["message", "edited_message"], }, 15_000, ); @@ -653,10 +666,12 @@ async function waitForObservedMessage(params: { observedMessages: TelegramObservedMessage[]; observationScenarioId: string; observationScenarioTitle: string; + expectedTextIncludes?: string[]; }) { const startedAt = Date.now(); let offset = params.initialOffset; let lastPollingError: unknown; + let lastExpectedMismatch: Error | undefined; while (Date.now() - startedAt < params.timeoutMs) { const remainingMs = Math.max( 1_000, @@ -671,7 +686,7 @@ async function waitForObservedMessage(params: { { offset, timeout: timeoutSeconds, - allowed_updates: ["message"], + allowed_updates: ["message", "edited_message"], }, timeoutSeconds * 1000 + 5_000, ); @@ -703,10 +718,23 @@ async function waitForObservedMessage(params: { }; params.observedMessages.push(observedMessage); if (matchedScenario) { + try { + assertTelegramScenarioReply({ + expectedTextIncludes: params.expectedTextIncludes, + message: observedMessage, + }); + } catch (error) { + lastExpectedMismatch = + error instanceof Error ? error : new Error(formatErrorMessage(error)); + continue; + } return { message: observedMessage, nextOffset: offset, observedAtMs: batchObservedAtMs }; } } } + if (lastExpectedMismatch) { + throw lastExpectedMismatch; + } const timeoutMessage = `timed out after ${params.timeoutMs}ms waiting for Telegram message`; if (lastPollingError) { throw new Error( @@ -1332,6 +1360,9 @@ export async function runTelegramQaLive(params: { observedMessages, observationScenarioId: scenario.id, observationScenarioTitle: scenario.title, + expectedTextIncludes: scenarioRun.expectReply + ? scenarioRun.expectedTextIncludes + : undefined, predicate: (message) => matchesTelegramScenarioReply({ allowAnySutReply: scenarioRun.allowAnySutReply, diff --git a/extensions/qa-lab/src/providers/mock-openai/server.ts b/extensions/qa-lab/src/providers/mock-openai/server.ts index 5d1b86e1c23..f83d86e2df1 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.ts @@ -152,6 +152,7 @@ const QA_TOOL_PROGRESS_PROMPT_RE = /tool progress qa check/i; const QA_GROUP_VISIBLE_REPLY_TOOL_PROMPT_RE = /qa group visible reply tool check/i; const QA_GROUP_MESSAGE_UNAVAILABLE_FALLBACK_PROMPT_RE = /qa group message unavailable fallback check/i; +const QA_TELEGRAM_CURRENT_SESSION_STATUS_PROMPT_RE = /telegram current session_status qa check/i; const QA_SUBAGENT_DIRECT_FALLBACK_PROMPT_RE = /subagent direct fallback qa check/i; const QA_SUBAGENT_DIRECT_FALLBACK_WORKER_RE = /subagent direct fallback worker/i; const QA_SUBAGENT_DIRECT_FALLBACK_MARKER = "QA-SUBAGENT-DIRECT-FALLBACK-OK"; @@ -673,6 +674,28 @@ function hasToolErrorOutput(toolJson: Record | null, toolOutput return /\b(?:error|failed|failure|not found|no such file|enoent)\b/i.test(toolOutput); } +function extractSessionStatusSessionKey( + toolJson: Record | null, + toolOutput: string, +) { + const details = toolJson?.details; + if (details && typeof details === "object") { + const sessionKey = (details as { sessionKey?: unknown }).sessionKey; + if (typeof sessionKey === "string" && sessionKey.trim()) { + return sessionKey.trim(); + } + } + const topLevelSessionKey = toolJson?.sessionKey; + if (typeof topLevelSessionKey === "string" && topLevelSessionKey.trim()) { + return topLevelSessionKey.trim(); + } + const statusLineSessionKey = /(?:^|\n)[^\n]*Session:\s*([^\s•\n]+)/u.exec(toolOutput)?.[1]; + if (statusLineSessionKey?.trim()) { + return statusLineSessionKey.trim(); + } + return /"sessionKey"\s*:\s*"([^"]+)"/.exec(toolOutput)?.[1]?.trim() ?? ""; +} + function isHeartbeatPrompt(text: string) { const trimmed = text.trim(); if (!trimmed || /remember this fact/i.test(trimmed)) { @@ -1349,6 +1372,17 @@ async function buildResponsesPayload( exactMarkerDirective ?? exactReplyDirective ?? "QA-GROUP-FALLBACK-OK", ); } + if (QA_TELEGRAM_CURRENT_SESSION_STATUS_PROMPT_RE.test(allInputText)) { + if (!toolOutput && hasDeclaredTool(body, "session_status")) { + return buildToolCallEventsWithArgs("session_status", { sessionKey: "current" }); + } + const sessionKey = extractSessionStatusSessionKey(toolJson, toolOutput); + return buildAssistantEvents( + sessionKey.includes(":telegram:group:") + ? `QA-TELEGRAM-CURRENT-SESSION-OK ${sessionKey}` + : `QA-TELEGRAM-CURRENT-SESSION-BAD ${sessionKey || "missing-session-key"}`, + ); + } if (/\bmarker\b/i.test(allInputText) && exactReplyDirective) { return buildAssistantEvents(exactReplyDirective); } diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts index fb7d2c2c0d5..ea08a4c59b4 100644 --- a/src/agents/openclaw-tools.session-status.test.ts +++ b/src/agents/openclaw-tools.session-status.test.ts @@ -492,6 +492,61 @@ describe("session_status tool", () => { expect(details.sessionKey).toBe("main"); }); + it("resolves sessionKey=current to runSessionKey under default tree visibility (#76708)", async () => { + resetSessionStore({ + "agent:main:telegram:default:direct:1234": { + sessionId: "s-tg-direct", + updatedAt: 5, + status: "done", + }, + "agent:main:main": { + sessionId: "s-main", + updatedAt: 10, + status: "running", + }, + }); + + // Default visibility is "tree". The tool is constructed with the Telegram + // sandbox key as agentSessionKey but the live run session key as runSessionKey. + // semantic-current must be treated as self for visibility purposes. + const tool = createSessionStatusTool({ + agentSessionKey: "agent:main:telegram:default:direct:1234", + runSessionKey: "agent:main:main", + config: mockConfig as never, + }); + + const result = await tool.execute("call-current-run-session", { sessionKey: "current" }); + const details = result.details as { ok?: boolean; sessionKey?: string }; + expect(details.ok).toBe(true); + expect(details.sessionKey).toBe("agent:main:main"); + }); + + it("rejects explicit cross-session key under tree visibility even when it equals runSessionKey (#76708)", async () => { + resetSessionStore({ + "agent:main:telegram:default:direct:1234": { + sessionId: "s-tg-direct", + updatedAt: 5, + status: "done", + }, + "agent:main:main": { + sessionId: "s-main", + updatedAt: 10, + status: "running", + }, + }); + + // Same setup but with an explicit key — should NOT bypass visibility. + const tool = createSessionStatusTool({ + agentSessionKey: "agent:main:telegram:default:direct:1234", + runSessionKey: "agent:main:main", + config: mockConfig as never, + }); + + await expect( + tool.execute("call-explicit-key", { sessionKey: "agent:main:main" }), + ).rejects.toThrow(/visibility is restricted/); + }); + it("treats the TUI client label as the current requester session", async () => { resetSessionStore({ "agent:main:main": { @@ -590,6 +645,29 @@ describe("session_status tool", () => { expect(details.statusText).toContain("🧠 Model:"); }); + it("resolves sandboxed sessionKey=current to the requester when no run session override exists", async () => { + resetSessionStore({}); + + const tool = getSessionStatusTool("agent:main:telegram:group:-5096326138", { + sandboxed: true, + }); + + const result = await tool.execute("call-current-sandboxed-channel", { + sessionKey: "current", + }); + const details = result.details as { ok?: boolean; sessionKey?: string; statusText?: string }; + expect(details.ok).toBe(true); + expect(details.sessionKey).toBe("agent:main:telegram:group:-5096326138"); + expect(details.statusText).toContain("OpenClaw"); + expect(details.statusText).toContain("🧠 Model:"); + expect(callGatewayMock).not.toHaveBeenCalledWith( + expect.objectContaining({ + method: "sessions.resolve", + params: expect.objectContaining({ key: "current" }), + }), + ); + }); + it("resolves the default session_status lookup for a channel-plugin requester via implicit fallback", async () => { resetSessionStore({}); diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index f7f20194cd1..79d9629983b 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -260,6 +260,12 @@ export function createOpenClawTools( sandboxBrowserBridgeUrl?: string; allowHostBrowserControl?: boolean; agentSessionKey?: string; + /** + * The actual live run session key. When the tool is constructed with a sandbox/policy + * session key, this allows `session_status({sessionKey:"current"})` to resolve to + * the live run session instead of the stale sandbox key. + */ + runSessionKey?: string; agentChannel?: GatewayMessageChannel; agentAccountId?: string; /** Delivery target for topic/thread routing. */ @@ -588,6 +594,7 @@ export function createOpenClawTools( }), createSessionStatusTool({ agentSessionKey: options?.agentSessionKey, + runSessionKey: options?.runSessionKey, config: resolvedConfig, sandboxed: options?.sandboxed, }), diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index bdf51e6daa1..2c63232c19c 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -664,6 +664,10 @@ async function compactEmbeddedPiSessionDirectOnce( messageProvider: resolvedMessageProvider, agentAccountId: params.agentAccountId, sessionKey: sandboxSessionKey, + runSessionKey: + params.sessionKey && params.sessionKey !== sandboxSessionKey + ? params.sessionKey + : undefined, sessionId: params.sessionId, runId: params.runId, groupId: params.groupId, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 27a8e11e9d1..2b7f2322cf9 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -885,6 +885,13 @@ export async function runEmbeddedAttempt( ownerOnlyToolAllowlist: params.ownerOnlyToolAllowlist, allowGatewaySubagentBinding: params.allowGatewaySubagentBinding, sessionKey: sandboxSessionKey, + // When sandboxSessionKey differs from the real run session key (e.g. Telegram + // direct peer key vs agent:main:main), pass the live key so session_status + // "current" resolves to the active run session, not the stale sandbox key. + runSessionKey: + params.sessionKey && params.sessionKey !== sandboxSessionKey + ? params.sessionKey + : undefined, sessionId: params.sessionId, runId: params.runId, agentDir, diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index db233bc9165..451efa8a6e7 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -274,6 +274,12 @@ export function createOpenClawCodingTools(options?: { messageThreadId?: string | number; sandbox?: SandboxContext | null; sessionKey?: string; + /** + * The actual live run session key. When the tool set is constructed with a + * sandbox/policy session key, this allows `session_status({sessionKey:"current"})` + * to resolve to the live run session instead of the stale sandbox key. + */ + runSessionKey?: string; /** Ephemeral session UUID — regenerated on /new and /reset. */ sessionId?: string; /** Stable run identifier for this agent invocation. */ @@ -698,6 +704,7 @@ export function createOpenClawCodingTools(options?: { sandboxBrowserBridgeUrl: sandbox?.browser?.bridgeUrl, allowHostBrowserControl: sandbox ? sandbox.browserAllowHostControl : true, agentSessionKey: options?.sessionKey, + runSessionKey: options?.runSessionKey, agentChannel: resolveGatewayMessageChannel(options?.messageProvider), agentAccountId: options?.agentAccountId, agentTo: options?.messageTo, diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index 495f6a0e582..6dba9a402bb 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -276,6 +276,12 @@ async function resolveModelOverride(params: { export function createSessionStatusTool(opts?: { agentSessionKey?: string; + /** + * The actual live run session key. When the tool is constructed with a sandbox/policy + * session key (e.g. a Telegram direct peer key), this allows `session_status({sessionKey: + * "current"})` to resolve to the live run session instead of the stale sandbox key. + */ + runSessionKey?: string; config?: OpenClawConfig; sandboxed?: boolean; }): AnyAgentTool { @@ -346,12 +352,31 @@ export function createSessionStatusTool(opts?: { const requestedKeyParam = readStringParam(params, "sessionKey"); let requestedKeyRaw = requestedKeyParam ?? opts?.agentSessionKey; + + // Track whether this is a semantic-current request (literal "current" or a + // current-client alias) BEFORE any rewrite, so visibility treats it as self. + const isSemanticCurrentRequest = + requestedKeyRaw === "current" || + Boolean( + resolveCurrentSessionClientAlias({ + key: requestedKeyRaw ?? "", + requesterInternalKey: effectiveRequesterKey, + }), + ); + + // Resolve semantic "current" to the live run session key for lookup purposes (#76708). + // In sandboxed channel runs there may be no separate runSessionKey because the sandbox + // key already is the live requester; avoid probing literal "current" through the gateway. + if (requestedKeyRaw === "current" && (opts?.runSessionKey || opts?.sandboxed === true)) { + requestedKeyRaw = opts.runSessionKey ?? effectiveRequesterKey; + } + const currentSessionAlias = resolveCurrentSessionClientAlias({ key: requestedKeyRaw ?? "", requesterInternalKey: effectiveRequesterKey, }); if (currentSessionAlias) { - requestedKeyRaw = currentSessionAlias; + requestedKeyRaw = opts?.runSessionKey ?? currentSessionAlias; } const requestedKeyInput = requestedKeyRaw?.trim() ?? ""; let resolvedViaSessionId = false; @@ -374,7 +399,7 @@ export function createSessionStatusTool(opts?: { } }; - if (requestedKeyRaw.startsWith("agent:")) { + if (requestedKeyRaw.startsWith("agent:") && !isSemanticCurrentRequest) { const requestedAgentId = resolveAgentIdFromSessionKey(requestedKeyRaw); ensureAgentAccess(requestedAgentId); const access = visibilityGuard.check( @@ -485,7 +510,7 @@ export function createSessionStatusTool(opts?: { if (!resolved) { const fallback = resolveImplicitCurrentSessionFallback({ - allowFallback: requestedKeyRaw === "current" || requestedKeyParam === undefined, + allowFallback: isSemanticCurrentRequest || requestedKeyParam === undefined, storeScopedRequesterKey, }); if (fallback) { @@ -501,6 +526,7 @@ export function createSessionStatusTool(opts?: { // Preserve caller-scoped raw-key/current lookups as "self" for visibility checks. const shouldTreatVisibilityTargetAsSelf = + isSemanticCurrentRequest || resolvedViaImplicitCurrentFallback || (!resolvedViaSessionId && (requestedKeyInput === "current" || resolved.key === requestedKeyInput)); diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 3dc80851d14..6e8c853f839 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -522,7 +522,7 @@ describe("package artifact reuse", () => { expect(workflow).toContain("published_upgrade_survivor_scenarios: reported-issues"); expect(workflow).toContain("telegram_mode: mock-openai"); expect(workflow).toContain( - "telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-context-command,telegram-mention-gating", + "telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-context-command,telegram-current-session-status-tool,telegram-mention-gating", ); expect(workflow).toContain("ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}"); expect(workflow).toContain("ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}");