From f07033ed3fb9006b35be6d8e2a64464ab40f3f3b Mon Sep 17 00:00:00 2001 From: Frank Yang Date: Fri, 13 Mar 2026 16:18:01 +0800 Subject: [PATCH 01/18] fix: address delivery dedupe review follow-ups (#44666) Merged via squash. Prepared head SHA: 8e6d254cc4781df66ee02b683c4ad72b5a633502 Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com> Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com> Reviewed-by: @frankekn --- CHANGELOG.md | 1 + .../subagent-registry.persistence.test.ts | 29 +++++ src/agents/subagent-registry.ts | 16 ++- src/config/sessions/sessions.test.ts | 82 ++++++++++++++ src/config/sessions/transcript.ts | 34 ++++++ .../delivery-dispatch.double-announce.test.ts | 47 +++++++- src/cron/isolated-agent/delivery-dispatch.ts | 103 +++++++++++++++++- src/gateway/server-methods/send.test.ts | 1 + src/gateway/server-methods/send.ts | 2 + src/infra/outbound/deliver.test.ts | 6 +- src/infra/outbound/deliver.ts | 13 +-- src/infra/outbound/delivery-queue.ts | 10 +- src/infra/outbound/message.test.ts | 33 ++++++ src/infra/outbound/message.ts | 9 +- src/infra/outbound/mirror.ts | 14 +++ .../outbound/outbound-send-service.test.ts | 41 +++++++ src/infra/outbound/outbound-send-service.ts | 9 +- 17 files changed, 413 insertions(+), 37 deletions(-) create mode 100644 src/infra/outbound/mirror.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 56143615b29..b3d4d0398ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,7 @@ Docs: https://docs.openclaw.ai - Cron/doctor: stop flagging canonical `agentTurn` and `systemEvent` payload kinds as legacy cron storage, while still normalizing whitespace-padded and non-canonical variants. (#44012) Thanks @shuicici. - ACP/client final-message delivery: preserve terminal assistant text snapshots before resolving `end_turn`, so ACP clients no longer drop the last visible reply when the gateway sends the final message body on the terminal chat event. (#17615) Thanks @pjeby. - Telegram/Discord status reactions: show a temporary compacting reaction during auto-compaction pauses and restore thinking afterward so the bot no longer appears frozen while context is being compacted. (#35474) thanks @Cypherm. +- Delivery/dedupe: trim completed direct-cron delivery cache correctly and keep mirrored transcript dedupe active even when transcript files contain malformed lines. (#44666) thanks @frankekn. ## 2026.3.11 diff --git a/src/agents/subagent-registry.persistence.test.ts b/src/agents/subagent-registry.persistence.test.ts index 468de55953c..32f2e06311e 100644 --- a/src/agents/subagent-registry.persistence.test.ts +++ b/src/agents/subagent-registry.persistence.test.ts @@ -343,6 +343,35 @@ describe("subagent registry persistence", () => { expect(afterSecond.runs["run-3"].cleanupCompletedAt).toBeDefined(); }); + it("retries cleanup announce after announce flow rejects", async () => { + const persisted = createPersistedEndedRun({ + runId: "run-reject", + childSessionKey: "agent:main:subagent:reject", + task: "reject announce", + cleanup: "keep", + }); + const registryPath = await writePersistedRegistry(persisted); + + announceSpy.mockRejectedValueOnce(new Error("announce boom")); + await restartRegistryAndFlush(); + + expect(announceSpy).toHaveBeenCalledTimes(1); + const afterFirst = JSON.parse(await fs.readFile(registryPath, "utf8")) as { + runs: Record; + }; + expect(afterFirst.runs["run-reject"].cleanupHandled).toBe(false); + expect(afterFirst.runs["run-reject"].cleanupCompletedAt).toBeUndefined(); + + announceSpy.mockResolvedValueOnce(true); + await restartRegistryAndFlush(); + + expect(announceSpy).toHaveBeenCalledTimes(2); + const afterSecond = JSON.parse(await fs.readFile(registryPath, "utf8")) as { + runs: Record; + }; + expect(afterSecond.runs["run-reject"].cleanupCompletedAt).toBeDefined(); + }); + it("keeps delete-mode runs retryable when announce is deferred", async () => { const persisted = createPersistedEndedRun({ runId: "run-4", diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index 477544bdd3d..d9c593c3e84 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -534,6 +534,18 @@ function startSubagentAnnounceCleanupFlow(runId: string, entry: SubagentRunRecor return false; } const requesterOrigin = normalizeDeliveryContext(entry.requesterOrigin); + const finalizeAnnounceCleanup = (didAnnounce: boolean) => { + void finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce).catch((err) => { + defaultRuntime.log(`[warn] subagent cleanup finalize failed (${runId}): ${String(err)}`); + const current = subagentRuns.get(runId); + if (!current || current.cleanupCompletedAt) { + return; + } + current.cleanupHandled = false; + persistSubagentRuns(); + }); + }; + void runSubagentAnnounceFlow({ childSessionKey: entry.childSessionKey, childRunId: entry.runId, @@ -555,13 +567,13 @@ function startSubagentAnnounceCleanupFlow(runId: string, entry: SubagentRunRecor wakeOnDescendantSettle: entry.wakeOnDescendantSettle === true, }) .then((didAnnounce) => { - void finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce); + finalizeAnnounceCleanup(didAnnounce); }) .catch((error) => { defaultRuntime.log( `[warn] Subagent announce flow failed during cleanup for run ${runId}: ${String(error)}`, ); - void finalizeSubagentCleanup(runId, entry.cleanup, false); + finalizeAnnounceCleanup(false); }); return true; } diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts index dfe4b74e9b2..6866d6c10c1 100644 --- a/src/config/sessions/sessions.test.ts +++ b/src/config/sessions/sessions.test.ts @@ -324,6 +324,88 @@ describe("appendAssistantMessageToSessionTranscript", () => { expect(messageLine.message.content[0].text).toBe("Hello from delivery mirror!"); } }); + + it("does not append a duplicate delivery mirror for the same idempotency key", async () => { + const sessionId = "test-session-id"; + const sessionKey = "test-session"; + const store = { + [sessionKey]: { + sessionId, + chatType: "direct", + channel: "discord", + }, + }; + fs.writeFileSync(fixture.storePath(), JSON.stringify(store), "utf-8"); + + await appendAssistantMessageToSessionTranscript({ + sessionKey, + text: "Hello from delivery mirror!", + idempotencyKey: "mirror:test-source-message", + storePath: fixture.storePath(), + }); + await appendAssistantMessageToSessionTranscript({ + sessionKey, + text: "Hello from delivery mirror!", + idempotencyKey: "mirror:test-source-message", + storePath: fixture.storePath(), + }); + + const sessionFile = resolveSessionTranscriptPathInDir(sessionId, fixture.sessionsDir()); + const lines = fs.readFileSync(sessionFile, "utf-8").trim().split("\n"); + expect(lines.length).toBe(2); + + const messageLine = JSON.parse(lines[1]); + expect(messageLine.message.idempotencyKey).toBe("mirror:test-source-message"); + expect(messageLine.message.content[0].text).toBe("Hello from delivery mirror!"); + }); + + it("ignores malformed transcript lines when checking mirror idempotency", async () => { + const sessionId = "test-session-id"; + const sessionKey = "test-session"; + const store = { + [sessionKey]: { + sessionId, + chatType: "direct", + channel: "discord", + }, + }; + fs.writeFileSync(fixture.storePath(), JSON.stringify(store), "utf-8"); + + const sessionFile = resolveSessionTranscriptPathInDir(sessionId, fixture.sessionsDir()); + fs.writeFileSync( + sessionFile, + [ + JSON.stringify({ + type: "session", + version: 1, + id: sessionId, + timestamp: new Date().toISOString(), + cwd: process.cwd(), + }), + "{not-json", + JSON.stringify({ + type: "message", + message: { + role: "assistant", + idempotencyKey: "mirror:test-source-message", + content: [{ type: "text", text: "Hello from delivery mirror!" }], + }, + }), + ].join("\n") + "\n", + "utf-8", + ); + + const result = await appendAssistantMessageToSessionTranscript({ + sessionKey, + text: "Hello from delivery mirror!", + idempotencyKey: "mirror:test-source-message", + storePath: fixture.storePath(), + }); + + expect(result.ok).toBe(true); + const lines = fs.readFileSync(sessionFile, "utf-8").trim().split("\n"); + expect(lines.length).toBe(3); + }); }); describe("resolveAndPersistSessionFile", () => { diff --git a/src/config/sessions/transcript.ts b/src/config/sessions/transcript.ts index e6a8044f5c6..aa1890de953 100644 --- a/src/config/sessions/transcript.ts +++ b/src/config/sessions/transcript.ts @@ -135,6 +135,7 @@ export async function appendAssistantMessageToSessionTranscript(params: { sessionKey: string; text?: string; mediaUrls?: string[]; + idempotencyKey?: string; /** Optional override for store path (mostly for tests). */ storePath?: string; }): Promise<{ ok: true; sessionFile: string } | { ok: false; reason: string }> { @@ -179,6 +180,13 @@ export async function appendAssistantMessageToSessionTranscript(params: { await ensureSessionHeader({ sessionFile, sessionId: entry.sessionId }); + if ( + params.idempotencyKey && + (await transcriptHasIdempotencyKey(sessionFile, params.idempotencyKey)) + ) { + return { ok: true, sessionFile }; + } + const sessionManager = SessionManager.open(sessionFile); sessionManager.appendMessage({ role: "assistant", @@ -202,8 +210,34 @@ export async function appendAssistantMessageToSessionTranscript(params: { }, stopReason: "stop", timestamp: Date.now(), + ...(params.idempotencyKey ? { idempotencyKey: params.idempotencyKey } : {}), }); emitSessionTranscriptUpdate(sessionFile); return { ok: true, sessionFile }; } + +async function transcriptHasIdempotencyKey( + transcriptPath: string, + idempotencyKey: string, +): Promise { + try { + const raw = await fs.promises.readFile(transcriptPath, "utf-8"); + for (const line of raw.split(/\r?\n/)) { + if (!line.trim()) { + continue; + } + try { + const parsed = JSON.parse(line) as { message?: { idempotencyKey?: unknown } }; + if (parsed.message?.idempotencyKey === idempotencyKey) { + return true; + } + } catch { + continue; + } + } + } catch { + return false; + } + return false; +} diff --git a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts index 2c7eb20a3c6..b245b4b9c94 100644 --- a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts +++ b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts @@ -49,7 +49,11 @@ vi.mock("./subagent-followup.js", () => ({ import { countActiveDescendantRuns } from "../../agents/subagent-registry.js"; import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js"; import { shouldEnqueueCronMainSummary } from "../heartbeat-policy.js"; -import { dispatchCronDelivery } from "./delivery-dispatch.js"; +import { + dispatchCronDelivery, + getCompletedDirectCronDeliveriesCountForTests, + resetCompletedDirectCronDeliveriesForTests, +} from "./delivery-dispatch.js"; import type { DeliveryTargetResolution } from "./delivery-target.js"; import type { RunCronAgentTurnResult } from "./run.js"; import { @@ -84,7 +88,11 @@ function makeWithRunSession() { }); } -function makeBaseParams(overrides: { synthesizedText?: string; deliveryRequested?: boolean }) { +function makeBaseParams(overrides: { + synthesizedText?: string; + deliveryRequested?: boolean; + runSessionId?: string; +}) { const resolvedDelivery = makeResolvedDelivery(); return { cfg: {} as never, @@ -98,7 +106,7 @@ function makeBaseParams(overrides: { synthesizedText?: string; deliveryRequested } as never, agentId: "main", agentSessionKey: "agent:main", - runSessionId: "run-123", + runSessionId: overrides.runSessionId ?? "run-123", runStartedAt: Date.now(), runEndedAt: Date.now(), timeoutMs: 30_000, @@ -126,6 +134,7 @@ function makeBaseParams(overrides: { synthesizedText?: string; deliveryRequested describe("dispatchCronDelivery — double-announce guard", () => { beforeEach(() => { vi.clearAllMocks(); + resetCompletedDirectCronDeliveriesForTests(); vi.mocked(countActiveDescendantRuns).mockReturnValue(0); vi.mocked(expectsSubagentFollowup).mockReturnValue(false); vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); @@ -278,6 +287,38 @@ describe("dispatchCronDelivery — double-announce guard", () => { expect(deliverOutboundPayloads).toHaveBeenCalledTimes(2); }); + it("keeps direct announce delivery idempotent across replay for the same run session", async () => { + vi.mocked(countActiveDescendantRuns).mockReturnValue(0); + vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); + vi.mocked(deliverOutboundPayloads).mockResolvedValue([{ ok: true } as never]); + + const params = makeBaseParams({ synthesizedText: "Replay-safe cron update." }); + const first = await dispatchCronDelivery(params); + const second = await dispatchCronDelivery(params); + + expect(first.delivered).toBe(true); + expect(second.delivered).toBe(true); + expect(second.deliveryAttempted).toBe(true); + expect(deliverOutboundPayloads).toHaveBeenCalledTimes(1); + }); + + it("prunes the completed-delivery cache back to the entry cap", async () => { + vi.mocked(countActiveDescendantRuns).mockReturnValue(0); + vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); + vi.mocked(deliverOutboundPayloads).mockResolvedValue([{ ok: true } as never]); + + for (let i = 0; i < 2003; i += 1) { + const params = makeBaseParams({ + synthesizedText: `Replay-safe cron update ${i}.`, + runSessionId: `run-${i}`, + }); + const state = await dispatchCronDelivery(params); + expect(state.delivered).toBe(true); + } + + expect(getCompletedDirectCronDeliveriesCountForTests()).toBe(2000); + }); + it("does not retry permanent direct announce failures", async () => { vi.stubEnv("OPENCLAW_TEST_FAST", "1"); vi.mocked(countActiveDescendantRuns).mockReturnValue(0); diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index a5dc0190b72..6ddddf20669 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -5,7 +5,10 @@ import { createOutboundSendDeps, type CliDeps } from "../../cli/outbound-send-de import type { OpenClawConfig } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; import { sleepWithAbort } from "../../infra/backoff.js"; -import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js"; +import { + deliverOutboundPayloads, + type OutboundDeliveryResult, +} from "../../infra/outbound/deliver.js"; import { resolveAgentOutboundIdentity } from "../../infra/outbound/identity.js"; import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js"; import { logWarn } from "../../logger.js"; @@ -131,6 +134,91 @@ const PERMANENT_DIRECT_CRON_DELIVERY_ERROR_PATTERNS: readonly RegExp[] = [ /outbound not configured for channel/i, ]; +type CompletedDirectCronDelivery = { + ts: number; + results: OutboundDeliveryResult[]; +}; + +const COMPLETED_DIRECT_CRON_DELIVERIES = new Map(); + +function cloneDeliveryResults( + results: readonly OutboundDeliveryResult[], +): OutboundDeliveryResult[] { + return results.map((result) => ({ + ...result, + ...(result.meta ? { meta: { ...result.meta } } : {}), + })); +} + +function pruneCompletedDirectCronDeliveries(now: number) { + const ttlMs = process.env.OPENCLAW_TEST_FAST === "1" ? 60_000 : 24 * 60 * 60 * 1000; + for (const [key, entry] of COMPLETED_DIRECT_CRON_DELIVERIES) { + if (now - entry.ts >= ttlMs) { + COMPLETED_DIRECT_CRON_DELIVERIES.delete(key); + } + } + const maxEntries = 2000; + if (COMPLETED_DIRECT_CRON_DELIVERIES.size <= maxEntries) { + return; + } + const entries = [...COMPLETED_DIRECT_CRON_DELIVERIES.entries()].toSorted( + (a, b) => a[1].ts - b[1].ts, + ); + const toDelete = COMPLETED_DIRECT_CRON_DELIVERIES.size - maxEntries; + for (let i = 0; i < toDelete; i += 1) { + const oldest = entries[i]; + if (!oldest) { + break; + } + COMPLETED_DIRECT_CRON_DELIVERIES.delete(oldest[0]); + } +} + +function rememberCompletedDirectCronDelivery( + idempotencyKey: string, + results: readonly OutboundDeliveryResult[], +) { + const now = Date.now(); + COMPLETED_DIRECT_CRON_DELIVERIES.set(idempotencyKey, { + ts: now, + results: cloneDeliveryResults(results), + }); + pruneCompletedDirectCronDeliveries(now); +} + +function getCompletedDirectCronDelivery( + idempotencyKey: string, +): OutboundDeliveryResult[] | undefined { + const now = Date.now(); + pruneCompletedDirectCronDeliveries(now); + const cached = COMPLETED_DIRECT_CRON_DELIVERIES.get(idempotencyKey); + if (!cached) { + return undefined; + } + return cloneDeliveryResults(cached.results); +} + +function buildDirectCronDeliveryIdempotencyKey(params: { + runSessionId: string; + delivery: SuccessfulDeliveryTarget; +}): string { + const threadId = + params.delivery.threadId == null || params.delivery.threadId === "" + ? "" + : String(params.delivery.threadId); + const accountId = params.delivery.accountId?.trim() ?? ""; + const normalizedTo = normalizeDeliveryTarget(params.delivery.channel, params.delivery.to); + return `cron-direct-delivery:v1:${params.runSessionId}:${params.delivery.channel}:${accountId}:${normalizedTo}:${threadId}`; +} + +export function resetCompletedDirectCronDeliveriesForTests() { + COMPLETED_DIRECT_CRON_DELIVERIES.clear(); +} + +export function getCompletedDirectCronDeliveriesCountForTests(): number { + return COMPLETED_DIRECT_CRON_DELIVERIES.size; +} + function summarizeDirectCronDeliveryError(error: unknown): string { if (error instanceof Error) { return error.message || "error"; @@ -221,6 +309,10 @@ export async function dispatchCronDelivery( options?: { retryTransient?: boolean }, ): Promise => { const identity = resolveAgentOutboundIdentity(params.cfgWithAgentDefaults, params.agentId); + const deliveryIdempotencyKey = buildDirectCronDeliveryIdempotencyKey({ + runSessionId: params.runSessionId, + delivery, + }); try { const payloadsForDelivery = deliveryPayloads.length > 0 @@ -240,6 +332,12 @@ export async function dispatchCronDelivery( }); } deliveryAttempted = true; + const cachedResults = getCompletedDirectCronDelivery(deliveryIdempotencyKey); + if (cachedResults) { + // Cached entries are only recorded after a successful non-empty delivery. + delivered = true; + return null; + } const deliverySession = buildOutboundSessionContext({ cfg: params.cfgWithAgentDefaults, agentId: params.agentId, @@ -273,6 +371,9 @@ export async function dispatchCronDelivery( }) : await runDelivery(); delivered = deliveryResults.length > 0; + if (delivered) { + rememberCompletedDirectCronDelivery(deliveryIdempotencyKey, deliveryResults); + } return null; } catch (err) { if (!params.deliveryBestEffort) { diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index 0220a4d6895..22cf527a46b 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -334,6 +334,7 @@ describe("gateway send mirroring", () => { sessionKey: "agent:main:main", text: "caption", mediaUrls: ["https://example.com/files/report.pdf?sig=1"], + idempotencyKey: "idem-2", }), }), ); diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 1ec5d23c133..4dcdd1f61f9 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -268,6 +268,7 @@ export const sendHandlers: GatewayRequestHandlers = { agentId: effectiveAgentId, text: mirrorText || message, mediaUrls: mirrorMediaUrls.length > 0 ? mirrorMediaUrls : undefined, + idempotencyKey: idem, } : derivedRoute ? { @@ -275,6 +276,7 @@ export const sendHandlers: GatewayRequestHandlers = { agentId: effectiveAgentId, text: mirrorText || message, mediaUrls: mirrorMediaUrls.length > 0 ? mirrorMediaUrls : undefined, + idempotencyKey: idem, } : undefined, }); diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index e5b24c06a8c..8e5383ea055 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -869,11 +869,15 @@ describe("deliverOutboundPayloads", () => { sessionKey: "agent:main:main", text: "caption", mediaUrls: ["https://example.com/files/report.pdf?sig=1"], + idempotencyKey: "idem-deliver-1", }, }); expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith( - expect.objectContaining({ text: "report.pdf" }), + expect.objectContaining({ + text: "report.pdf", + idempotencyKey: "idem-deliver-1", + }), ); }); diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index caca4985370..79bbbc17179 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -38,6 +38,7 @@ import type { sendMessageWhatsApp } from "../../web/outbound.js"; import { throwIfAborted } from "./abort.js"; import { ackDelivery, enqueueDelivery, failDelivery } from "./delivery-queue.js"; import type { OutboundIdentity } from "./identity.js"; +import type { DeliveryMirror } from "./mirror.js"; import type { NormalizedOutboundPayload } from "./payloads.js"; import { normalizeReplyPayloadsForDelivery } from "./payloads.js"; import { isPlainTextSurface, sanitizeForPlainText } from "./sanitize-text.js"; @@ -237,16 +238,7 @@ type DeliverOutboundPayloadsCoreParams = { onPayload?: (payload: NormalizedOutboundPayload) => void; /** Session/agent context used for hooks and media local-root scoping. */ session?: OutboundSessionContext; - mirror?: { - sessionKey: string; - agentId?: string; - text?: string; - mediaUrls?: string[]; - /** Whether this message is being sent in a group/channel context */ - isGroup?: boolean; - /** Group or channel identifier for correlation with received events */ - groupId?: string; - }; + mirror?: DeliveryMirror; silent?: boolean; }; @@ -820,6 +812,7 @@ async function deliverOutboundPayloadsCore( agentId: params.mirror.agentId, sessionKey: params.mirror.sessionKey, text: mirrorText, + idempotencyKey: params.mirror.idempotencyKey, }); } } diff --git a/src/infra/outbound/delivery-queue.ts b/src/infra/outbound/delivery-queue.ts index 1cbab613bc4..97c37f911e4 100644 --- a/src/infra/outbound/delivery-queue.ts +++ b/src/infra/outbound/delivery-queue.ts @@ -4,6 +4,7 @@ import type { ReplyPayload } from "../../auto-reply/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { resolveStateDir } from "../../config/paths.js"; import { generateSecureUuid } from "../secure-random.js"; +import type { OutboundMirror } from "./mirror.js"; import type { OutboundChannel } from "./targets.js"; const QUEUE_DIRNAME = "delivery-queue"; @@ -18,13 +19,6 @@ const BACKOFF_MS: readonly number[] = [ 600_000, // retry 4: 10m ]; -type DeliveryMirrorPayload = { - sessionKey: string; - agentId?: string; - text?: string; - mediaUrls?: string[]; -}; - type QueuedDeliveryPayload = { channel: Exclude; to: string; @@ -40,7 +34,7 @@ type QueuedDeliveryPayload = { bestEffort?: boolean; gifPlayback?: boolean; silent?: boolean; - mirror?: DeliveryMirrorPayload; + mirror?: OutboundMirror; }; export interface QueuedDelivery extends QueuedDeliveryPayload { diff --git a/src/infra/outbound/message.test.ts b/src/infra/outbound/message.test.ts index 7cebff01d90..200d4d587e1 100644 --- a/src/infra/outbound/message.test.ts +++ b/src/infra/outbound/message.test.ts @@ -15,6 +15,16 @@ vi.mock("../../channels/plugins/index.js", () => ({ vi.mock("../../agents/agent-scope.js", () => ({ resolveDefaultAgentId: () => "main", + resolveSessionAgentId: ({ + sessionKey, + }: { + sessionKey?: string; + config?: unknown; + agentId?: string; + }) => { + const match = sessionKey?.match(/^agent:([^:]+)/i); + return match?.[1] ?? "main"; + }, resolveAgentWorkspaceDir: () => "/tmp/openclaw-test-workspace", })); @@ -71,6 +81,29 @@ describe("sendMessage", () => { ); }); + it("propagates the send idempotency key into mirrored transcript delivery", async () => { + await sendMessage({ + cfg: {}, + channel: "telegram", + to: "123456", + content: "hi", + idempotencyKey: "idem-send-1", + mirror: { + sessionKey: "agent:main:telegram:dm:123456", + }, + }); + + expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( + expect.objectContaining({ + mirror: expect.objectContaining({ + sessionKey: "agent:main:telegram:dm:123456", + text: "hi", + idempotencyKey: "idem-send-1", + }), + }), + ); + }); + it("recovers telegram plugin resolution so message/send does not fail with Unknown channel: telegram", async () => { const telegramPlugin = { outbound: { deliveryMode: "direct" }, diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts index f8c09538f75..8bfd6b104b5 100644 --- a/src/infra/outbound/message.ts +++ b/src/infra/outbound/message.ts @@ -16,6 +16,7 @@ import { type OutboundDeliveryResult, type OutboundSendDeps, } from "./deliver.js"; +import type { OutboundMirror } from "./mirror.js"; import { normalizeReplyPayloadsForDelivery } from "./payloads.js"; import { buildOutboundSessionContext } from "./session-context.js"; import { resolveOutboundTarget } from "./targets.js"; @@ -47,12 +48,7 @@ type MessageSendParams = { cfg?: OpenClawConfig; gateway?: MessageGatewayOptions; idempotencyKey?: string; - mirror?: { - sessionKey: string; - agentId?: string; - text?: string; - mediaUrls?: string[]; - }; + mirror?: OutboundMirror; abortSignal?: AbortSignal; silent?: boolean; }; @@ -232,6 +228,7 @@ export async function sendMessage(params: MessageSendParams): Promise ({ sendMessage: vi.fn(), sendPoll: vi.fn(), getAgentScopedMediaLocalRoots: vi.fn(() => ["/tmp/agent-roots"]), + appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })), })); vi.mock("../../channels/plugins/message-actions.js", () => ({ @@ -26,6 +27,10 @@ vi.mock("../../media/local-roots.js", async (importOriginal) => { }; }); +vi.mock("../../config/sessions.js", () => ({ + appendAssistantMessageToSessionTranscript: mocks.appendAssistantMessageToSessionTranscript, +})); + import { executePollAction, executeSendAction } from "./outbound-send-service.js"; describe("executeSendAction", () => { @@ -35,6 +40,7 @@ describe("executeSendAction", () => { mocks.sendPoll.mockClear(); mocks.getDefaultMediaLocalRoots.mockClear(); mocks.getAgentScopedMediaLocalRoots.mockClear(); + mocks.appendAssistantMessageToSessionTranscript.mockClear(); }); it("forwards ctx.agentId to sendMessage on core outbound path", async () => { @@ -127,6 +133,41 @@ describe("executeSendAction", () => { ); }); + it("passes mirror idempotency keys through plugin-handled sends", async () => { + mocks.dispatchChannelMessageAction.mockResolvedValue({ + ok: true, + value: { messageId: "msg-plugin" }, + continuePrompt: "", + output: "", + sessionId: "s1", + model: "gpt-5.2", + usage: {}, + }); + + await executeSendAction({ + ctx: { + cfg: {}, + channel: "discord", + params: { to: "channel:123", message: "hello" }, + dryRun: false, + mirror: { + sessionKey: "agent:main:discord:channel:123", + idempotencyKey: "idem-plugin-send-1", + }, + }, + to: "channel:123", + message: "hello", + }); + + expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:main:discord:channel:123", + text: "hello", + idempotencyKey: "idem-plugin-send-1", + }), + ); + }); + it("forwards poll args to sendPoll on core outbound path", async () => { mocks.dispatchChannelMessageAction.mockResolvedValue(null); mocks.sendPoll.mockResolvedValue({ diff --git a/src/infra/outbound/outbound-send-service.ts b/src/infra/outbound/outbound-send-service.ts index 0661d5ddafb..a6b27a40b4c 100644 --- a/src/infra/outbound/outbound-send-service.ts +++ b/src/infra/outbound/outbound-send-service.ts @@ -9,6 +9,7 @@ import { throwIfAborted } from "./abort.js"; import type { OutboundSendDeps } from "./deliver.js"; import type { MessagePollResult, MessageSendResult } from "./message.js"; import { sendMessage, sendPoll } from "./message.js"; +import type { OutboundMirror } from "./mirror.js"; import { extractToolPayload } from "./tool-payload.js"; export type OutboundGatewayContext = { @@ -31,12 +32,7 @@ export type OutboundSendContext = { toolContext?: ChannelThreadingToolContext; deps?: OutboundSendDeps; dryRun: boolean; - mirror?: { - sessionKey: string; - agentId?: string; - text?: string; - mediaUrls?: string[]; - }; + mirror?: OutboundMirror; abortSignal?: AbortSignal; silent?: boolean; }; @@ -115,6 +111,7 @@ export async function executeSendAction(params: { sessionKey: params.ctx.mirror.sessionKey, text: mirrorText, mediaUrls: mirrorMediaUrls, + idempotencyKey: params.ctx.mirror.idempotencyKey, }); }, }); From 4e27c9b958407d86683e2c065f12f93588135da9 Mon Sep 17 00:00:00 2001 From: Frank Yang Date: Fri, 13 Mar 2026 16:37:11 +0800 Subject: [PATCH 02/18] CLI: align xhigh thinking help text (#44819) Merged via squash. Prepared head SHA: ff1f12717692d7745b34c5aef7c1dde3c6a4500d Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com> Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com> Reviewed-by: @frankekn --- CHANGELOG.md | 1 + src/cli/cron-cli/register.cron-add.ts | 5 ++++- src/cli/cron-cli/register.cron-edit.ts | 5 ++++- src/cli/program/register.agent.ts | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3d4d0398ca..d4cf4122aa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,6 +94,7 @@ Docs: https://docs.openclaw.ai - ACP/client final-message delivery: preserve terminal assistant text snapshots before resolving `end_turn`, so ACP clients no longer drop the last visible reply when the gateway sends the final message body on the terminal chat event. (#17615) Thanks @pjeby. - Telegram/Discord status reactions: show a temporary compacting reaction during auto-compaction pauses and restore thinking afterward so the bot no longer appears frozen while context is being compacted. (#35474) thanks @Cypherm. - Delivery/dedupe: trim completed direct-cron delivery cache correctly and keep mirrored transcript dedupe active even when transcript files contain malformed lines. (#44666) thanks @frankekn. +- CLI/thinking help: add the missing `xhigh` level hints to `openclaw cron add`, `openclaw cron edit`, and `openclaw agent` so the help text matches the levels already accepted at runtime. (#44819) Thanks @frankekn. ## 2026.3.11 diff --git a/src/cli/cron-cli/register.cron-add.ts b/src/cli/cron-cli/register.cron-add.ts index 05025dc05e6..bd7d0ff1af5 100644 --- a/src/cli/cron-cli/register.cron-add.ts +++ b/src/cli/cron-cli/register.cron-add.ts @@ -81,7 +81,10 @@ export function registerCronAddCommand(cron: Command) { .option("--exact", "Disable cron staggering (set stagger to 0)", false) .option("--system-event ", "System event payload (main session)") .option("--message ", "Agent message payload") - .option("--thinking ", "Thinking level for agent jobs (off|minimal|low|medium|high)") + .option( + "--thinking ", + "Thinking level for agent jobs (off|minimal|low|medium|high|xhigh)", + ) .option("--model ", "Model override for agent jobs (provider/model or alias)") .option("--timeout-seconds ", "Timeout seconds for agent jobs") .option("--light-context", "Use lightweight bootstrap context for agent jobs", false) diff --git a/src/cli/cron-cli/register.cron-edit.ts b/src/cli/cron-cli/register.cron-edit.ts index 35bf45907f9..b2007fc3f1a 100644 --- a/src/cli/cron-cli/register.cron-edit.ts +++ b/src/cli/cron-cli/register.cron-edit.ts @@ -49,7 +49,10 @@ export function registerCronEditCommand(cron: Command) { .option("--exact", "Disable cron staggering (set stagger to 0)") .option("--system-event ", "Set systemEvent payload") .option("--message ", "Set agentTurn payload message") - .option("--thinking ", "Thinking level for agent jobs") + .option( + "--thinking ", + "Thinking level for agent jobs (off|minimal|low|medium|high|xhigh)", + ) .option("--model ", "Model override for agent jobs") .option("--timeout-seconds ", "Timeout seconds for agent jobs") .option("--light-context", "Enable lightweight bootstrap context for agent jobs") diff --git a/src/cli/program/register.agent.ts b/src/cli/program/register.agent.ts index fdb45a0960a..e5847f3c164 100644 --- a/src/cli/program/register.agent.ts +++ b/src/cli/program/register.agent.ts @@ -27,7 +27,7 @@ export function registerAgentCommands(program: Command, args: { agentChannelOpti .option("-t, --to ", "Recipient number in E.164 used to derive the session key") .option("--session-id ", "Use an explicit session id") .option("--agent ", "Agent id (overrides routing bindings)") - .option("--thinking ", "Thinking level: off | minimal | low | medium | high") + .option("--thinking ", "Thinking level: off | minimal | low | medium | high | xhigh") .option("--verbose ", "Persist agent verbose level for the session") .option( "--channel ", From 0705225274be79e3c0da8138e7488ac53c18cc02 Mon Sep 17 00:00:00 2001 From: Frank Yang Date: Fri, 13 Mar 2026 16:40:53 +0800 Subject: [PATCH 03/18] docs: fix changelog credit for xhigh help (#44874) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4cf4122aa6..bb9fa83b516 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,7 +94,7 @@ Docs: https://docs.openclaw.ai - ACP/client final-message delivery: preserve terminal assistant text snapshots before resolving `end_turn`, so ACP clients no longer drop the last visible reply when the gateway sends the final message body on the terminal chat event. (#17615) Thanks @pjeby. - Telegram/Discord status reactions: show a temporary compacting reaction during auto-compaction pauses and restore thinking afterward so the bot no longer appears frozen while context is being compacted. (#35474) thanks @Cypherm. - Delivery/dedupe: trim completed direct-cron delivery cache correctly and keep mirrored transcript dedupe active even when transcript files contain malformed lines. (#44666) thanks @frankekn. -- CLI/thinking help: add the missing `xhigh` level hints to `openclaw cron add`, `openclaw cron edit`, and `openclaw agent` so the help text matches the levels already accepted at runtime. (#44819) Thanks @frankekn. +- CLI/thinking help: add the missing `xhigh` level hints to `openclaw cron add`, `openclaw cron edit`, and `openclaw agent` so the help text matches the levels already accepted at runtime. (#44819) Thanks @kiki830621. ## 2026.3.11 From 5ca0233db05fe9166e314bd7a139e3483fe1e18d Mon Sep 17 00:00:00 2001 From: Frank Yang Date: Fri, 13 Mar 2026 16:57:56 +0800 Subject: [PATCH 04/18] fix(agents): drop Anthropic thinking blocks on replay (#44843) * agents: drop Anthropic thinking blocks on replay * fix: extend anthropic replay sanitization openclaw#44429 thanks @jmcte * fix: extend anthropic replay sanitization openclaw#44843 thanks @jmcte * test: add bedrock replay sanitization coverage openclaw#44843 * test: cover anthropic provider drop-thinking hints openclaw#44843 --------- Co-authored-by: johnmteneyckjr --- CHANGELOG.md | 1 + ...ed-runner.sanitize-session-history.test.ts | 41 +++++++++++++++---- src/agents/pi-embedded-runner/run/attempt.ts | 7 ++-- src/agents/provider-capabilities.test.ts | 26 +++++++++++- src/agents/provider-capabilities.ts | 2 + src/agents/transcript-policy.ts | 6 +-- 6 files changed, 67 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb9fa83b516..5483519af2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,7 @@ Docs: https://docs.openclaw.ai - Telegram/Discord status reactions: show a temporary compacting reaction during auto-compaction pauses and restore thinking afterward so the bot no longer appears frozen while context is being compacted. (#35474) thanks @Cypherm. - Delivery/dedupe: trim completed direct-cron delivery cache correctly and keep mirrored transcript dedupe active even when transcript files contain malformed lines. (#44666) thanks @frankekn. - CLI/thinking help: add the missing `xhigh` level hints to `openclaw cron add`, `openclaw cron edit`, and `openclaw agent` so the help text matches the levels already accepted at runtime. (#44819) Thanks @kiki830621. +- Agents/Anthropic replay: drop replayed assistant thinking blocks for native Anthropic and Bedrock Claude providers so persisted follow-up turns no longer fail on stored thinking blocks. (#44843) Thanks @jmcte. ## 2026.3.11 diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts index 57639c8046e..2a71e0c95a3 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts +++ b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts @@ -52,6 +52,21 @@ describe("sanitizeSessionHistory", () => { sessionId: TEST_SESSION_ID, }); + const sanitizeAnthropicHistory = async (params: { + messages: AgentMessage[]; + provider?: string; + modelApi?: string; + modelId?: string; + }) => + sanitizeSessionHistory({ + messages: params.messages, + modelApi: params.modelApi ?? "anthropic-messages", + provider: params.provider ?? "anthropic", + modelId: params.modelId ?? "claude-opus-4-6", + sessionManager: makeMockSessionManager(), + sessionId: TEST_SESSION_ID, + }); + const getAssistantMessage = (messages: AgentMessage[]) => { expect(messages[1]?.role).toBe("assistant"); return messages[1] as Extract; @@ -760,22 +775,30 @@ describe("sanitizeSessionHistory", () => { expect(types).not.toContain("thinking"); }); - it("does not drop thinking blocks for non-copilot providers", async () => { + it("drops assistant thinking blocks for anthropic replay", async () => { setNonGoogleModelApi(); const messages = makeThinkingAndTextAssistantMessages(); - const result = await sanitizeSessionHistory({ + const result = await sanitizeAnthropicHistory({ messages }); + + const assistant = getAssistantMessage(result); + expect(assistant.content).toEqual([{ type: "text", text: "hi" }]); + }); + + it("drops assistant thinking blocks for amazon-bedrock replay", async () => { + setNonGoogleModelApi(); + + const messages = makeThinkingAndTextAssistantMessages(); + + const result = await sanitizeAnthropicHistory({ messages, - modelApi: "anthropic-messages", - provider: "anthropic", - modelId: "claude-opus-4-6", - sessionManager: makeMockSessionManager(), - sessionId: TEST_SESSION_ID, + provider: "amazon-bedrock", + modelApi: "bedrock-converse-stream", }); - const types = getAssistantContentTypes(result); - expect(types).toContain("thinking"); + const assistant = getAssistantMessage(result); + expect(assistant.content).toEqual([{ type: "text", text: "hi" }]); }); it("does not drop thinking blocks for non-claude copilot models", async () => { diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 3457fdf0161..274ef0ef865 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -1947,9 +1947,10 @@ export async function runEmbeddedAttempt( activeSession.agent.streamFn = cacheTrace.wrapStreamFn(activeSession.agent.streamFn); } - // Copilot/Claude can reject persisted `thinking` blocks (e.g. thinkingSignature:"reasoning_text") - // on *any* follow-up provider call (including tool continuations). Wrap the stream function - // so every outbound request sees sanitized messages. + // Anthropic Claude endpoints can reject replayed `thinking` blocks + // (e.g. thinkingSignature:"reasoning_text") on any follow-up provider + // call, including tool continuations. Wrap the stream function so every + // outbound request sees sanitized messages. if (transcriptPolicy.dropThinkingBlocks) { const inner = activeSession.agent.streamFn; activeSession.agent.streamFn = (model, context, options) => { diff --git a/src/agents/provider-capabilities.test.ts b/src/agents/provider-capabilities.test.ts index 90d2b52ff5a..ef59f025de8 100644 --- a/src/agents/provider-capabilities.test.ts +++ b/src/agents/provider-capabilities.test.ts @@ -22,7 +22,19 @@ describe("resolveProviderCapabilities", () => { transcriptToolCallIdMode: "default", transcriptToolCallIdModelHints: [], geminiThoughtSignatureModelHints: [], - dropThinkingBlockModelHints: [], + dropThinkingBlockModelHints: ["claude"], + }); + expect(resolveProviderCapabilities("amazon-bedrock")).toEqual({ + anthropicToolSchemaMode: "native", + anthropicToolChoiceMode: "native", + providerFamily: "anthropic", + preserveAnthropicThinkingSignatures: true, + openAiCompatTurnValidation: true, + geminiThoughtSignatureSanitization: false, + transcriptToolCallIdMode: "default", + transcriptToolCallIdModelHints: [], + geminiThoughtSignatureModelHints: [], + dropThinkingBlockModelHints: ["claude"], }); }); @@ -82,6 +94,18 @@ describe("resolveProviderCapabilities", () => { it("tracks provider families and model-specific transcript quirks in the registry", () => { expect(isOpenAiProviderFamily("openai")).toBe(true); expect(isAnthropicProviderFamily("amazon-bedrock")).toBe(true); + expect( + shouldDropThinkingBlocksForModel({ + provider: "anthropic", + modelId: "claude-opus-4-6", + }), + ).toBe(true); + expect( + shouldDropThinkingBlocksForModel({ + provider: "amazon-bedrock", + modelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", + }), + ).toBe(true); expect( shouldDropThinkingBlocksForModel({ provider: "github-copilot", diff --git a/src/agents/provider-capabilities.ts b/src/agents/provider-capabilities.ts index 27aadbcd7d3..f443fac4d11 100644 --- a/src/agents/provider-capabilities.ts +++ b/src/agents/provider-capabilities.ts @@ -29,9 +29,11 @@ const DEFAULT_PROVIDER_CAPABILITIES: ProviderCapabilities = { const PROVIDER_CAPABILITIES: Record> = { anthropic: { providerFamily: "anthropic", + dropThinkingBlockModelHints: ["claude"], }, "amazon-bedrock": { providerFamily: "anthropic", + dropThinkingBlockModelHints: ["claude"], }, // kimi-coding natively supports Anthropic tool framing (input_schema); // converting to OpenAI format causes XML text fallback instead of tool_use blocks. diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index d6d9ec5916a..46795bad1bc 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -80,9 +80,9 @@ export function resolveTranscriptPolicy(params: { }); const requiresOpenAiCompatibleToolIdSanitization = params.modelApi === "openai-completions"; - // GitHub Copilot's Claude endpoints can reject persisted `thinking` blocks with - // non-binary/non-base64 signatures (e.g. thinkingSignature: "reasoning_text"). - // Drop these blocks at send-time to keep sessions usable. + // Anthropic Claude endpoints can reject replayed `thinking` blocks unless the + // original signatures are preserved byte-for-byte. Drop them at send-time to + // keep persisted sessions usable across follow-up turns. const dropThinkingBlocks = shouldDropThinkingBlocksForModel({ provider, modelId }); const needsNonImageSanitize = From e986aa175f48ef9582c976abe62a97d7fc04b7dc Mon Sep 17 00:00:00 2001 From: Jealous Date: Fri, 13 Mar 2026 01:58:33 -0700 Subject: [PATCH 05/18] =?UTF-8?q?docs:=20fix=20session=20key=20:dm:=20?= =?UTF-8?q?=E2=86=92=20:direct=20(#26506)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/channels/googlechat.md | 2 +- docs/cli/sessions.md | 2 +- docs/concepts/session.md | 6 +++--- docs/gateway/configuration.md | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/channels/googlechat.md b/docs/channels/googlechat.md index 09693589af7..bc9d435f4de 100644 --- a/docs/channels/googlechat.md +++ b/docs/channels/googlechat.md @@ -145,7 +145,7 @@ Configure your tunnel's ingress rules to only route the webhook path: - `audienceType: "app-url"` → audience is your HTTPS webhook URL. - `audienceType: "project-number"` → audience is the Cloud project number. 3. Messages are routed by space: - - DMs use session key `agent::googlechat:dm:`. + - DMs use session key `agent::googlechat:direct:`. - Spaces use session key `agent::googlechat:group:`. 4. DM access is pairing by default. Unknown senders receive a pairing code; approve with: - `openclaw pairing approve googlechat ` diff --git a/docs/cli/sessions.md b/docs/cli/sessions.md index b8c1ebfac6f..6ea2df094f0 100644 --- a/docs/cli/sessions.md +++ b/docs/cli/sessions.md @@ -60,7 +60,7 @@ openclaw sessions cleanup --dry-run openclaw sessions cleanup --agent work --dry-run openclaw sessions cleanup --all-agents --dry-run openclaw sessions cleanup --enforce -openclaw sessions cleanup --enforce --active-key "agent:main:telegram:dm:123" +openclaw sessions cleanup --enforce --active-key "agent:main:telegram:direct:123" openclaw sessions cleanup --json ``` diff --git a/docs/concepts/session.md b/docs/concepts/session.md index 5c60655858e..2a58c15cb4d 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -191,9 +191,9 @@ the workspace is writable. See [Memory](/concepts/memory) and - Direct chats follow `session.dmScope` (default `main`). - `main`: `agent::` (continuity across devices/channels). - Multiple phone numbers and channels can map to the same agent main key; they act as transports into one conversation. - - `per-peer`: `agent::dm:`. - - `per-channel-peer`: `agent:::dm:`. - - `per-account-channel-peer`: `agent::::dm:` (accountId defaults to `default`). + - `per-peer`: `agent::direct:`. + - `per-channel-peer`: `agent:::direct:`. + - `per-account-channel-peer`: `agent::::direct:` (accountId defaults to `default`). - If `session.identityLinks` matches a provider-prefixed peer id (for example `telegram:123`), the canonical key replaces `` so the same person shares a session across channels. - Group chats isolate state: `agent:::group:` (rooms/channels use `agent:::channel:`). - Telegram forum topics append `:topic:` to the group id for isolation. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index d7e5f5c25d3..0f1dd65cbbc 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -472,7 +472,7 @@ Control-plane write RPCs (`config.apply`, `config.patch`, `update.run`) are rate openclaw gateway call config.apply --params '{ "raw": "{ agents: { defaults: { workspace: \"~/.openclaw/workspace\" } } }", "baseHash": "", - "sessionKey": "agent:main:whatsapp:dm:+15555550123" + "sessionKey": "agent:main:whatsapp:direct:+15555550123" }' ``` From beff0cf02c6cedaeb2360bd854d5652b6a413f70 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 13 Mar 2026 14:25:34 +0530 Subject: [PATCH 06/18] feat(android): redesign Connect tab with unified status cards Merge endpoint and status into a single grouped card with icons. Split connect/disconnect into context-aware buttons. --- .../ai/openclaw/app/ui/ConnectTabScreen.kt | 194 +++++++++++------- 1 file changed, 122 insertions(+), 72 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt index 5391ff78fe7..448336d8e41 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -18,8 +19,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.PowerSettingsNew import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -128,96 +132,142 @@ fun ConnectTabScreen(viewModel: MainViewModel) { verticalArrangement = Arrangement.spacedBy(14.dp), ) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text("Connection Control", style = mobileCaption1.copy(fontWeight = FontWeight.Bold), color = mobileAccent) Text("Gateway Connection", style = mobileTitle1, color = mobileText) Text( - "One primary action. Open advanced controls only when needed.", + if (isConnected) "Your gateway is active and ready." else "Connect to your gateway to get started.", style = mobileCallout, color = mobileTextSecondary, ) } + // Status cards in a unified card group Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(14.dp), - color = mobileSurface, + color = Color.White, border = BorderStroke(1.dp, mobileBorder), ) { - Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text("Active endpoint", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) - Text(activeEndpoint, style = mobileBody.copy(fontFamily = FontFamily.Monospace), color = mobileText) + Column { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Surface( + shape = RoundedCornerShape(10.dp), + color = mobileAccentSoft, + ) { + Icon( + imageVector = Icons.Default.Link, + contentDescription = null, + modifier = Modifier.padding(8.dp).size(18.dp), + tint = mobileAccent, + ) + } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Endpoint", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + Text(activeEndpoint, style = mobileBody.copy(fontFamily = FontFamily.Monospace), color = mobileText) + } + } + HorizontalDivider(color = mobileBorder) + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Surface( + shape = RoundedCornerShape(10.dp), + color = if (isConnected) mobileSuccessSoft else mobileSurface, + ) { + Icon( + imageVector = Icons.Default.Cloud, + contentDescription = null, + modifier = Modifier.padding(8.dp).size(18.dp), + tint = if (isConnected) mobileSuccess else mobileTextTertiary, + ) + } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Status", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + Text(statusText, style = mobileBody, color = if (isConnected) mobileSuccess else mobileText) + } + } } } - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(14.dp), - color = mobileSurface, - border = BorderStroke(1.dp, mobileBorder), - ) { - Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text("Gateway state", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) - Text(statusText, style = mobileBody, color = mobileText) - } - } - - Button( - onClick = { - if (isConnected) { + if (isConnected) { + // Outlined secondary button when connected — don't scream "danger" + Button( + onClick = { viewModel.disconnect() validationText = null - return@Button - } - if (statusText.contains("operator offline", ignoreCase = true)) { + }, + modifier = Modifier.fillMaxWidth().height(48.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = mobileDanger, + ), + border = BorderStroke(1.dp, mobileDanger.copy(alpha = 0.4f)), + ) { + Icon(Icons.Default.PowerSettingsNew, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("Disconnect", style = mobileHeadline.copy(fontWeight = FontWeight.SemiBold)) + } + } else { + Button( + onClick = { + if (statusText.contains("operator offline", ignoreCase = true)) { + validationText = null + viewModel.refreshGatewayConnection() + return@Button + } + + val config = + resolveGatewayConnectConfig( + useSetupCode = inputMode == ConnectInputMode.SetupCode, + setupCode = setupCode, + manualHost = manualHostInput, + manualPort = manualPortInput, + manualTls = manualTlsInput, + fallbackToken = gatewayToken, + fallbackPassword = passwordInput, + ) + + if (config == null) { + validationText = + if (inputMode == ConnectInputMode.SetupCode) { + "Paste a valid setup code to connect." + } else { + "Enter a valid manual host and port to connect." + } + return@Button + } + validationText = null - viewModel.refreshGatewayConnection() - return@Button - } - - val config = - resolveGatewayConnectConfig( - useSetupCode = inputMode == ConnectInputMode.SetupCode, - setupCode = setupCode, - manualHost = manualHostInput, - manualPort = manualPortInput, - manualTls = manualTlsInput, - fallbackToken = gatewayToken, - fallbackPassword = passwordInput, - ) - - if (config == null) { - validationText = - if (inputMode == ConnectInputMode.SetupCode) { - "Paste a valid setup code to connect." - } else { - "Enter a valid manual host and port to connect." - } - return@Button - } - - validationText = null - viewModel.setManualEnabled(true) - viewModel.setManualHost(config.host) - viewModel.setManualPort(config.port) - viewModel.setManualTls(config.tls) - viewModel.setGatewayBootstrapToken(config.bootstrapToken) - if (config.token.isNotBlank()) { - viewModel.setGatewayToken(config.token) - } else if (config.bootstrapToken.isNotBlank()) { - viewModel.setGatewayToken("") - } - viewModel.setGatewayPassword(config.password) - viewModel.connectManual() - }, - modifier = Modifier.fillMaxWidth().height(52.dp), - shape = RoundedCornerShape(14.dp), - colors = - ButtonDefaults.buttonColors( - containerColor = if (isConnected) mobileDanger else mobileAccent, - contentColor = Color.White, - ), - ) { - Text(primaryLabel, style = mobileHeadline.copy(fontWeight = FontWeight.Bold)) + viewModel.setManualEnabled(true) + viewModel.setManualHost(config.host) + viewModel.setManualPort(config.port) + viewModel.setManualTls(config.tls) + viewModel.setGatewayBootstrapToken(config.bootstrapToken) + if (config.token.isNotBlank()) { + viewModel.setGatewayToken(config.token) + } else if (config.bootstrapToken.isNotBlank()) { + viewModel.setGatewayToken("") + } + viewModel.setGatewayPassword(config.password) + viewModel.connectManual() + }, + modifier = Modifier.fillMaxWidth().height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = mobileAccent, + contentColor = Color.White, + ), + ) { + Text("Connect Gateway", style = mobileHeadline.copy(fontWeight = FontWeight.Bold)) + } } Surface( From 720b9d2c45609a166e0533627497a5ab0e7ccc71 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 13 Mar 2026 14:25:39 +0530 Subject: [PATCH 07/18] feat(android): add speaker label and status pill to Voice tab Add text label under speaker toggle, balance layout with matching spacer column, and wrap status text in a colored pill. --- .../java/ai/openclaw/app/ui/VoiceTabScreen.kt | 66 +++++++++++++------ 1 file changed, 46 insertions(+), 20 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceTabScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceTabScreen.kt index be66f42bef3..f8e17a17c6b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceTabScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceTabScreen.kt @@ -17,10 +17,12 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding @@ -212,19 +214,26 @@ fun VoiceTabScreen(viewModel: MainViewModel) { verticalAlignment = Alignment.CenterVertically, ) { // Speaker toggle - IconButton( - onClick = { viewModel.setSpeakerEnabled(!speakerEnabled) }, - modifier = Modifier.size(48.dp), - colors = - IconButtonDefaults.iconButtonColors( - containerColor = if (speakerEnabled) mobileSurface else mobileDangerSoft, - ), - ) { - Icon( - imageVector = if (speakerEnabled) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff, - contentDescription = if (speakerEnabled) "Mute speaker" else "Unmute speaker", - modifier = Modifier.size(22.dp), - tint = if (speakerEnabled) mobileTextSecondary else mobileDanger, + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) { + IconButton( + onClick = { viewModel.setSpeakerEnabled(!speakerEnabled) }, + modifier = Modifier.size(48.dp), + colors = + IconButtonDefaults.iconButtonColors( + containerColor = if (speakerEnabled) mobileSurface else mobileDangerSoft, + ), + ) { + Icon( + imageVector = if (speakerEnabled) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff, + contentDescription = if (speakerEnabled) "Mute speaker" else "Unmute speaker", + modifier = Modifier.size(22.dp), + tint = if (speakerEnabled) mobileTextSecondary else mobileDanger, + ) + } + Text( + if (speakerEnabled) "Speaker" else "Muted", + style = mobileCaption2, + color = if (speakerEnabled) mobileTextTertiary else mobileDanger, ) } @@ -278,8 +287,12 @@ fun VoiceTabScreen(viewModel: MainViewModel) { } } - // Invisible spacer to balance the row (same size as speaker button) - Box(modifier = Modifier.size(48.dp)) + // Invisible spacer to balance the row (matches speaker column width) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box(modifier = Modifier.size(48.dp)) + Spacer(modifier = Modifier.height(4.dp)) + Text("", style = mobileCaption2) + } } // Status + labels @@ -292,11 +305,24 @@ fun VoiceTabScreen(viewModel: MainViewModel) { micEnabled -> "Listening" else -> "Mic off" } - Text( - "$gatewayStatus · $stateText", - style = mobileCaption1, - color = mobileTextSecondary, - ) + val stateColor = + when { + micEnabled -> mobileSuccess + micIsSending -> mobileAccent + else -> mobileTextSecondary + } + Surface( + shape = RoundedCornerShape(999.dp), + color = if (micEnabled) mobileSuccessSoft else mobileSurface, + border = BorderStroke(1.dp, if (micEnabled) mobileSuccess.copy(alpha = 0.3f) else mobileBorder), + ) { + Text( + "$gatewayStatus · $stateText", + style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), + color = stateColor, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp), + ) + } if (!hasMicPermission) { val showRationale = From c761b5b8a8b2ba07be75e62ebb839f671020bde8 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 13 Mar 2026 14:25:45 +0530 Subject: [PATCH 08/18] feat(android): compact chat composer layout Remove MESSAGE label and divider, let text field auto-size instead of fixed 92dp, and merge Detail/Attach into the bottom action row. --- .../ai/openclaw/app/ui/chat/ChatComposer.kt | 128 ++++++++---------- 1 file changed, 57 insertions(+), 71 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt index 9601febfa31..25fafe95073 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt @@ -26,7 +26,6 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -78,65 +77,15 @@ fun ChatComposer( val sendBusy = pendingRunCount > 0 Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Box(modifier = Modifier.weight(1f)) { - Surface( - onClick = { showThinkingMenu = true }, - shape = RoundedCornerShape(14.dp), - color = mobileAccentSoft, - border = BorderStroke(1.dp, mobileBorderStrong), - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = "Thinking: ${thinkingLabel(thinkingLevel)}", - style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), - color = mobileText, - ) - Icon(Icons.Default.ArrowDropDown, contentDescription = "Select thinking level", tint = mobileTextSecondary) - } - } - - DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) { - ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } - ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } - ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } - ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } - } - } - - SecondaryActionButton( - label = "Attach", - icon = Icons.Default.AttachFile, - enabled = true, - onClick = onPickImages, - ) - } - if (attachments.isNotEmpty()) { AttachmentsStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment) } - HorizontalDivider(color = mobileBorder) - - Text( - text = "MESSAGE", - style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 0.9.sp), - color = mobileTextSecondary, - ) - OutlinedTextField( value = input, onValueChange = { input = it }, - modifier = Modifier.fillMaxWidth().height(92.dp), - placeholder = { Text("Type a message", style = mobileBodyStyle(), color = mobileTextTertiary) }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Type a message…", style = mobileBodyStyle(), color = mobileTextTertiary) }, minLines = 2, maxLines = 5, textStyle = mobileBodyStyle().copy(color = mobileText), @@ -155,26 +104,62 @@ fun ChatComposer( Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - SecondaryActionButton( - label = "Refresh", - icon = Icons.Default.Refresh, - enabled = true, - compact = true, - onClick = onRefresh, - ) + Box { + Surface( + onClick = { showThinkingMenu = true }, + shape = RoundedCornerShape(14.dp), + color = Color.White, + border = BorderStroke(1.dp, mobileBorderStrong), + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = thinkingLabel(thinkingLevel), + style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), + color = mobileTextSecondary, + ) + Icon(Icons.Default.ArrowDropDown, contentDescription = "Select thinking level", modifier = Modifier.size(18.dp), tint = mobileTextTertiary) + } + } - SecondaryActionButton( - label = "Abort", - icon = Icons.Default.Stop, - enabled = pendingRunCount > 0, - compact = true, - onClick = onAbort, - ) + DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) { + ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + } } + SecondaryActionButton( + label = "Attach", + icon = Icons.Default.AttachFile, + enabled = true, + compact = true, + onClick = onPickImages, + ) + + SecondaryActionButton( + label = "Refresh", + icon = Icons.Default.Refresh, + enabled = true, + compact = true, + onClick = onRefresh, + ) + + SecondaryActionButton( + label = "Abort", + icon = Icons.Default.Stop, + enabled = pendingRunCount > 0, + compact = true, + onClick = onAbort, + ) + + Spacer(modifier = Modifier.weight(1f)) + Button( onClick = { val text = input @@ -182,8 +167,9 @@ fun ChatComposer( onSend(text) }, enabled = canSend, - modifier = Modifier.weight(1f).height(48.dp), + modifier = Modifier.height(44.dp), shape = RoundedCornerShape(14.dp), + contentPadding = PaddingValues(horizontal = 20.dp), colors = ButtonDefaults.buttonColors( containerColor = mobileAccent, @@ -198,7 +184,7 @@ fun ChatComposer( } else { Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(16.dp)) } - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(6.dp)) Text( text = "Send", style = mobileHeadline.copy(fontWeight = FontWeight.Bold), From 8b0e16a1c8270703db786cc36ce6f0247da19908 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 13 Mar 2026 14:25:55 +0530 Subject: [PATCH 09/18] feat(android): soften chat role labels and deduplicate session header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename role labels to You/OpenClaw/System, update streaming label to OpenClaw · Live, and remove the redundant SESSION row + Connected pill since the top bar and chip row already convey both. --- .../openclaw/app/ui/chat/ChatMessageViews.kt | 10 +-- .../openclaw/app/ui/chat/ChatSheetContent.kt | 84 ++++--------------- 2 files changed, 23 insertions(+), 71 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt index 9d08352a3f0..f61195f43fb 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt @@ -151,7 +151,7 @@ fun ChatPendingToolsBubble(toolCalls: List) { ChatBubbleContainer( style = bubbleStyle("assistant"), - roleLabel = "TOOLS", + roleLabel = "Tools", ) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text("Running tools...", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) @@ -188,7 +188,7 @@ fun ChatPendingToolsBubble(toolCalls: List) { fun ChatStreamingAssistantBubble(text: String) { ChatBubbleContainer( style = bubbleStyle("assistant").copy(borderColor = mobileAccent), - roleLabel = "ASSISTANT · LIVE", + roleLabel = "OpenClaw · Live", ) { ChatMarkdown(text = text, textColor = mobileText) } @@ -224,9 +224,9 @@ private fun bubbleStyle(role: String): ChatBubbleStyle { private fun roleLabel(role: String): String { return when (role) { - "user" -> "USER" - "system" -> "SYSTEM" - else -> "ASSISTANT" + "user" -> "You" + "system" -> "System" + else -> "OpenClaw" } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt index 2c09f4488b0..e20b57ac3f5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt @@ -42,12 +42,8 @@ import ai.openclaw.app.ui.mobileCallout import ai.openclaw.app.ui.mobileCaption1 import ai.openclaw.app.ui.mobileCaption2 import ai.openclaw.app.ui.mobileDanger -import ai.openclaw.app.ui.mobileSuccess -import ai.openclaw.app.ui.mobileSuccessSoft import ai.openclaw.app.ui.mobileText import ai.openclaw.app.ui.mobileTextSecondary -import ai.openclaw.app.ui.mobileWarning -import ai.openclaw.app.ui.mobileWarningSoft import java.io.ByteArrayOutputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -106,7 +102,6 @@ fun ChatSheetContent(viewModel: MainViewModel) { sessionKey = sessionKey, sessions = sessions, mainSessionKey = mainSessionKey, - healthOk = healthOk, onSelectSession = { key -> viewModel.switchChatSession(key) }, ) @@ -160,77 +155,34 @@ private fun ChatThreadSelector( sessionKey: String, sessions: List, mainSessionKey: String, - healthOk: Boolean, onSelectSession: (String) -> Unit, ) { val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey) - val currentSessionLabel = - friendlySessionName(sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey) - Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, - ) { - Text( - text = "SESSION", - style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 0.8.sp), - color = mobileTextSecondary, - ) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (entry in sessionOptions) { + val active = entry.key == sessionKey + Surface( + onClick = { onSelectSession(entry.key) }, + shape = RoundedCornerShape(14.dp), + color = if (active) mobileAccent else Color.White, + border = BorderStroke(1.dp, if (active) Color(0xFF154CAD) else mobileBorderStrong), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { Text( - text = currentSessionLabel, - style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), - color = mobileText, + text = friendlySessionName(entry.displayName ?: entry.key), + style = mobileCaption1.copy(fontWeight = if (active) FontWeight.Bold else FontWeight.SemiBold), + color = if (active) Color.White else mobileText, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), ) - ChatConnectionPill(healthOk = healthOk) } } - - Row( - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - for (entry in sessionOptions) { - val active = entry.key == sessionKey - Surface( - onClick = { onSelectSession(entry.key) }, - shape = RoundedCornerShape(14.dp), - color = if (active) mobileAccent else Color.White, - border = BorderStroke(1.dp, if (active) Color(0xFF154CAD) else mobileBorderStrong), - tonalElevation = 0.dp, - shadowElevation = 0.dp, - ) { - Text( - text = friendlySessionName(entry.displayName ?: entry.key), - style = mobileCaption1.copy(fontWeight = if (active) FontWeight.Bold else FontWeight.SemiBold), - color = if (active) Color.White else mobileText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - ) - } - } - } - } -} - -@Composable -private fun ChatConnectionPill(healthOk: Boolean) { - Surface( - shape = RoundedCornerShape(999.dp), - color = if (healthOk) mobileSuccessSoft else mobileWarningSoft, - border = BorderStroke(1.dp, if (healthOk) mobileSuccess.copy(alpha = 0.35f) else mobileWarning.copy(alpha = 0.35f)), - ) { - Text( - text = if (healthOk) "Connected" else "Offline", - style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), - color = if (healthOk) mobileSuccess else mobileWarning, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), - ) } } From c04544891d280ae9de13a6be569e65a2c9ddb622 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 13 Mar 2026 14:26:02 +0530 Subject: [PATCH 10/18] feat(android): consolidate Settings into grouped card sections Remove header bloat, merge Node info into a single Device card, group permissions into Media/Notifications/Data Access cards with internal dividers, and combine Screen+Debug into Preferences. Sections reduced from 9 to 6. --- .../java/ai/openclaw/app/ui/SettingsSheet.kt | 655 ++++++++---------- 1 file changed, 274 insertions(+), 381 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt index a3f7868fa90..c7cdf8289ff 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt @@ -345,179 +345,90 @@ fun SettingsSheet(viewModel: MainViewModel) { contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - item { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text( - "SETTINGS", - style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), - color = mobileAccent, - ) - Text("Device Configuration", style = mobileTitle2, color = mobileText) - Text( - "Manage capabilities, permissions, and diagnostics.", - style = mobileCallout, - color = mobileTextSecondary, - ) - } - } - item { HorizontalDivider(color = mobileBorder) } - - // Order parity: Node → Voice → Camera → Messaging → Location → Screen. + // ── Node ── item { Text( - "NODE", - style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), - color = mobileAccent, - ) - } - item { - OutlinedTextField( - value = displayName, - onValueChange = viewModel::setDisplayName, - label = { Text("Name", style = mobileCaption1, color = mobileTextSecondary) }, - modifier = Modifier.fillMaxWidth(), - textStyle = mobileBody.copy(color = mobileText), - colors = settingsTextFieldColors(), - ) - } - item { Text("Instance ID: $instanceId", style = mobileCallout.copy(fontFamily = FontFamily.Monospace), color = mobileTextSecondary) } - item { Text("Device: $deviceModel", style = mobileCallout, color = mobileTextSecondary) } - item { Text("Version: $appVersion", style = mobileCallout, color = mobileTextSecondary) } - - item { HorizontalDivider(color = mobileBorder) } - - // Voice - item { - Text( - "VOICE", + "DEVICE", style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), color = mobileAccent, ) } item { - ListItem( - modifier = Modifier.settingsRowModifier(), - colors = listItemColors, - headlineContent = { Text("Microphone permission", style = mobileHeadline) }, - supportingContent = { + Column(modifier = Modifier.settingsRowModifier()) { + OutlinedTextField( + value = displayName, + onValueChange = viewModel::setDisplayName, + label = { Text("Name", style = mobileCaption1, color = mobileTextSecondary) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp), + textStyle = mobileBody.copy(color = mobileText), + colors = settingsTextFieldColors(), + ) + HorizontalDivider(color = mobileBorder) + Column( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text("$deviceModel · $appVersion", style = mobileCallout, color = mobileTextSecondary) Text( - if (micPermissionGranted) { - "Granted. Use the Voice tab mic button to capture transcript while the app is open." - } else { - "Required for foreground Voice tab transcription." - }, - style = mobileCallout, + instanceId.take(8) + "…", + style = mobileCaption1.copy(fontFamily = FontFamily.Monospace), + color = mobileTextTertiary, ) - }, - trailingContent = { - Button( - onClick = { - if (micPermissionGranted) { - openAppSettings(context) - } else { - audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - } - }, - colors = settingsPrimaryButtonColors(), - shape = RoundedCornerShape(14.dp), - ) { - Text( - if (micPermissionGranted) "Manage" else "Grant", - style = mobileCallout.copy(fontWeight = FontWeight.Bold), - ) - } - }, - ) - } - item { - Text( - "Voice wake and talk modes were removed. Voice now uses one mic on/off flow in the Voice tab while the app is open.", - style = mobileCallout, - color = mobileTextSecondary, - ) - } - - item { HorizontalDivider(color = mobileBorder) } - - // Camera - item { - Text( - "CAMERA", - style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), - color = mobileAccent, - ) - } - item { - ListItem( - modifier = Modifier.settingsRowModifier(), - colors = listItemColors, - headlineContent = { Text("Allow Camera", style = mobileHeadline) }, - supportingContent = { Text("Allows the gateway to request photos or short video clips (foreground only).", style = mobileCallout) }, - trailingContent = { Switch(checked = cameraEnabled, onCheckedChange = ::setCameraEnabledChecked) }, - ) - } - item { - Text( - "Tip: grant Microphone permission for video clips with audio.", - style = mobileCallout, - color = mobileTextSecondary, - ) - } - - item { HorizontalDivider(color = mobileBorder) } - - // Messaging - item { - Text( - "MESSAGING", - style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), - color = mobileAccent, - ) - } - item { - val buttonLabel = - when { - !smsPermissionAvailable -> "Unavailable" - smsPermissionGranted -> "Manage" - else -> "Grant" + } } - ListItem( - modifier = Modifier.settingsRowModifier(), - colors = listItemColors, - headlineContent = { Text("SMS Permission", style = mobileHeadline) }, - supportingContent = { - Text( - if (smsPermissionAvailable) { - "Allow the gateway to send SMS from this device." - } else { - "SMS requires a device with telephony hardware." + } + + // ── Media ── + item { + Text( + "MEDIA", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + } + item { + Column(modifier = Modifier.settingsRowModifier()) { + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Microphone", style = mobileHeadline) }, + supportingContent = { + Text( + if (micPermissionGranted) "Granted" else "Required for voice transcription.", + style = mobileCallout, + ) }, - style = mobileCallout, - ) - }, - trailingContent = { - Button( - onClick = { - if (!smsPermissionAvailable) return@Button - if (smsPermissionGranted) { - openAppSettings(context) - } else { - smsPermissionLauncher.launch(Manifest.permission.SEND_SMS) + trailingContent = { + Button( + onClick = { + if (micPermissionGranted) { + openAppSettings(context) + } else { + audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + }, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), + ) { + Text( + if (micPermissionGranted) "Manage" else "Grant", + style = mobileCallout.copy(fontWeight = FontWeight.Bold), + ) } }, - enabled = smsPermissionAvailable, - colors = settingsPrimaryButtonColors(), - shape = RoundedCornerShape(14.dp), - ) { - Text(buttonLabel, style = mobileCallout.copy(fontWeight = FontWeight.Bold)) - } - }, - ) - } + ) + HorizontalDivider(color = mobileBorder) + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Camera", style = mobileHeadline) }, + supportingContent = { Text("Photos and video clips (foreground only).", style = mobileCallout) }, + trailingContent = { Switch(checked = cameraEnabled, onCheckedChange = ::setCameraEnabledChecked) }, + ) + } + } - item { HorizontalDivider(color = mobileBorder) } - - // Notifications + // ── Notifications & Messaging ── item { Text( "NOTIFICATIONS", @@ -526,67 +437,87 @@ fun SettingsSheet(viewModel: MainViewModel) { ) } item { - val buttonLabel = - if (notificationsPermissionGranted) { - "Manage" - } else { - "Grant" - } - ListItem( - modifier = Modifier.settingsRowModifier(), - colors = listItemColors, - headlineContent = { Text("System Notifications", style = mobileHeadline) }, - supportingContent = { - Text( - "Required for `system.notify` and Android foreground service alerts.", - style = mobileCallout, - ) - }, - trailingContent = { - Button( - onClick = { - if (notificationsPermissionGranted || Build.VERSION.SDK_INT < 33) { - openAppSettings(context) - } else { - notificationsPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + Column(modifier = Modifier.settingsRowModifier()) { + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("System Notifications", style = mobileHeadline) }, + supportingContent = { + Text("Alerts and foreground service.", style = mobileCallout) + }, + trailingContent = { + Button( + onClick = { + if (notificationsPermissionGranted || Build.VERSION.SDK_INT < 33) { + openAppSettings(context) + } else { + notificationsPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + }, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), + ) { + Text( + if (notificationsPermissionGranted) "Manage" else "Grant", + style = mobileCallout.copy(fontWeight = FontWeight.Bold), + ) + } + }, + ) + HorizontalDivider(color = mobileBorder) + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Notification Listener", style = mobileHeadline) }, + supportingContent = { + Text("Read and interact with notifications.", style = mobileCallout) + }, + trailingContent = { + Button( + onClick = { openNotificationListenerSettings(context) }, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), + ) { + Text( + if (notificationListenerEnabled) "Manage" else "Enable", + style = mobileCallout.copy(fontWeight = FontWeight.Bold), + ) + } + }, + ) + if (smsPermissionAvailable) { + HorizontalDivider(color = mobileBorder) + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("SMS", style = mobileHeadline) }, + supportingContent = { + Text("Send SMS from this device.", style = mobileCallout) + }, + trailingContent = { + Button( + onClick = { + if (smsPermissionGranted) { + openAppSettings(context) + } else { + smsPermissionLauncher.launch(Manifest.permission.SEND_SMS) + } + }, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), + ) { + Text( + if (smsPermissionGranted) "Manage" else "Grant", + style = mobileCallout.copy(fontWeight = FontWeight.Bold), + ) } }, - colors = settingsPrimaryButtonColors(), - shape = RoundedCornerShape(14.dp), - ) { - Text(buttonLabel, style = mobileCallout.copy(fontWeight = FontWeight.Bold)) - } - }, - ) - } - item { - ListItem( - modifier = Modifier.settingsRowModifier(), - colors = listItemColors, - headlineContent = { Text("Notification Listener Access", style = mobileHeadline) }, - supportingContent = { - Text( - "Required for `notifications.list` and `notifications.actions`.", - style = mobileCallout, ) - }, - trailingContent = { - Button( - onClick = { openNotificationListenerSettings(context) }, - colors = settingsPrimaryButtonColors(), - shape = RoundedCornerShape(14.dp), - ) { - Text( - if (notificationListenerEnabled) "Manage" else "Enable", - style = mobileCallout.copy(fontWeight = FontWeight.Bold), - ) - } - }, - ) + } + } } - item { HorizontalDivider(color = mobileBorder) } - // Data access + // ── Data Access ── item { Text( "DATA ACCESS", @@ -595,142 +526,115 @@ fun SettingsSheet(viewModel: MainViewModel) { ) } item { - ListItem( - modifier = Modifier.settingsRowModifier(), - colors = listItemColors, - headlineContent = { Text("Photos Permission", style = mobileHeadline) }, - supportingContent = { - Text( - "Required for `photos.latest`.", - style = mobileCallout, - ) - }, - trailingContent = { - Button( - onClick = { - if (photosPermissionGranted) { - openAppSettings(context) - } else { - photosPermissionLauncher.launch(photosPermission) + Column(modifier = Modifier.settingsRowModifier()) { + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Photos", style = mobileHeadline) }, + supportingContent = { Text("Access recent photos.", style = mobileCallout) }, + trailingContent = { + Button( + onClick = { + if (photosPermissionGranted) { + openAppSettings(context) + } else { + photosPermissionLauncher.launch(photosPermission) + } + }, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), + ) { + Text( + if (photosPermissionGranted) "Manage" else "Grant", + style = mobileCallout.copy(fontWeight = FontWeight.Bold), + ) + } + }, + ) + HorizontalDivider(color = mobileBorder) + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Contacts", style = mobileHeadline) }, + supportingContent = { Text("Search and add contacts.", style = mobileCallout) }, + trailingContent = { + Button( + onClick = { + if (contactsPermissionGranted) { + openAppSettings(context) + } else { + contactsPermissionLauncher.launch(arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)) + } + }, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), + ) { + Text( + if (contactsPermissionGranted) "Manage" else "Grant", + style = mobileCallout.copy(fontWeight = FontWeight.Bold), + ) + } + }, + ) + HorizontalDivider(color = mobileBorder) + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Calendar", style = mobileHeadline) }, + supportingContent = { Text("Read and create events.", style = mobileCallout) }, + trailingContent = { + Button( + onClick = { + if (calendarPermissionGranted) { + openAppSettings(context) + } else { + calendarPermissionLauncher.launch(arrayOf(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)) + } + }, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), + ) { + Text( + if (calendarPermissionGranted) "Manage" else "Grant", + style = mobileCallout.copy(fontWeight = FontWeight.Bold), + ) + } + }, + ) + if (motionAvailable) { + HorizontalDivider(color = mobileBorder) + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Motion", style = mobileHeadline) }, + supportingContent = { Text("Track steps and activity.", style = mobileCallout) }, + trailingContent = { + val motionButtonLabel = + when { + !motionPermissionRequired -> "Manage" + motionPermissionGranted -> "Manage" + else -> "Grant" + } + Button( + onClick = { + if (!motionPermissionRequired || motionPermissionGranted) { + openAppSettings(context) + } else { + motionPermissionLauncher.launch(Manifest.permission.ACTIVITY_RECOGNITION) + } + }, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), + ) { + Text(motionButtonLabel, style = mobileCallout.copy(fontWeight = FontWeight.Bold)) } }, - colors = settingsPrimaryButtonColors(), - shape = RoundedCornerShape(14.dp), - ) { - Text( - if (photosPermissionGranted) "Manage" else "Grant", - style = mobileCallout.copy(fontWeight = FontWeight.Bold), - ) - } - }, - ) - } - item { - ListItem( - modifier = Modifier.settingsRowModifier(), - colors = listItemColors, - headlineContent = { Text("Contacts Permission", style = mobileHeadline) }, - supportingContent = { - Text( - "Required for `contacts.search` and `contacts.add`.", - style = mobileCallout, ) - }, - trailingContent = { - Button( - onClick = { - if (contactsPermissionGranted) { - openAppSettings(context) - } else { - contactsPermissionLauncher.launch(arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)) - } - }, - colors = settingsPrimaryButtonColors(), - shape = RoundedCornerShape(14.dp), - ) { - Text( - if (contactsPermissionGranted) "Manage" else "Grant", - style = mobileCallout.copy(fontWeight = FontWeight.Bold), - ) - } - }, - ) - } - item { - ListItem( - modifier = Modifier.settingsRowModifier(), - colors = listItemColors, - headlineContent = { Text("Calendar Permission", style = mobileHeadline) }, - supportingContent = { - Text( - "Required for `calendar.events` and `calendar.add`.", - style = mobileCallout, - ) - }, - trailingContent = { - Button( - onClick = { - if (calendarPermissionGranted) { - openAppSettings(context) - } else { - calendarPermissionLauncher.launch(arrayOf(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)) - } - }, - colors = settingsPrimaryButtonColors(), - shape = RoundedCornerShape(14.dp), - ) { - Text( - if (calendarPermissionGranted) "Manage" else "Grant", - style = mobileCallout.copy(fontWeight = FontWeight.Bold), - ) - } - }, - ) - } - item { - val motionButtonLabel = - when { - !motionAvailable -> "Unavailable" - !motionPermissionRequired -> "Manage" - motionPermissionGranted -> "Manage" - else -> "Grant" } - ListItem( - modifier = Modifier.settingsRowModifier(), - colors = listItemColors, - headlineContent = { Text("Motion Permission", style = mobileHeadline) }, - supportingContent = { - Text( - if (!motionAvailable) { - "This device does not expose accelerometer or step-counter motion sensors." - } else { - "Required for `motion.activity` and `motion.pedometer`." - }, - style = mobileCallout, - ) - }, - trailingContent = { - Button( - onClick = { - if (!motionAvailable) return@Button - if (!motionPermissionRequired || motionPermissionGranted) { - openAppSettings(context) - } else { - motionPermissionLauncher.launch(Manifest.permission.ACTIVITY_RECOGNITION) - } - }, - enabled = motionAvailable, - colors = settingsPrimaryButtonColors(), - shape = RoundedCornerShape(14.dp), - ) { - Text(motionButtonLabel, style = mobileCallout.copy(fontWeight = FontWeight.Bold)) - } - }, - ) + } } - item { HorizontalDivider(color = mobileBorder) } - // Location + // ── Location ── item { Text( "LOCATION", @@ -739,7 +643,7 @@ fun SettingsSheet(viewModel: MainViewModel) { ) } item { - Column(modifier = Modifier.settingsRowModifier(), verticalArrangement = Arrangement.spacedBy(0.dp)) { + Column(modifier = Modifier.settingsRowModifier()) { ListItem( modifier = Modifier.fillMaxWidth(), colors = listItemColors, @@ -781,50 +685,39 @@ fun SettingsSheet(viewModel: MainViewModel) { ) } } - item { HorizontalDivider(color = mobileBorder) } - // Screen + // ── Preferences ── item { Text( - "SCREEN", + "PREFERENCES", style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), color = mobileAccent, ) } - item { - ListItem( - modifier = Modifier.settingsRowModifier(), - colors = listItemColors, - headlineContent = { Text("Prevent Sleep", style = mobileHeadline) }, - supportingContent = { Text("Keeps the screen awake while OpenClaw is open.", style = mobileCallout) }, - trailingContent = { Switch(checked = preventSleep, onCheckedChange = viewModel::setPreventSleep) }, - ) - } - - item { HorizontalDivider(color = mobileBorder) } - - // Debug item { - Text( - "DEBUG", - style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), - color = mobileAccent, - ) - } - item { - ListItem( - modifier = Modifier.settingsRowModifier(), - colors = listItemColors, - headlineContent = { Text("Debug Canvas Status", style = mobileHeadline) }, - supportingContent = { Text("Show status text in the canvas when debug is enabled.", style = mobileCallout) }, - trailingContent = { - Switch( - checked = canvasDebugStatusEnabled, - onCheckedChange = viewModel::setCanvasDebugStatusEnabled, + Column(modifier = Modifier.settingsRowModifier()) { + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Prevent Sleep", style = mobileHeadline) }, + supportingContent = { Text("Keep screen awake while open.", style = mobileCallout) }, + trailingContent = { Switch(checked = preventSleep, onCheckedChange = viewModel::setPreventSleep) }, ) - }, - ) - } + HorizontalDivider(color = mobileBorder) + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Debug Canvas", style = mobileHeadline) }, + supportingContent = { Text("Show status overlay on canvas.", style = mobileCallout) }, + trailingContent = { + Switch( + checked = canvasDebugStatusEnabled, + onCheckedChange = viewModel::setCanvasDebugStatusEnabled, + ) + }, + ) + } + } item { Spacer(modifier = Modifier.height(24.dp)) } } From 7638052178aa8f0ae51d61259cc1249443747b9c Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 13 Mar 2026 14:30:24 +0530 Subject: [PATCH 11/18] fix: note android chat settings redesign (#44894) --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5483519af2b..ead6682da12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Docs: https://docs.openclaw.ai ## Unreleased +### Changes + +- Android/chat settings: redesign the chat settings sheet with grouped device and media sections, refresh the Connect and Voice tabs, and tighten the chat composer/session header for a denser mobile layout. (#44894) Thanks @obviyus. + ### Fixes - Windows/gateway install: bound `schtasks` calls and fall back to the Startup-folder login item when task creation hangs, so native `openclaw gateway install` fails fast instead of wedging forever on broken Scheduled Task setups. From a3eed2b70f5055074ca96a802be1637604ada3db Mon Sep 17 00:00:00 2001 From: Jealous Date: Fri, 13 Mar 2026 02:09:51 -0700 Subject: [PATCH 12/18] fix(agents): avoid injecting memory file twice on case-insensitive mounts (#26054) * fix(agents): avoid injecting memory file twice on case-insensitive mounts On case-insensitive file systems mounted into Docker from macOS, both MEMORY.md and memory.md pass fs.access() even when they are the same underlying file. The previous dedup via fs.realpath() failed in this scenario because realpath does not normalise case through the Docker mount layer, so both paths were treated as distinct entries and the same content was injected into the bootstrap context twice, wasting tokens. Fix by replacing the collect-then-dedup approach with an early-exit: try MEMORY.md first; fall back to memory.md only when MEMORY.md is absent. This makes the function return at most one entry regardless of filesystem case-sensitivity. * docs: clarify singular memory bootstrap fallback * fix: note memory bootstrap fallback docs and changelog (#26054) (thanks @Lanfei) --------- Co-authored-by: Ayaan Zaidi --- CHANGELOG.md | 1 + docs/concepts/system-prompt.md | 2 +- docs/reference/token-use.md | 2 +- src/agents/workspace.ts | 44 ++++++++++++---------------------- 4 files changed, 18 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ead6682da12..506d9cd7c3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - Agents/compaction: compare post-compaction token sanity checks against full-session pre-compaction totals and skip the check when token estimation fails, so sessions with large bootstrap context keep real token counts instead of falling back to unknown. (#28347) thanks @efe-arv. - Discord/gateway startup: treat plain-text and transient `/gateway/bot` metadata fetch failures as transient startup errors so Discord gateway boot no longer crashes on unhandled rejections. (#44397) Thanks @jalehman. - Gateway/session reset: preserve `lastAccountId` and `lastThreadId` across gateway session resets so replies keep routing back to the same account and thread after `/reset`. (#44773) Thanks @Lanfei. +- Agents/memory bootstrap: load only one root memory file, preferring `MEMORY.md` and using `memory.md` as a fallback, so case-insensitive Docker mounts no longer inject duplicate memory context. (#26054) Thanks @Lanfei. ## 2026.3.12 diff --git a/docs/concepts/system-prompt.md b/docs/concepts/system-prompt.md index 1a5edfcc6e3..a1d1b482fb2 100644 --- a/docs/concepts/system-prompt.md +++ b/docs/concepts/system-prompt.md @@ -59,7 +59,7 @@ Bootstrap files are trimmed and appended under **Project Context** so the model - `USER.md` - `HEARTBEAT.md` - `BOOTSTRAP.md` (only on brand-new workspaces) -- `MEMORY.md` and/or `memory.md` (when present in the workspace; either or both may be injected) +- `MEMORY.md` when present, otherwise `memory.md` as a lowercase fallback All of these files are **injected into the context window** on every turn, which means they consume tokens. Keep them concise — especially `MEMORY.md`, which can diff --git a/docs/reference/token-use.md b/docs/reference/token-use.md index 9e85c25e687..8493e99f098 100644 --- a/docs/reference/token-use.md +++ b/docs/reference/token-use.md @@ -18,7 +18,7 @@ OpenClaw assembles its own system prompt on every run. It includes: - Tool list + short descriptions - Skills list (only metadata; instructions are loaded on demand with `read`) - Self-update instructions -- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` and/or `memory.md` when present). Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 20000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 150000). `memory/*.md` files are on-demand via memory tools and are not auto-injected. +- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` when present or `memory.md` as a lowercase fallback). Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 20000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 150000). `memory/*.md` files are on-demand via memory tools and are not auto-injected. - Time (UTC + user timezone) - Reply tags + heartbeat behavior - Runtime metadata (host/OS/model/thinking) diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index 830b44504ad..c4f1044a8d9 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -458,41 +458,24 @@ export async function ensureAgentWorkspace(params?: { }; } -async function resolveMemoryBootstrapEntries( +async function resolveMemoryBootstrapEntry( resolvedDir: string, -): Promise> { - const candidates: WorkspaceBootstrapFileName[] = [ - DEFAULT_MEMORY_FILENAME, - DEFAULT_MEMORY_ALT_FILENAME, - ]; - const entries: Array<{ name: WorkspaceBootstrapFileName; filePath: string }> = []; - for (const name of candidates) { +): Promise<{ name: WorkspaceBootstrapFileName; filePath: string } | null> { + // Prefer MEMORY.md; fall back to memory.md only when absent. + // Checking both and deduplicating via realpath is unreliable on case-insensitive + // file systems mounted in Docker (e.g. macOS volumes), where both names pass + // fs.access() but realpath does not normalise case through the mount layer, + // causing the same content to be injected twice and wasting tokens. + for (const name of [DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME] as const) { const filePath = path.join(resolvedDir, name); try { await fs.access(filePath); - entries.push({ name, filePath }); + return { name, filePath }; } catch { - // optional + // try next candidate } } - if (entries.length <= 1) { - return entries; - } - - const seen = new Set(); - const deduped: Array<{ name: WorkspaceBootstrapFileName; filePath: string }> = []; - for (const entry of entries) { - let key = entry.filePath; - try { - key = await fs.realpath(entry.filePath); - } catch {} - if (seen.has(key)) { - continue; - } - seen.add(key); - deduped.push(entry); - } - return deduped; + return null; } export async function loadWorkspaceBootstrapFiles(dir: string): Promise { @@ -532,7 +515,10 @@ export async function loadWorkspaceBootstrapFiles(dir: string): Promise Date: Fri, 13 Mar 2026 02:21:55 -0700 Subject: [PATCH 13/18] Docker: add OPENCLAW_TZ timezone support (#34119) * Docker: add OPENCLAW_TZ timezone support * fix: validate docker timezone names * fix: support Docker timezone override (#34119) (thanks @Lanfei) --------- Co-authored-by: Ayaan Zaidi --- CHANGELOG.md | 1 + docker-compose.yml | 2 ++ docker-setup.sh | 21 ++++++++++++++++++++- src/docker-setup.e2e.test.ts | 28 ++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 506d9cd7c3d..9adb4c86acd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - Android/chat settings: redesign the chat settings sheet with grouped device and media sections, refresh the Connect and Voice tabs, and tighten the chat composer/session header for a denser mobile layout. (#44894) Thanks @obviyus. +- Docker/timezone override: add `OPENCLAW_TZ` so `docker-setup.sh` can pin gateway and CLI containers to a chosen IANA timezone instead of inheriting the daemon default. (#34119) Thanks @Lanfei. ### Fixes diff --git a/docker-compose.yml b/docker-compose.yml index cc7169d3a88..c0bffc64458 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ services: CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY:-} CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY:-} CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-} + TZ: ${OPENCLAW_TZ:-UTC} volumes: - ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw - ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace @@ -65,6 +66,7 @@ services: CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY:-} CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY:-} CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-} + TZ: ${OPENCLAW_TZ:-UTC} volumes: - ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw - ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace diff --git a/docker-setup.sh b/docker-setup.sh index 450c2025ffa..19e5461765b 100755 --- a/docker-setup.sh +++ b/docker-setup.sh @@ -10,6 +10,7 @@ HOME_VOLUME_NAME="${OPENCLAW_HOME_VOLUME:-}" RAW_SANDBOX_SETTING="${OPENCLAW_SANDBOX:-}" SANDBOX_ENABLED="" DOCKER_SOCKET_PATH="${OPENCLAW_DOCKER_SOCKET:-}" +TIMEZONE="${OPENCLAW_TZ:-}" fail() { echo "ERROR: $*" >&2 @@ -135,6 +136,11 @@ contains_disallowed_chars() { [[ "$value" == *$'\n'* || "$value" == *$'\r'* || "$value" == *$'\t'* ]] } +is_valid_timezone() { + local value="$1" + [[ -e "/usr/share/zoneinfo/$value" && ! -d "/usr/share/zoneinfo/$value" ]] +} + validate_mount_path_value() { local label="$1" local value="$2" @@ -202,6 +208,17 @@ fi if [[ -n "$SANDBOX_ENABLED" ]]; then validate_mount_path_value "OPENCLAW_DOCKER_SOCKET" "$DOCKER_SOCKET_PATH" fi +if [[ -n "$TIMEZONE" ]]; then + if contains_disallowed_chars "$TIMEZONE"; then + fail "OPENCLAW_TZ contains unsupported control characters." + fi + if [[ ! "$TIMEZONE" =~ ^[A-Za-z0-9/_+\-]+$ ]]; then + fail "OPENCLAW_TZ must be a valid IANA timezone string (e.g. Asia/Shanghai)." + fi + if ! is_valid_timezone "$TIMEZONE"; then + fail "OPENCLAW_TZ must match a timezone in /usr/share/zoneinfo (e.g. Asia/Shanghai)." + fi +fi mkdir -p "$OPENCLAW_CONFIG_DIR" mkdir -p "$OPENCLAW_WORKSPACE_DIR" @@ -224,6 +241,7 @@ export OPENCLAW_HOME_VOLUME="$HOME_VOLUME_NAME" export OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}" export OPENCLAW_SANDBOX="$SANDBOX_ENABLED" export OPENCLAW_DOCKER_SOCKET="$DOCKER_SOCKET_PATH" +export OPENCLAW_TZ="$TIMEZONE" # Detect Docker socket GID for sandbox group_add. DOCKER_GID="" @@ -408,7 +426,8 @@ upsert_env "$ENV_FILE" \ OPENCLAW_DOCKER_SOCKET \ DOCKER_GID \ OPENCLAW_INSTALL_DOCKER_CLI \ - OPENCLAW_ALLOW_INSECURE_PRIVATE_WS + OPENCLAW_ALLOW_INSECURE_PRIVATE_WS \ + OPENCLAW_TZ if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then echo "==> Building Docker image: $IMAGE_NAME" diff --git a/src/docker-setup.e2e.test.ts b/src/docker-setup.e2e.test.ts index 6890e7d55a8..8d5eec70ed0 100644 --- a/src/docker-setup.e2e.test.ts +++ b/src/docker-setup.e2e.test.ts @@ -205,6 +205,18 @@ describe("docker-setup.sh", () => { expect(identityDirStat.isDirectory()).toBe(true); }); + it("writes OPENCLAW_TZ into .env when given a real IANA timezone", async () => { + const activeSandbox = requireSandbox(sandbox); + + const result = runDockerSetup(activeSandbox, { + OPENCLAW_TZ: "Asia/Shanghai", + }); + + expect(result.status).toBe(0); + const envFile = await readFile(join(activeSandbox.rootDir, ".env"), "utf8"); + expect(envFile).toContain("OPENCLAW_TZ=Asia/Shanghai"); + }); + it("precreates agent data dirs to avoid EACCES in container", async () => { const activeSandbox = requireSandbox(sandbox); const configDir = join(activeSandbox.rootDir, "config-agent-dirs"); @@ -411,6 +423,17 @@ describe("docker-setup.sh", () => { expect(result.stderr).toContain("OPENCLAW_HOME_VOLUME must match"); }); + it("rejects OPENCLAW_TZ values that are not present in zoneinfo", async () => { + const activeSandbox = requireSandbox(sandbox); + + const result = runDockerSetup(activeSandbox, { + OPENCLAW_TZ: "Nope/Bad", + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("OPENCLAW_TZ must match a timezone in /usr/share/zoneinfo"); + }); + it("avoids associative arrays so the script remains Bash 3.2-compatible", async () => { const script = await readFile(join(repoRoot, "docker-setup.sh"), "utf8"); expect(script).not.toMatch(/^\s*declare -A\b/m); @@ -455,4 +478,9 @@ describe("docker-setup.sh", () => { 2, ); }); + + it("keeps docker-compose timezone env defaults aligned across services", async () => { + const compose = await readFile(join(repoRoot, "docker-compose.yml"), "utf8"); + expect(compose.match(/TZ: \$\{OPENCLAW_TZ:-UTC\}/g)).toHaveLength(2); + }); }); From 84428bbba6b03c7c955928231d3030216da1b7ba Mon Sep 17 00:00:00 2001 From: Kaneki Date: Fri, 13 Mar 2026 17:29:21 +0800 Subject: [PATCH 14/18] Android: fix HttpURLConnection leak in TalkModeVoiceResolver (#43780) * Android: fix HttpURLConnection leak in TalkModeVoiceResolver.listVoices * fix null errorStream NPE and preserve HTTP keep-alive * fix: restore voice resolver disconnect cleanup --------- Co-authored-by: Ayaan Zaidi --- .../app/voice/TalkModeVoiceResolver.kt | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeVoiceResolver.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeVoiceResolver.kt index eff52017624..7ada19e166b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeVoiceResolver.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeVoiceResolver.kt @@ -79,26 +79,30 @@ internal object TalkModeVoiceResolver { return withContext(Dispatchers.IO) { val url = URL("https://api.elevenlabs.io/v1/voices") val conn = url.openConnection() as HttpURLConnection - conn.requestMethod = "GET" - conn.connectTimeout = 15_000 - conn.readTimeout = 15_000 - conn.setRequestProperty("xi-api-key", apiKey) + try { + conn.requestMethod = "GET" + conn.connectTimeout = 15_000 + conn.readTimeout = 15_000 + conn.setRequestProperty("xi-api-key", apiKey) - val code = conn.responseCode - val stream = if (code >= 400) conn.errorStream else conn.inputStream - val data = stream.readBytes() - if (code >= 400) { - val message = data.toString(Charsets.UTF_8) - throw IllegalStateException("ElevenLabs voices failed: $code $message") - } + val code = conn.responseCode + val stream = if (code >= 400) conn.errorStream else conn.inputStream + val data = stream?.use { it.readBytes() } ?: byteArrayOf() + if (code >= 400) { + val message = data.toString(Charsets.UTF_8) + throw IllegalStateException("ElevenLabs voices failed: $code $message") + } - val root = json.parseToJsonElement(data.toString(Charsets.UTF_8)).asObjectOrNull() - val voices = (root?.get("voices") as? JsonArray) ?: JsonArray(emptyList()) - voices.mapNotNull { entry -> - val obj = entry.asObjectOrNull() ?: return@mapNotNull null - val voiceId = obj["voice_id"].asStringOrNull() ?: return@mapNotNull null - val name = obj["name"].asStringOrNull() - ElevenLabsVoice(voiceId, name) + val root = json.parseToJsonElement(data.toString(Charsets.UTF_8)).asObjectOrNull() + val voices = (root?.get("voices") as? JsonArray) ?: JsonArray(emptyList()) + voices.mapNotNull { entry -> + val obj = entry.asObjectOrNull() ?: return@mapNotNull null + val voiceId = obj["voice_id"].asStringOrNull() ?: return@mapNotNull null + val name = obj["name"].asStringOrNull() + ElevenLabsVoice(voiceId, name) + } + } finally { + conn.disconnect() } } } From 60cb1d683c9416c1032a3c33c589625e0d6622db Mon Sep 17 00:00:00 2001 From: cheapestinference Date: Fri, 13 Mar 2026 10:30:24 +0100 Subject: [PATCH 15/18] fix(agents): respect explicit user compat overrides for non-native openai-completions (#44432) Reviewed-by: @frankekn --- CHANGELOG.md | 1 + src/agents/model-compat.test.ts | 18 +++++++++++++++--- src/agents/model-compat.ts | 19 ++++++++++++++----- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9adb4c86acd..47fb63e5c39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai - Discord/gateway startup: treat plain-text and transient `/gateway/bot` metadata fetch failures as transient startup errors so Discord gateway boot no longer crashes on unhandled rejections. (#44397) Thanks @jalehman. - Gateway/session reset: preserve `lastAccountId` and `lastThreadId` across gateway session resets so replies keep routing back to the same account and thread after `/reset`. (#44773) Thanks @Lanfei. - Agents/memory bootstrap: load only one root memory file, preferring `MEMORY.md` and using `memory.md` as a fallback, so case-insensitive Docker mounts no longer inject duplicate memory context. (#26054) Thanks @Lanfei. +- Agents/OpenAI-compatible compat overrides: respect explicit user `models[].compat` opt-ins for non-native `openai-completions` endpoints so usage-in-streaming capability overrides no longer get forced off when the endpoint actually supports them. (#44432) Thanks @cheapestinference. ## 2026.3.12 diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index fc52ee2205e..56b9c16203c 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -251,7 +251,7 @@ describe("normalizeModelCompat", () => { }); }); - it("overrides explicit supportsDeveloperRole true on non-native endpoints", () => { + it("respects explicit supportsDeveloperRole true on non-native endpoints", () => { const model = { ...baseModel(), provider: "custom-cpa", @@ -259,10 +259,10 @@ describe("normalizeModelCompat", () => { compat: { supportsDeveloperRole: true }, }; const normalized = normalizeModelCompat(model); - expect(supportsDeveloperRole(normalized)).toBe(false); + expect(supportsDeveloperRole(normalized)).toBe(true); }); - it("overrides explicit supportsUsageInStreaming true on non-native endpoints", () => { + it("respects explicit supportsUsageInStreaming true on non-native endpoints", () => { const model = { ...baseModel(), provider: "custom-cpa", @@ -270,6 +270,18 @@ describe("normalizeModelCompat", () => { compat: { supportsUsageInStreaming: true }, }; const normalized = normalizeModelCompat(model); + expect(supportsUsageInStreaming(normalized)).toBe(true); + }); + + it("still forces flags off when not explicitly set by user", () => { + const model = { + ...baseModel(), + provider: "custom-cpa", + baseUrl: "https://proxy.example.com/v1", + }; + delete (model as { compat?: unknown }).compat; + const normalized = normalizeModelCompat(model); + expect(supportsDeveloperRole(normalized)).toBe(false); expect(supportsUsageInStreaming(normalized)).toBe(false); }); diff --git a/src/agents/model-compat.ts b/src/agents/model-compat.ts index 7bad084fe57..72deb0c655f 100644 --- a/src/agents/model-compat.ts +++ b/src/agents/model-compat.ts @@ -55,17 +55,22 @@ export function normalizeModelCompat(model: Model): Model { // The `developer` role and stream usage chunks are OpenAI-native behaviors. // Many OpenAI-compatible backends reject `developer` and/or emit usage-only // chunks that break strict parsers expecting choices[0]. For non-native - // openai-completions endpoints, force both compat flags off. + // openai-completions endpoints, force both compat flags off — unless the + // user has explicitly opted in via their model config. const compat = model.compat ?? undefined; // When baseUrl is empty the pi-ai library defaults to api.openai.com, so // leave compat unchanged and let default native behavior apply. - // Note: explicit true values are intentionally overridden for non-native - // endpoints for safety. const needsForce = baseUrl ? !isOpenAINativeEndpoint(baseUrl) : false; if (!needsForce) { return model; } - if (compat?.supportsDeveloperRole === false && compat?.supportsUsageInStreaming === false) { + + // Respect explicit user overrides: if the user has set a compat flag to + // true in their model definition, they know their endpoint supports it. + const forcedDeveloperRole = compat?.supportsDeveloperRole === true; + const forcedUsageStreaming = compat?.supportsUsageInStreaming === true; + + if (forcedDeveloperRole && forcedUsageStreaming) { return model; } @@ -73,7 +78,11 @@ export function normalizeModelCompat(model: Model): Model { return { ...model, compat: compat - ? { ...compat, supportsDeveloperRole: false, supportsUsageInStreaming: false } + ? { + ...compat, + supportsDeveloperRole: forcedDeveloperRole || false, + supportsUsageInStreaming: forcedUsageStreaming || false, + } : { supportsDeveloperRole: false, supportsUsageInStreaming: false }, } as typeof model; } From b28a2257f7e8ff65053ed67fb688460dc63cf205 Mon Sep 17 00:00:00 2001 From: xingsy97 <87063252+xingsy97@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:58:17 +0000 Subject: [PATCH 16/18] test(config): cover requiresOpenAiAnthropicToolPayload in compat schema fixture Adds the missing requiresOpenAiAnthropicToolPayload field to the model-compat schema acceptance test, guarding against regressions like #43339 where onboarding fails with "Unrecognized key". Closes #43339 --- src/config/config-misc.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index 647986a96e0..2a1972d9040 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -315,6 +315,7 @@ describe("model compat config schema", () => { requiresAssistantAfterToolResult: false, requiresThinkingAsText: false, requiresMistralToolIds: false, + requiresOpenAiAnthropicToolPayload: true, }, }, ], From 2c39cd095358a9c501a72949b6531eb6fc3fe8dc Mon Sep 17 00:00:00 2001 From: xingsy97 <87063252+xingsy97@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:37:03 +0800 Subject: [PATCH 17/18] fix(agents): rephrase session reset prompt to avoid Azure content filter (#43403) * fix(agents): rephrase session reset prompt to avoid Azure content filter Azure OpenAI's content filter flags the phrase 'Execute your Session Startup sequence now' as potentially harmful, causing /new and /reset to return 400 for all Azure-hosted deployments. Replace 'Execute ... now' with 'Run your Session Startup sequence' in session-reset-prompt.ts and post-compaction-context.ts. The semantics are identical but the softer phrasing avoids the false-positive. Closes #42769 * ci: retrigger checks (windows shard timeout) * fix: add changelog for Azure startup prompt fix (#43403) (thanks @xingsy97) --------- Co-authored-by: Ayaan Zaidi --- CHANGELOG.md | 1 + .../reply.triggers.trigger-handling.test-harness.ts | 2 +- src/auto-reply/reply/post-compaction-context.test.ts | 4 ++-- src/auto-reply/reply/post-compaction-context.ts | 2 +- src/auto-reply/reply/session-reset-prompt.test.ts | 2 +- src/auto-reply/reply/session-reset-prompt.ts | 2 +- src/gateway/server-methods/agent.test.ts | 2 +- src/gateway/server.agent.gateway-server-agent-b.test.ts | 2 +- 8 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47fb63e5c39..f8820bea39f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai - Gateway/session reset: preserve `lastAccountId` and `lastThreadId` across gateway session resets so replies keep routing back to the same account and thread after `/reset`. (#44773) Thanks @Lanfei. - Agents/memory bootstrap: load only one root memory file, preferring `MEMORY.md` and using `memory.md` as a fallback, so case-insensitive Docker mounts no longer inject duplicate memory context. (#26054) Thanks @Lanfei. - Agents/OpenAI-compatible compat overrides: respect explicit user `models[].compat` opt-ins for non-native `openai-completions` endpoints so usage-in-streaming capability overrides no longer get forced off when the endpoint actually supports them. (#44432) Thanks @cheapestinference. +- Agents/Azure OpenAI startup prompts: rephrase the built-in `/new`, `/reset`, and post-compaction startup instruction so Azure OpenAI deployments no longer hit HTTP 400 false positives from the content filter. (#43403) Thanks @xingsy97. ## 2026.3.12 diff --git a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts index 69db49e97ee..db8dd5b1fae 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts @@ -363,7 +363,7 @@ export async function runGreetingPromptForBareNewOrReset(params: { expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); const prompt = runEmbeddedPiAgentMock.mock.calls.at(-1)?.[0]?.prompt ?? ""; expect(prompt).toContain("A new session was started via /new or /reset"); - expect(prompt).toContain("Execute your Session Startup sequence now"); + expect(prompt).toContain("Run your Session Startup sequence"); } export function installTriggerHandlingE2eTestHooks() { diff --git a/src/auto-reply/reply/post-compaction-context.test.ts b/src/auto-reply/reply/post-compaction-context.test.ts index 0c97df4d50b..02a4a27e6de 100644 --- a/src/auto-reply/reply/post-compaction-context.test.ts +++ b/src/auto-reply/reply/post-compaction-context.test.ts @@ -332,7 +332,7 @@ Read WORKFLOW.md on startup. fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); const result = await readPostCompactionContext(tmpDir); expect(result).not.toBeNull(); - expect(result).toContain("Execute your Session Startup sequence now"); + expect(result).toContain("Run your Session Startup sequence"); }); it("falls back to legacy sections when defaults are explicitly configured", async () => { @@ -368,7 +368,7 @@ Read WORKFLOW.md on startup. expect(result).not.toBeNull(); expect(result).toContain("Do startup things"); expect(result).toContain("Be safe"); - expect(result).toContain("Execute your Session Startup sequence now"); + expect(result).toContain("Run your Session Startup sequence"); }); it("custom section names are matched case-insensitively", async () => { diff --git a/src/auto-reply/reply/post-compaction-context.ts b/src/auto-reply/reply/post-compaction-context.ts index 316ac3c29b1..791c1a91a19 100644 --- a/src/auto-reply/reply/post-compaction-context.ts +++ b/src/auto-reply/reply/post-compaction-context.ts @@ -136,7 +136,7 @@ export async function readPostCompactionContext( // would be misleading for deployments that use different section names. const prose = isDefaultSections ? "Session was just compacted. The conversation summary above is a hint, NOT a substitute for your startup sequence. " + - "Execute your Session Startup sequence now — read the required files before responding to the user." + "Run your Session Startup sequence — read the required files before responding to the user." : `Session was just compacted. The conversation summary above is a hint, NOT a substitute for your full startup sequence. ` + `Re-read the sections injected below (${displayNames.join(", ")}) and follow your configured startup procedure before responding to the user.`; diff --git a/src/auto-reply/reply/session-reset-prompt.test.ts b/src/auto-reply/reply/session-reset-prompt.test.ts index c6a1d2d9562..bf038243962 100644 --- a/src/auto-reply/reply/session-reset-prompt.test.ts +++ b/src/auto-reply/reply/session-reset-prompt.test.ts @@ -5,7 +5,7 @@ import { buildBareSessionResetPrompt } from "./session-reset-prompt.js"; describe("buildBareSessionResetPrompt", () => { it("includes the core session startup instruction", () => { const prompt = buildBareSessionResetPrompt(); - expect(prompt).toContain("Execute your Session Startup sequence now"); + expect(prompt).toContain("Run your Session Startup sequence"); expect(prompt).toContain("read the required files before responding to the user"); }); diff --git a/src/auto-reply/reply/session-reset-prompt.ts b/src/auto-reply/reply/session-reset-prompt.ts index 67e693f70b1..c903e3a688a 100644 --- a/src/auto-reply/reply/session-reset-prompt.ts +++ b/src/auto-reply/reply/session-reset-prompt.ts @@ -2,7 +2,7 @@ import { appendCronStyleCurrentTimeLine } from "../../agents/current-time.js"; import type { OpenClawConfig } from "../../config/config.js"; const BARE_SESSION_RESET_PROMPT_BASE = - "A new session was started via /new or /reset. Execute your Session Startup sequence now - read the required files before responding to the user. Then greet the user in your configured persona, if one is provided. Be yourself - use your defined voice, mannerisms, and mood. Keep it to 1-3 sentences and ask what they want to do. If the runtime model differs from default_model in the system prompt, mention the default model. Do not mention internal steps, files, tools, or reasoning."; + "A new session was started via /new or /reset. Run your Session Startup sequence - read the required files before responding to the user. Then greet the user in your configured persona, if one is provided. Be yourself - use your defined voice, mannerisms, and mood. Keep it to 1-3 sentences and ask what they want to do. If the runtime model differs from default_model in the system prompt, mention the default model. Do not mention internal steps, files, tools, or reasoning."; /** * Build the bare session reset prompt, appending the current date/time so agents diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index 5dfa27b20ce..f3b74416c70 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -582,7 +582,7 @@ describe("gateway agent handler", () => { expect(mocks.performGatewaySessionReset).toHaveBeenCalledTimes(1); const call = readLastAgentCommandCall(); // Message is now dynamically built with current date — check key substrings - expect(call?.message).toContain("Execute your Session Startup sequence now"); + expect(call?.message).toContain("Run your Session Startup sequence"); expect(call?.message).toContain("Current time:"); expect(call?.message).not.toBe(BARE_SESSION_RESET_PROMPT); expect(call?.sessionId).toBe("reset-session-id"); diff --git a/src/gateway/server.agent.gateway-server-agent-b.test.ts b/src/gateway/server.agent.gateway-server-agent-b.test.ts index 755186080ba..61fff855a8f 100644 --- a/src/gateway/server.agent.gateway-server-agent-b.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-b.test.ts @@ -287,7 +287,7 @@ describe("gateway server agent", () => { await vi.waitFor(() => expect(calls.length).toBeGreaterThan(callsBefore)); const call = (calls.at(-1)?.[0] ?? {}) as Record; expect(call.message).toBeTypeOf("string"); - expect(call.message).toContain("Execute your Session Startup sequence now"); + expect(call.message).toContain("Run your Session Startup sequence"); expect(call.message).toContain("Current time:"); expect(typeof call.sessionId).toBe("string"); expect(call.sessionId).not.toBe("sess-main-before-reset"); From f9ea8797297eadab1a9abaed0328ebd687610f71 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 13 Mar 2026 15:19:16 +0530 Subject: [PATCH 18/18] docs(contributing): update Android app ownership --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a4bb0e17361..87ccbeff4ef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,7 +23,7 @@ Welcome to the lobster tank! 🦞 - **Jos** - Telegram, API, Nix mode - GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes) -- **Ayaan Zaidi** - Telegram subsystem, iOS app +- **Ayaan Zaidi** - Telegram subsystem, Android app - GitHub: [@obviyus](https://github.com/obviyus) · X: [@0bviyus](https://x.com/0bviyus) - **Tyler Yust** - Agents/subagents, cron, BlueBubbles, macOS app