@syengup/friday-channel-next 0.1.14 → 0.1.15

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: {
@@ -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";
@@ -314,41 +313,9 @@ export async function handleMessages(req, res) {
314
313
  res.end(JSON.stringify({ accepted: true, deviceId: normalizedDeviceId, runId }));
315
314
  log("MESSAGE_RECEIVED", normalizedDeviceId, runId, `textLen=${trimmedText.length} attachments=${attachments.length} sessionKey=${baseSessionKey}`);
316
315
  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
- }
316
+ // Resolve defaults from the OpenClaw agent config so settings are never left empty. Prefers the
317
+ // target agent's own model/thinking over the global defaults (see resolveAgentDefaults).
318
+ const { model: defaultModel, thinking: defaultThinking } = resolveAgentDefaults(baseSessionKey);
352
319
  const modelRef = payload.modelRef ?? defaultModel;
353
320
  const reasoningLevel = payload.reasoningLevel ?? "stream";
354
321
  const thinkingLevel = payload.thinkingLevel ?? defaultThinking;
@@ -356,7 +323,8 @@ export async function handleMessages(req, res) {
356
323
  if (modelRef) {
357
324
  settings.modelRef = modelRef;
358
325
  const split = splitModelRef(modelRef);
359
- settings.providerOverride = split.provider;
326
+ // `?? null` clears a stale provider when the resolved ref is bare (no `provider/` prefix).
327
+ settings.providerOverride = split.provider ?? null;
360
328
  settings.modelOverride = split.modelId;
361
329
  }
362
330
  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/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.15",
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: {
@@ -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";
@@ -425,58 +425,20 @@ export async function handleMessages(req: IncomingMessage, res: ServerResponse):
425
425
 
426
426
  const cfg = resolveFridayNextConfig(getHostOpenClawConfigSnapshot(runtime.config));
427
427
 
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
- }
428
+ // Resolve defaults from the OpenClaw agent config so settings are never left empty. Prefers the
429
+ // target agent's own model/thinking over the global defaults (see resolveAgentDefaults).
430
+ const { model: defaultModel, thinking: defaultThinking } = resolveAgentDefaults(baseSessionKey);
470
431
 
471
432
  const modelRef = payload.modelRef ?? defaultModel;
472
433
  const reasoningLevel = payload.reasoningLevel ?? "stream";
473
434
  const thinkingLevel = payload.thinkingLevel ?? defaultThinking;
474
435
 
475
- const settings: Record<string, string | undefined> = {};
436
+ const settings: FridaySessionSettingsUpdate = {};
476
437
  if (modelRef) {
477
438
  settings.modelRef = modelRef;
478
439
  const split = splitModelRef(modelRef);
479
- settings.providerOverride = split.provider;
440
+ // `?? null` clears a stale provider when the resolved ref is bare (no `provider/` prefix).
441
+ settings.providerOverride = split.provider ?? null;
480
442
  settings.modelOverride = split.modelId;
481
443
  }
482
444
  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
+ }