@syengup/friday-channel-next 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -138,6 +138,15 @@ export const fridayNextChannelPlugin = createChatChannelPlugin({
138
138
  },
139
139
  parseExplicitTarget: () => ({ to: "friday-next" }),
140
140
  formatTargetDisplay: ({ display }) => display || "Friday Next",
141
+ // friday-next is a transparent proxy: outbound text/media already reach the app live
142
+ // via SSE (sendText/sendMedia/handleSend). The OpenClaw core additionally mirrors
143
+ // message-tool sends into the recipient's session transcript (model:"delivery-mirror").
144
+ // For friday-next that recipient session falls back to
145
+ // `agent:<agentId>:friday-next:direct:<deviceId>` — an orphan session unrelated to the
146
+ // app's real conversation — spawning a phantom session + a stray delivery-mirror message.
147
+ // The core checks this hook first; returning null short-circuits route resolution so no
148
+ // orphan session entry and no delivery-mirror are written.
149
+ resolveOutboundSessionRoute: () => null,
141
150
  },
142
151
  },
143
152
  outbound: {
@@ -22,6 +22,22 @@ export type FridayReplyPayload = {
22
22
  interactive?: unknown;
23
23
  channelData?: unknown;
24
24
  };
25
+ /** Map local / gateway paths to public `/friday-next/files/...` URLs where possible. */
26
+ /**
27
+ * Canvas snapshots are captured so the *agent* can "see" the rendered canvas. OpenClaw core surfaces
28
+ * any image tool result as deliverable media on the assistant reply block, which for Friday Next would
29
+ * make the snapshot auto-appear as an attachment mid-stream — not what we want. Snapshot temp files are
30
+ * named `openclaw-canvas-snapshot-<uuid>.<ext>`, so we detect them by basename and drop them from the
31
+ * delivered payload (the assistant text is preserved). Agent-initiated media sends are unaffected —
32
+ * those flow through the `outbound` channel action, not the deliver block path.
33
+ */
34
+ export declare function isCanvasSnapshotMediaPath(url: unknown): boolean;
35
+ export declare function translateDeliverPayload(pl: FridayReplyPayload, kind: string, meta?: {
36
+ modelName?: string;
37
+ totalTokens?: number;
38
+ contextTokensUsed?: number;
39
+ contextWindowMax?: number;
40
+ }): Record<string, unknown>;
25
41
  export interface FridayMessagePayload {
26
42
  deviceId: string;
27
43
  text: string;
@@ -13,8 +13,7 @@ import crypto from "node:crypto";
13
13
  import { resolveFridayNextConfig } from "../../config.js";
14
14
  import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
15
15
  import { getFridayNextRuntime } from "../../runtime.js";
16
- import { getFridayAgentForwardRuntime } from "../../agent-forward-runtime.js";
17
- import { agentIdFromSessionKey, setSessionSettings, splitModelRef, toSessionStoreKey, } from "../../session/session-manager.js";
16
+ import { resolveAgentDefaults, setSessionSettings, splitModelRef, toSessionStoreKey, } from "../../session/session-manager.js";
18
17
  import { sseEmitter } from "../../sse/emitter.js";
19
18
  import { extractBearerToken } from "../middleware/auth.js";
20
19
  import { readJsonBody } from "../middleware/body.js";
@@ -85,7 +84,28 @@ function inferFridayNextMediaKind(params) {
85
84
  return "file";
86
85
  }
87
86
  /** Map local / gateway paths to public `/friday-next/files/...` URLs where possible. */
88
- function translateDeliverPayload(pl, kind, meta) {
87
+ /**
88
+ * Canvas snapshots are captured so the *agent* can "see" the rendered canvas. OpenClaw core surfaces
89
+ * any image tool result as deliverable media on the assistant reply block, which for Friday Next would
90
+ * make the snapshot auto-appear as an attachment mid-stream — not what we want. Snapshot temp files are
91
+ * named `openclaw-canvas-snapshot-<uuid>.<ext>`, so we detect them by basename and drop them from the
92
+ * delivered payload (the assistant text is preserved). Agent-initiated media sends are unaffected —
93
+ * those flow through the `outbound` channel action, not the deliver block path.
94
+ */
95
+ export function isCanvasSnapshotMediaPath(url) {
96
+ if (typeof url !== "string")
97
+ return false;
98
+ const base = url.split(/[/\\]/).pop() ?? url;
99
+ return /canvas-snapshot-/i.test(base);
100
+ }
101
+ export function translateDeliverPayload(pl, kind, meta) {
102
+ // Strip canvas-snapshot tool-result images before any media resolution (paths here are still the
103
+ // original `/tmp/openclaw/openclaw-canvas-snapshot-*.jpg` temp paths, not yet copied to friday files).
104
+ const filteredSingle = typeof pl.mediaUrl === "string" && !isCanvasSnapshotMediaPath(pl.mediaUrl) ? pl.mediaUrl : null;
105
+ const filteredArr = Array.isArray(pl.mediaUrls)
106
+ ? pl.mediaUrls.filter((u) => !isCanvasSnapshotMediaPath(u))
107
+ : pl.mediaUrls;
108
+ pl = { ...pl, mediaUrl: filteredSingle, mediaUrls: filteredArr };
89
109
  const raw = { ...pl };
90
110
  const originalUrls = collectReplyPayloadMediaUrls(pl);
91
111
  if (typeof pl.mediaUrl === "string" && pl.mediaUrl.trim()) {
@@ -314,41 +334,9 @@ export async function handleMessages(req, res) {
314
334
  res.end(JSON.stringify({ accepted: true, deviceId: normalizedDeviceId, runId }));
315
335
  log("MESSAGE_RECEIVED", normalizedDeviceId, runId, `textLen=${trimmedText.length} attachments=${attachments.length} sessionKey=${baseSessionKey}`);
316
336
  const cfg = resolveFridayNextConfig(getHostOpenClawConfigSnapshot(runtime.config));
317
- // Resolve defaults from the OpenClaw agent config so settings are never left empty.
318
- // The target agent comes from the app-supplied sessionKey (`agent:<id>:<rest>`); prefer that
319
- // agent's own configured model/thinking over the global defaults so non-main agents are not
320
- // silently forced onto the global default model.
321
- const targetAgentId = agentIdFromSessionKey(baseSessionKey);
322
- let defaultModel;
323
- let defaultThinking;
324
- try {
325
- const forwardRt = getFridayAgentForwardRuntime();
326
- if (forwardRt) {
327
- const ocCfg = (forwardRt.getConfig() ?? {});
328
- const agents = ocCfg.agents;
329
- const agentEntry = agents?.list?.find((a) => agentIdFromSessionKey(`agent:${String(a?.id ?? "")}:x`) === targetAgentId);
330
- const agentModel = agentEntry?.model;
331
- const perAgentModel = typeof agentModel === "string"
332
- ? agentModel
333
- : typeof agentModel?.primary === "string"
334
- ? agentModel.primary
335
- : undefined;
336
- const perAgentThinking = typeof agentEntry?.thinkingDefault === "string"
337
- ? agentEntry.thinkingDefault
338
- : undefined;
339
- const agentDefaults = agents?.defaults;
340
- const model = agentDefaults?.model;
341
- const globalModel = typeof model?.primary === "string" ? model.primary : undefined;
342
- const globalThinking = typeof agentDefaults?.thinkingDefault === "string"
343
- ? agentDefaults.thinkingDefault
344
- : undefined;
345
- defaultModel = perAgentModel ?? globalModel;
346
- defaultThinking = perAgentThinking ?? globalThinking;
347
- }
348
- }
349
- catch {
350
- // Config not available (tests) — leave defaults undefined.
351
- }
337
+ // Resolve defaults from the OpenClaw agent config so settings are never left empty. Prefers the
338
+ // target agent's own model/thinking over the global defaults (see resolveAgentDefaults).
339
+ const { model: defaultModel, thinking: defaultThinking } = resolveAgentDefaults(baseSessionKey);
352
340
  const modelRef = payload.modelRef ?? defaultModel;
353
341
  const reasoningLevel = payload.reasoningLevel ?? "stream";
354
342
  const thinkingLevel = payload.thinkingLevel ?? defaultThinking;
@@ -356,7 +344,8 @@ export async function handleMessages(req, res) {
356
344
  if (modelRef) {
357
345
  settings.modelRef = modelRef;
358
346
  const split = splitModelRef(modelRef);
359
- settings.providerOverride = split.provider;
347
+ // `?? null` clears a stale provider when the resolved ref is bare (no `provider/` prefix).
348
+ settings.providerOverride = split.provider ?? null;
360
349
  settings.modelOverride = split.modelId;
361
350
  }
362
351
  if (reasoningLevel)
@@ -1,4 +1,4 @@
1
- import { setSessionSettings, getSessionSettings, splitModelRef, } from "../../session/session-manager.js";
1
+ import { setSessionSettings, getSessionSettings, splitModelRef, resolveAgentDefaults, } from "../../session/session-manager.js";
2
2
  import { readJsonBody } from "../middleware/body.js";
3
3
  import { extractBearerToken } from "../middleware/auth.js";
4
4
  const VALID_REASONING = new Set(["on", "off", "stream"]);
@@ -43,7 +43,7 @@ export async function handleSessionsSettings(req, res) {
43
43
  }
44
44
  const reasoningLevel = typeof body?.reasoningLevel === "string" ? body.reasoningLevel : undefined;
45
45
  const thinkingLevel = typeof body?.thinkingLevel === "string" ? body.thinkingLevel : undefined;
46
- const modelRef = typeof body?.modelRef === "string" ? body.modelRef : undefined;
46
+ const modelRef = typeof body?.modelRef === "string" ? body.modelRef.trim() : undefined;
47
47
  const errors = [];
48
48
  if (reasoningLevel !== undefined && !VALID_REASONING.has(reasoningLevel)) {
49
49
  errors.push(`reasoningLevel must be one of: ${[...VALID_REASONING].join(", ")}`);
@@ -57,11 +57,26 @@ export async function handleSessionsSettings(req, res) {
57
57
  res.end(JSON.stringify({ error: errors.join("; ") }));
58
58
  return true;
59
59
  }
60
- const settings = { reasoningLevel, thinkingLevel, modelRef };
61
- if (modelRef) {
62
- const split = splitModelRef(modelRef);
63
- settings["providerOverride"] = split.provider;
64
- settings["modelOverride"] = split.modelId;
60
+ // The app omits (or empties) modelRef to mean "use the agent's default model". Resolve that
61
+ // default and write it as an *explicit* override, identical in shape to any other selection — so
62
+ // the agent runs the default exactly the way it runs an explicitly-picked model. Do NOT just
63
+ // clear the override here: the session entry is shared with the OpenClaw core, which stamps it
64
+ // with provenance fields (`modelOverrideSource`, `model`, `modelProvider`); deleting only our
65
+ // three fields leaves those dangling and the core mis-resolves to a fallback model.
66
+ const effectiveModelRef = modelRef || resolveAgentDefaults(sessionKey).model;
67
+ const settings = { reasoningLevel, thinkingLevel };
68
+ if (effectiveModelRef) {
69
+ const split = splitModelRef(effectiveModelRef);
70
+ settings.modelRef = effectiveModelRef;
71
+ // `?? null` clears a stale provider when the ref is bare (no `provider/` prefix).
72
+ settings.providerOverride = split.provider ?? null;
73
+ settings.modelOverride = split.modelId;
74
+ }
75
+ else {
76
+ // No configured default to resolve (e.g. config unavailable) — clear rather than pin a stale model.
77
+ settings.modelRef = null;
78
+ settings.providerOverride = null;
79
+ settings.modelOverride = null;
65
80
  }
66
81
  const result = setSessionSettings(sessionKey, settings);
67
82
  res.statusCode = 200;
@@ -17,5 +17,34 @@ export interface FridaySessionSettings {
17
17
  providerOverride?: string;
18
18
  modelOverride?: string;
19
19
  }
20
- export declare function setSessionSettings(sessionKey: string, settings: FridaySessionSettings, historyDir?: string): FridaySessionSettings;
20
+ /**
21
+ * Update shape for {@link setSessionSettings}. A field set to a string writes that value, a field
22
+ * left `undefined` is untouched, and a field set to `null` **clears** the stored value. The `null`
23
+ * case is what lets the app reset a model override back to the agent default — without it the merge
24
+ * could only ever add/replace overrides, never remove them (the cause of "selecting the default
25
+ * model doesn't take effect": a prior `provider/model` override survived and was read back).
26
+ */
27
+ export type FridaySessionSettingsUpdate = {
28
+ reasoningLevel?: string | null;
29
+ thinkingLevel?: string | null;
30
+ modelRef?: string | null;
31
+ providerOverride?: string | null;
32
+ modelOverride?: string | null;
33
+ };
34
+ export declare function setSessionSettings(sessionKey: string, settings: FridaySessionSettingsUpdate, historyDir?: string): FridaySessionSettings;
21
35
  export declare function getSessionSettings(sessionKey: string, historyDir?: string): FridaySessionSettings;
36
+ /**
37
+ * Resolve the configured default model + thinking level for the agent that owns `sessionKey`,
38
+ * reading the live OpenClaw config. Prefers the target agent's own `model`/`thinkingDefault` over
39
+ * the global `agents.defaults`, so non-main agents aren't silently forced onto the global default.
40
+ *
41
+ * Used to write the default model as an **explicit** override when the app selects it (the app
42
+ * sends no modelRef for the default). Writing it explicitly — rather than clearing the stored
43
+ * override — keeps the shared session entry consistent with the core's provenance fields
44
+ * (`modelOverrideSource`, `model`, `modelProvider`); a bare clear leaves those dangling and the
45
+ * agent mis-resolves to a fallback model.
46
+ */
47
+ export declare function resolveAgentDefaults(sessionKey: string): {
48
+ model?: string;
49
+ thinking?: string;
50
+ };
@@ -1,6 +1,7 @@
1
1
  import { join } from "node:path";
2
2
  import os from "node:os";
3
3
  import { readFileSync, writeFileSync } from "node:fs";
4
+ import { getFridayAgentForwardRuntime } from "../agent-forward-runtime.js";
4
5
  const FRIDAY_AGENT_ID = "main";
5
6
  const SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i;
6
7
  /** Path/shell-safe agent id (mirrors OpenClaw's `normalizeAgentId`). Anything else falls back to `main`. */
@@ -122,7 +123,17 @@ export function setSessionSettings(sessionKey, settings, historyDir) {
122
123
  let updated = false;
123
124
  for (const key of fieldKeys) {
124
125
  const value = settings[key];
125
- if (value !== undefined && data[fileKey][key] !== value) {
126
+ if (value === undefined)
127
+ continue; // leave the stored value untouched
128
+ if (value === null) {
129
+ // Explicit clear — remove the override so the agent falls back to its default.
130
+ if (key in data[fileKey]) {
131
+ delete data[fileKey][key];
132
+ updated = true;
133
+ }
134
+ continue;
135
+ }
136
+ if (data[fileKey][key] !== value) {
126
137
  data[fileKey][key] = value;
127
138
  updated = true;
128
139
  }
@@ -163,3 +174,41 @@ export function getSessionSettings(sessionKey, historyDir) {
163
174
  return {};
164
175
  }
165
176
  }
177
+ /**
178
+ * Resolve the configured default model + thinking level for the agent that owns `sessionKey`,
179
+ * reading the live OpenClaw config. Prefers the target agent's own `model`/`thinkingDefault` over
180
+ * the global `agents.defaults`, so non-main agents aren't silently forced onto the global default.
181
+ *
182
+ * Used to write the default model as an **explicit** override when the app selects it (the app
183
+ * sends no modelRef for the default). Writing it explicitly — rather than clearing the stored
184
+ * override — keeps the shared session entry consistent with the core's provenance fields
185
+ * (`modelOverrideSource`, `model`, `modelProvider`); a bare clear leaves those dangling and the
186
+ * agent mis-resolves to a fallback model.
187
+ */
188
+ export function resolveAgentDefaults(sessionKey) {
189
+ try {
190
+ const forwardRt = getFridayAgentForwardRuntime();
191
+ if (!forwardRt)
192
+ return {};
193
+ const ocCfg = (forwardRt.getConfig() ?? {});
194
+ const agents = ocCfg.agents;
195
+ const targetAgentId = agentIdFromSessionKey(sessionKey);
196
+ const agentEntry = agents?.list?.find((a) => agentIdFromSessionKey(`agent:${String(a?.id ?? "")}:x`) === targetAgentId);
197
+ const agentModel = agentEntry?.model;
198
+ const perAgentModel = typeof agentModel === "string"
199
+ ? agentModel
200
+ : typeof agentModel?.primary === "string"
201
+ ? agentModel.primary
202
+ : undefined;
203
+ const perAgentThinking = typeof agentEntry?.thinkingDefault === "string" ? agentEntry.thinkingDefault : undefined;
204
+ const agentDefaults = agents?.defaults;
205
+ const model = agentDefaults?.model;
206
+ const globalModel = typeof model?.primary === "string" ? model.primary : undefined;
207
+ const globalThinking = typeof agentDefaults?.thinkingDefault === "string" ? agentDefaults.thinkingDefault : undefined;
208
+ return { model: perAgentModel ?? globalModel, thinking: perAgentThinking ?? globalThinking };
209
+ }
210
+ catch {
211
+ // Config not available (e.g. unit tests) — caller decides the fallback.
212
+ return {};
213
+ }
214
+ }
package/install.js CHANGED
@@ -238,14 +238,22 @@ if (configChanged) {
238
238
 
239
239
  // --------------- restart gateway ---------------
240
240
 
241
- log("Restarting OpenClaw gateway...");
241
+ log("Restarting OpenClaw gateway... (this can take 20-30s)");
242
242
  try {
243
- const out = execSync(`${openclawCmd} gateway restart`, { encoding: "utf8", stdio: "pipe", timeout: 15000 });
243
+ // A full gateway restart commonly takes 20s+ on a fresh boot; give it plenty of room
244
+ // so we don't kill it mid-restart and report a false failure.
245
+ const out = execSync(`${openclawCmd} gateway restart`, { encoding: "utf8", stdio: "pipe", timeout: 90000 });
244
246
  if (out.trim()) console.log(out.trim());
245
247
  } catch (e) {
246
248
  if (e.stdout?.trim()) console.log(e.stdout.trim());
247
249
  if (e.stderr?.trim()) console.error(e.stderr.trim());
248
- warn("Gateway restart failed. Restart manually: openclaw gateway restart");
250
+ // ETIMEDOUT/SIGTERM here usually means the restart is simply slow, not broken
251
+ // the verify step below will confirm whether the gateway actually came up.
252
+ if (e.code === "ETIMEDOUT" || e.signal === "SIGTERM") {
253
+ warn("Gateway restart is taking a while — will verify below.");
254
+ } else {
255
+ warn("Gateway restart failed. Restart manually: openclaw gateway restart");
256
+ }
249
257
  }
250
258
 
251
259
  // --------------- verify ---------------
@@ -270,7 +278,11 @@ const gatewayUrl = bindMode === "lan"
270
278
  ? `http://${getLanIp()}:${gatewayPort}`
271
279
  : `http://127.0.0.1:${gatewayPort}`;
272
280
 
273
- async function verifyGateway(url, token, retries = 6) {
281
+ // Always verify against loopback: the gateway binds 0.0.0.0 so it's reachable here,
282
+ // and this avoids false negatives from LAN/NAT routing of the advertised IP.
283
+ const verifyUrl = `http://127.0.0.1:${gatewayPort}`;
284
+
285
+ async function verifyGateway(url, token, retries = 30) {
274
286
  const http = await import("node:http");
275
287
  const { hostname, port } = new URL(url);
276
288
  for (let i = 1; i <= retries; i++) {
@@ -312,7 +324,7 @@ async function verifyGateway(url, token, retries = 6) {
312
324
  }
313
325
 
314
326
  log("Verifying gateway...");
315
- const verified = await verifyGateway(gatewayUrl, gatewayToken);
327
+ const verified = await verifyGateway(verifyUrl, gatewayToken);
316
328
 
317
329
  // --------------- show connection info ---------------
318
330
 
@@ -372,4 +384,39 @@ if (ipType === "tailscale") {
372
384
  } else {
373
385
  log("This URL appears to be publicly accessible (" + ip + ").");
374
386
  }
387
+
388
+ // On a cloud server the advertised IP is the internal/NAT address, which a phone
389
+ // over the internet can't reach. Best-effort: detect the public IP so the user
390
+ // has the address to actually connect with (and a reminder to open the port).
391
+ if (ipType === "private" || ipType === "loopback") {
392
+ const publicIp = await detectPublicIp();
393
+ if (publicIp && publicIp !== ip) {
394
+ log("");
395
+ log(BOLD_YELLOW("Looks like a cloud server. For remote access use the PUBLIC address:"));
396
+ log(BOLD_YELLOW("检测到云服务器,远程连接请改用公网地址:"));
397
+ log("Public URL: " + BOLD_YELLOW(`http://${publicIp}:${gatewayPort}`));
398
+ log("(Open inbound TCP port " + gatewayPort + " in your firewall / security group first.)");
399
+ log("(需先在防火墙/安全组放行入站 TCP 端口 " + gatewayPort + "。)");
400
+ }
401
+ }
375
402
  log("--------------------------------------------------");
403
+
404
+ async function detectPublicIp() {
405
+ const endpoints = ["http://api.ipify.org", "http://ifconfig.me/ip", "http://icanhazip.com"];
406
+ const http = await import("node:http");
407
+ for (const url of endpoints) {
408
+ try {
409
+ const ipStr = await new Promise((resolve, reject) => {
410
+ const req = http.get(url, { timeout: 3000 }, (res) => {
411
+ let body = "";
412
+ res.on("data", (c) => body += c);
413
+ res.on("end", () => resolve(body.trim()));
414
+ });
415
+ req.on("error", reject);
416
+ req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
417
+ });
418
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ipStr)) return ipStr;
419
+ } catch { /* try next */ }
420
+ }
421
+ return null;
422
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syengup/friday-channel-next",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "OpenClaw Friday Next Apple channel plugin",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,6 +12,15 @@
12
12
  "tsconfig.json",
13
13
  "openclaw.plugin.json"
14
14
  ],
15
+ "scripts": {
16
+ "build": "tsc -p tsconfig.json",
17
+ "prepublishOnly": "pnpm build && rm -rf dist/attachments",
18
+ "test": "npm run test:unit && npm run test:e2e",
19
+ "test:unit": "vitest run",
20
+ "test:e2e": "vitest run --config vitest.e2e.config.ts",
21
+ "test:smoke": "node scripts/e2e-smoke.mjs",
22
+ "test:msg-live": "node scripts/message-roundtrip-live.mjs"
23
+ },
15
24
  "bin": {
16
25
  "friday-channel-next": "install.js"
17
26
  },
@@ -57,13 +66,5 @@
57
66
  "typescript": "^6.0.3",
58
67
  "vitest": "^4.1.5",
59
68
  "zod": "^4.3.6"
60
- },
61
- "scripts": {
62
- "build": "tsc -p tsconfig.json",
63
- "test": "npm run test:unit && npm run test:e2e",
64
- "test:unit": "vitest run",
65
- "test:e2e": "vitest run --config vitest.e2e.config.ts",
66
- "test:smoke": "node scripts/e2e-smoke.mjs",
67
- "test:msg-live": "node scripts/message-roundtrip-live.mjs"
68
69
  }
69
- }
70
+ }
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { fridayNextChannelPlugin } from "./channel.js";
3
+
4
+ /**
5
+ * friday-next is a transparent proxy: outbound attachments/text already reach the app
6
+ * live via SSE (sendText/sendMedia/handleSend). OpenClaw core's generic outbound path
7
+ * additionally mirrors message-tool sends into the *recipient's* session transcript
8
+ * (model:"delivery-mirror"). For friday-next that recipient session falls back to
9
+ * `agent:<agentId>:friday-next:direct:<deviceId>` — an orphan session unrelated to the
10
+ * app's real conversation — producing a phantom session + a stray delivery-mirror message.
11
+ *
12
+ * The core consults `messaging.resolveOutboundSessionRoute` FIRST; returning `null`
13
+ * short-circuits route resolution so no orphan session entry and no delivery-mirror are
14
+ * created. This test pins that contract.
15
+ */
16
+ describe("friday-next channel suppresses core delivery-mirror", () => {
17
+ const messaging = (fridayNextChannelPlugin as { messaging?: Record<string, unknown> }).messaging;
18
+
19
+ it("exposes resolveOutboundSessionRoute on the messaging adapter", () => {
20
+ expect(typeof messaging?.resolveOutboundSessionRoute).toBe("function");
21
+ });
22
+
23
+ it("returns null for any outbound target so no mirror session is routed", async () => {
24
+ const resolve = messaging?.resolveOutboundSessionRoute as (
25
+ params: Record<string, unknown>,
26
+ ) => unknown;
27
+ const route = await resolve({
28
+ cfg: {},
29
+ agentId: "operator",
30
+ channel: "friday-next",
31
+ target: "9cd3d546-b230-40ab-b931-bb2e8305e38c",
32
+ currentSessionKey: "agent:operator:friday-next:9cd3d546",
33
+ });
34
+ expect(route).toBeNull();
35
+ });
36
+ });
package/src/channel.ts CHANGED
@@ -160,6 +160,15 @@ export const fridayNextChannelPlugin = createChatChannelPlugin({
160
160
  },
161
161
  parseExplicitTarget: () => ({ to: "friday-next" }),
162
162
  formatTargetDisplay: ({ display }: any) => display || "Friday Next",
163
+ // friday-next is a transparent proxy: outbound text/media already reach the app live
164
+ // via SSE (sendText/sendMedia/handleSend). The OpenClaw core additionally mirrors
165
+ // message-tool sends into the recipient's session transcript (model:"delivery-mirror").
166
+ // For friday-next that recipient session falls back to
167
+ // `agent:<agentId>:friday-next:direct:<deviceId>` — an orphan session unrelated to the
168
+ // app's real conversation — spawning a phantom session + a stray delivery-mirror message.
169
+ // The core checks this hook first; returning null short-circuits route resolution so no
170
+ // orphan session entry and no delivery-mirror are written.
171
+ resolveOutboundSessionRoute: () => null,
163
172
  },
164
173
  },
165
174
  outbound: {
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isCanvasSnapshotMediaPath, translateDeliverPayload } from "./messages.js";
3
+
4
+ /**
5
+ * canvas.snapshot tool results are an agent-facing capture. OpenClaw core surfaces image tool
6
+ * results as deliverable media on the assistant reply block, which would otherwise auto-attach the
7
+ * snapshot to the user's stream. translateDeliverPayload must drop snapshot temp files (by basename)
8
+ * while preserving the assistant text and any non-snapshot media.
9
+ */
10
+ describe("canvas snapshot media suppression", () => {
11
+ it("detects canvas snapshot temp paths by basename", () => {
12
+ expect(
13
+ isCanvasSnapshotMediaPath("/tmp/openclaw/openclaw-canvas-snapshot-d2e6aef2-0441.jpg"),
14
+ ).toBe(true);
15
+ expect(isCanvasSnapshotMediaPath("C:\\tmp\\openclaw-canvas-snapshot-abc.png")).toBe(true);
16
+ expect(isCanvasSnapshotMediaPath("/Users/me/Pictures/screenshot_latest.jpg")).toBe(false);
17
+ expect(isCanvasSnapshotMediaPath("/friday-next/files/uuid.jpg")).toBe(false);
18
+ expect(isCanvasSnapshotMediaPath(undefined)).toBe(false);
19
+ });
20
+
21
+ it("strips a snapshot mediaUrl from a block deliver payload, keeping the text", () => {
22
+ const out = translateDeliverPayload(
23
+ {
24
+ text: "🎉 通了!",
25
+ mediaUrl: "/tmp/openclaw/openclaw-canvas-snapshot-d2e6aef2-0441.jpg",
26
+ },
27
+ "block",
28
+ );
29
+ expect(out.text).toBe("🎉 通了!");
30
+ expect(out.mediaUrl).toBeNull();
31
+ // No media survived → no image mediaKind tagged onto the payload.
32
+ const channelData = out.channelData as { fridayNext?: { mediaKind?: string } } | undefined;
33
+ expect(channelData?.fridayNext?.mediaKind).toBeUndefined();
34
+ });
35
+
36
+ it("strips snapshot entries from mediaUrls but preserves non-snapshot media", () => {
37
+ const out = translateDeliverPayload(
38
+ {
39
+ text: "page rendered",
40
+ mediaUrls: [
41
+ "/tmp/openclaw/openclaw-canvas-snapshot-aaa.jpg",
42
+ "/Users/me/Pictures/real-image.png",
43
+ ],
44
+ },
45
+ "block",
46
+ );
47
+ const urls = out.mediaUrls as string[];
48
+ expect(urls).toHaveLength(1);
49
+ expect(urls.some((u) => u.includes("canvas-snapshot-"))).toBe(false);
50
+ });
51
+ });
@@ -27,12 +27,12 @@ export type FridayReplyPayload = {
27
27
  import { resolveFridayNextConfig } from "../../config.js";
28
28
  import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
29
29
  import { getFridayNextRuntime } from "../../runtime.js";
30
- import { getFridayAgentForwardRuntime } from "../../agent-forward-runtime.js";
31
30
  import {
32
- agentIdFromSessionKey,
31
+ resolveAgentDefaults,
33
32
  setSessionSettings,
34
33
  splitModelRef,
35
34
  toSessionStoreKey,
35
+ type FridaySessionSettingsUpdate,
36
36
  } from "../../session/session-manager.js";
37
37
  import { sseEmitter } from "../../sse/emitter.js";
38
38
  import { extractBearerToken } from "../middleware/auth.js";
@@ -132,11 +132,34 @@ function inferFridayNextMediaKind(params: {
132
132
  }
133
133
 
134
134
  /** Map local / gateway paths to public `/friday-next/files/...` URLs where possible. */
135
- function translateDeliverPayload(
135
+ /**
136
+ * Canvas snapshots are captured so the *agent* can "see" the rendered canvas. OpenClaw core surfaces
137
+ * any image tool result as deliverable media on the assistant reply block, which for Friday Next would
138
+ * make the snapshot auto-appear as an attachment mid-stream — not what we want. Snapshot temp files are
139
+ * named `openclaw-canvas-snapshot-<uuid>.<ext>`, so we detect them by basename and drop them from the
140
+ * delivered payload (the assistant text is preserved). Agent-initiated media sends are unaffected —
141
+ * those flow through the `outbound` channel action, not the deliver block path.
142
+ */
143
+ export function isCanvasSnapshotMediaPath(url: unknown): boolean {
144
+ if (typeof url !== "string") return false;
145
+ const base = url.split(/[/\\]/).pop() ?? url;
146
+ return /canvas-snapshot-/i.test(base);
147
+ }
148
+
149
+ export function translateDeliverPayload(
136
150
  pl: FridayReplyPayload,
137
151
  kind: string,
138
152
  meta?: { modelName?: string; totalTokens?: number; contextTokensUsed?: number; contextWindowMax?: number },
139
153
  ): Record<string, unknown> {
154
+ // Strip canvas-snapshot tool-result images before any media resolution (paths here are still the
155
+ // original `/tmp/openclaw/openclaw-canvas-snapshot-*.jpg` temp paths, not yet copied to friday files).
156
+ const filteredSingle =
157
+ typeof pl.mediaUrl === "string" && !isCanvasSnapshotMediaPath(pl.mediaUrl) ? pl.mediaUrl : null;
158
+ const filteredArr = Array.isArray(pl.mediaUrls)
159
+ ? pl.mediaUrls.filter((u) => !isCanvasSnapshotMediaPath(u))
160
+ : pl.mediaUrls;
161
+ pl = { ...pl, mediaUrl: filteredSingle, mediaUrls: filteredArr };
162
+
140
163
  const raw = { ...pl } as Record<string, unknown>;
141
164
  const originalUrls = collectReplyPayloadMediaUrls(pl);
142
165
  if (typeof pl.mediaUrl === "string" && pl.mediaUrl.trim()) {
@@ -425,58 +448,20 @@ export async function handleMessages(req: IncomingMessage, res: ServerResponse):
425
448
 
426
449
  const cfg = resolveFridayNextConfig(getHostOpenClawConfigSnapshot(runtime.config));
427
450
 
428
- // Resolve defaults from the OpenClaw agent config so settings are never left empty.
429
- // The target agent comes from the app-supplied sessionKey (`agent:<id>:<rest>`); prefer that
430
- // agent's own configured model/thinking over the global defaults so non-main agents are not
431
- // silently forced onto the global default model.
432
- const targetAgentId = agentIdFromSessionKey(baseSessionKey);
433
- let defaultModel: string | undefined;
434
- let defaultThinking: string | undefined;
435
- try {
436
- const forwardRt = getFridayAgentForwardRuntime();
437
- if (forwardRt) {
438
- const ocCfg = (forwardRt.getConfig() ?? {}) as Record<string, unknown>;
439
- const agents = ocCfg.agents as Record<string, unknown> | undefined;
440
-
441
- const agentEntry = (agents?.list as Array<Record<string, unknown>> | undefined)?.find(
442
- (a) => agentIdFromSessionKey(`agent:${String(a?.id ?? "")}:x`) === targetAgentId,
443
- );
444
- const agentModel = agentEntry?.model;
445
- const perAgentModel =
446
- typeof agentModel === "string"
447
- ? agentModel
448
- : typeof (agentModel as Record<string, unknown> | undefined)?.primary === "string"
449
- ? ((agentModel as Record<string, unknown>).primary as string)
450
- : undefined;
451
- const perAgentThinking =
452
- typeof agentEntry?.thinkingDefault === "string"
453
- ? (agentEntry.thinkingDefault as string)
454
- : undefined;
455
-
456
- const agentDefaults = agents?.defaults as Record<string, unknown> | undefined;
457
- const model = agentDefaults?.model as Record<string, unknown> | undefined;
458
- const globalModel = typeof model?.primary === "string" ? (model.primary as string) : undefined;
459
- const globalThinking =
460
- typeof agentDefaults?.thinkingDefault === "string"
461
- ? (agentDefaults.thinkingDefault as string)
462
- : undefined;
463
-
464
- defaultModel = perAgentModel ?? globalModel;
465
- defaultThinking = perAgentThinking ?? globalThinking;
466
- }
467
- } catch {
468
- // Config not available (tests) — leave defaults undefined.
469
- }
451
+ // Resolve defaults from the OpenClaw agent config so settings are never left empty. Prefers the
452
+ // target agent's own model/thinking over the global defaults (see resolveAgentDefaults).
453
+ const { model: defaultModel, thinking: defaultThinking } = resolveAgentDefaults(baseSessionKey);
470
454
 
471
455
  const modelRef = payload.modelRef ?? defaultModel;
472
456
  const reasoningLevel = payload.reasoningLevel ?? "stream";
473
457
  const thinkingLevel = payload.thinkingLevel ?? defaultThinking;
474
458
 
475
- const settings: Record<string, string | undefined> = {};
459
+ const settings: FridaySessionSettingsUpdate = {};
476
460
  if (modelRef) {
477
461
  settings.modelRef = modelRef;
478
462
  const split = splitModelRef(modelRef);
479
- settings.providerOverride = split.provider;
463
+ // `?? null` clears a stale provider when the resolved ref is bare (no `provider/` prefix).
464
+ settings.providerOverride = split.provider ?? null;
480
465
  settings.modelOverride = split.modelId;
481
466
  }
482
467
  if (reasoningLevel) settings.reasoningLevel = reasoningLevel;
@@ -3,6 +3,8 @@ import {
3
3
  setSessionSettings,
4
4
  getSessionSettings,
5
5
  splitModelRef,
6
+ resolveAgentDefaults,
7
+ type FridaySessionSettingsUpdate,
6
8
  } from "../../session/session-manager.js";
7
9
  import { readJsonBody } from "../middleware/body.js";
8
10
  import { extractBearerToken } from "../middleware/auth.js";
@@ -57,7 +59,7 @@ export async function handleSessionsSettings(
57
59
 
58
60
  const reasoningLevel = typeof body?.reasoningLevel === "string" ? body.reasoningLevel : undefined;
59
61
  const thinkingLevel = typeof body?.thinkingLevel === "string" ? body.thinkingLevel : undefined;
60
- const modelRef = typeof body?.modelRef === "string" ? body.modelRef : undefined;
62
+ const modelRef = typeof body?.modelRef === "string" ? body.modelRef.trim() : undefined;
61
63
 
62
64
  const errors: string[] = [];
63
65
  if (reasoningLevel !== undefined && !VALID_REASONING.has(reasoningLevel)) {
@@ -74,11 +76,26 @@ export async function handleSessionsSettings(
74
76
  return true;
75
77
  }
76
78
 
77
- const settings: Record<string, string | undefined> = { reasoningLevel, thinkingLevel, modelRef };
78
- if (modelRef) {
79
- const split = splitModelRef(modelRef);
80
- settings["providerOverride"] = split.provider;
81
- settings["modelOverride"] = split.modelId;
79
+ // The app omits (or empties) modelRef to mean "use the agent's default model". Resolve that
80
+ // default and write it as an *explicit* override, identical in shape to any other selection — so
81
+ // the agent runs the default exactly the way it runs an explicitly-picked model. Do NOT just
82
+ // clear the override here: the session entry is shared with the OpenClaw core, which stamps it
83
+ // with provenance fields (`modelOverrideSource`, `model`, `modelProvider`); deleting only our
84
+ // three fields leaves those dangling and the core mis-resolves to a fallback model.
85
+ const effectiveModelRef = modelRef || resolveAgentDefaults(sessionKey).model;
86
+
87
+ const settings: FridaySessionSettingsUpdate = { reasoningLevel, thinkingLevel };
88
+ if (effectiveModelRef) {
89
+ const split = splitModelRef(effectiveModelRef);
90
+ settings.modelRef = effectiveModelRef;
91
+ // `?? null` clears a stale provider when the ref is bare (no `provider/` prefix).
92
+ settings.providerOverride = split.provider ?? null;
93
+ settings.modelOverride = split.modelId;
94
+ } else {
95
+ // No configured default to resolve (e.g. config unavailable) — clear rather than pin a stale model.
96
+ settings.modelRef = null;
97
+ settings.providerOverride = null;
98
+ settings.modelOverride = null;
82
99
  }
83
100
 
84
101
  const result = setSessionSettings(sessionKey, settings);
@@ -87,4 +87,46 @@ describe("per-agent session settings file routing", () => {
87
87
 
88
88
  expect(readEntry("main", "agent:main:main")?.thinkingLevel).toBe("low");
89
89
  });
90
+
91
+ it("clears a stored model override when the trio is set to null (default re-selected)", () => {
92
+ seedSessionsFile("main");
93
+
94
+ // Prior non-default selection.
95
+ setSessionSettings(
96
+ "main",
97
+ { modelRef: "openai/gpt-x", providerOverride: "openai", modelOverride: "gpt-x" },
98
+ historyDir,
99
+ );
100
+ expect(getSessionSettings("main", historyDir).modelRef).toBe("openai/gpt-x");
101
+
102
+ // Switching back to the agent default sends nulls → override is removed, not merged.
103
+ setSessionSettings(
104
+ "main",
105
+ { modelRef: null, providerOverride: null, modelOverride: null },
106
+ historyDir,
107
+ );
108
+
109
+ const entry = readEntry("main", "agent:main:main")!;
110
+ expect(entry.modelRef).toBeUndefined();
111
+ expect(entry.providerOverride).toBeUndefined();
112
+ expect(entry.modelOverride).toBeUndefined();
113
+ expect(getSessionSettings("main", historyDir).modelRef).toBeUndefined();
114
+ });
115
+
116
+ it("leaves the stored model override untouched when fields are undefined", () => {
117
+ seedSessionsFile("main");
118
+
119
+ setSessionSettings(
120
+ "main",
121
+ { modelRef: "openai/gpt-x", providerOverride: "openai", modelOverride: "gpt-x" },
122
+ historyDir,
123
+ );
124
+ // A thinking-only update (model fields undefined) must not disturb the override.
125
+ setSessionSettings("main", { thinkingLevel: "high" }, historyDir);
126
+
127
+ const entry = readEntry("main", "agent:main:main")!;
128
+ expect(entry.thinkingLevel).toBe("high");
129
+ expect(entry.modelRef).toBe("openai/gpt-x");
130
+ expect(entry.providerOverride).toBe("openai");
131
+ });
90
132
  });
@@ -1,6 +1,7 @@
1
1
  import { join } from "node:path";
2
2
  import os from "node:os";
3
3
  import { readFileSync, writeFileSync } from "node:fs";
4
+ import { getFridayAgentForwardRuntime } from "../agent-forward-runtime.js";
4
5
 
5
6
  const FRIDAY_AGENT_ID = "main";
6
7
  const SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i;
@@ -132,9 +133,24 @@ export interface FridaySessionSettings {
132
133
  modelOverride?: string;
133
134
  }
134
135
 
136
+ /**
137
+ * Update shape for {@link setSessionSettings}. A field set to a string writes that value, a field
138
+ * left `undefined` is untouched, and a field set to `null` **clears** the stored value. The `null`
139
+ * case is what lets the app reset a model override back to the agent default — without it the merge
140
+ * could only ever add/replace overrides, never remove them (the cause of "selecting the default
141
+ * model doesn't take effect": a prior `provider/model` override survived and was read back).
142
+ */
143
+ export type FridaySessionSettingsUpdate = {
144
+ reasoningLevel?: string | null;
145
+ thinkingLevel?: string | null;
146
+ modelRef?: string | null;
147
+ providerOverride?: string | null;
148
+ modelOverride?: string | null;
149
+ };
150
+
135
151
  export function setSessionSettings(
136
152
  sessionKey: string,
137
- settings: FridaySessionSettings,
153
+ settings: FridaySessionSettingsUpdate,
138
154
  historyDir?: string,
139
155
  ): FridaySessionSettings {
140
156
  try {
@@ -145,13 +161,22 @@ export function setSessionSettings(
145
161
 
146
162
  upsertSessionEntry(data, fileKey, sessionKey);
147
163
 
148
- const fieldKeys: (keyof FridaySessionSettings)[] = [
164
+ const fieldKeys: (keyof FridaySessionSettingsUpdate)[] = [
149
165
  "reasoningLevel", "thinkingLevel", "modelRef", "providerOverride", "modelOverride",
150
166
  ];
151
167
  let updated = false;
152
168
  for (const key of fieldKeys) {
153
169
  const value = settings[key];
154
- if (value !== undefined && data[fileKey][key] !== value) {
170
+ if (value === undefined) continue; // leave the stored value untouched
171
+ if (value === null) {
172
+ // Explicit clear — remove the override so the agent falls back to its default.
173
+ if (key in data[fileKey]) {
174
+ delete data[fileKey][key];
175
+ updated = true;
176
+ }
177
+ continue;
178
+ }
179
+ if (data[fileKey][key] !== value) {
155
180
  data[fileKey][key] = value;
156
181
  updated = true;
157
182
  }
@@ -197,3 +222,48 @@ export function getSessionSettings(
197
222
  return {};
198
223
  }
199
224
  }
225
+
226
+ /**
227
+ * Resolve the configured default model + thinking level for the agent that owns `sessionKey`,
228
+ * reading the live OpenClaw config. Prefers the target agent's own `model`/`thinkingDefault` over
229
+ * the global `agents.defaults`, so non-main agents aren't silently forced onto the global default.
230
+ *
231
+ * Used to write the default model as an **explicit** override when the app selects it (the app
232
+ * sends no modelRef for the default). Writing it explicitly — rather than clearing the stored
233
+ * override — keeps the shared session entry consistent with the core's provenance fields
234
+ * (`modelOverrideSource`, `model`, `modelProvider`); a bare clear leaves those dangling and the
235
+ * agent mis-resolves to a fallback model.
236
+ */
237
+ export function resolveAgentDefaults(sessionKey: string): { model?: string; thinking?: string } {
238
+ try {
239
+ const forwardRt = getFridayAgentForwardRuntime();
240
+ if (!forwardRt) return {};
241
+ const ocCfg = (forwardRt.getConfig() ?? {}) as Record<string, unknown>;
242
+ const agents = ocCfg.agents as Record<string, unknown> | undefined;
243
+ const targetAgentId = agentIdFromSessionKey(sessionKey);
244
+
245
+ const agentEntry = (agents?.list as Array<Record<string, unknown>> | undefined)?.find(
246
+ (a) => agentIdFromSessionKey(`agent:${String(a?.id ?? "")}:x`) === targetAgentId,
247
+ );
248
+ const agentModel = agentEntry?.model;
249
+ const perAgentModel =
250
+ typeof agentModel === "string"
251
+ ? agentModel
252
+ : typeof (agentModel as Record<string, unknown> | undefined)?.primary === "string"
253
+ ? ((agentModel as Record<string, unknown>).primary as string)
254
+ : undefined;
255
+ const perAgentThinking =
256
+ typeof agentEntry?.thinkingDefault === "string" ? (agentEntry.thinkingDefault as string) : undefined;
257
+
258
+ const agentDefaults = agents?.defaults as Record<string, unknown> | undefined;
259
+ const model = agentDefaults?.model as Record<string, unknown> | undefined;
260
+ const globalModel = typeof model?.primary === "string" ? (model.primary as string) : undefined;
261
+ const globalThinking =
262
+ typeof agentDefaults?.thinkingDefault === "string" ? (agentDefaults.thinkingDefault as string) : undefined;
263
+
264
+ return { model: perAgentModel ?? globalModel, thinking: perAgentThinking ?? globalThinking };
265
+ } catch {
266
+ // Config not available (e.g. unit tests) — caller decides the fallback.
267
+ return {};
268
+ }
269
+ }