@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.
- package/dist/src/channel.js +9 -0
- package/dist/src/http/handlers/messages.js +6 -38
- package/dist/src/http/handlers/sessions-settings.js +22 -7
- package/dist/src/session/session-manager.d.ts +30 -1
- package/dist/src/session/session-manager.js +50 -1
- package/package.json +11 -10
- package/src/channel.outbound-mirror-suppression.test.ts +36 -0
- package/src/channel.ts +9 -0
- package/src/http/handlers/messages.ts +8 -46
- package/src/http/handlers/sessions-settings.ts +23 -6
- package/src/session/session-manager.test.ts +42 -0
- package/src/session/session-manager.ts +73 -3
package/dist/src/channel.js
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
319
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
430
|
-
|
|
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:
|
|
436
|
+
const settings: FridaySessionSettingsUpdate = {};
|
|
476
437
|
if (modelRef) {
|
|
477
438
|
settings.modelRef = modelRef;
|
|
478
439
|
const split = splitModelRef(modelRef);
|
|
479
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
+
}
|