@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.
- package/dist/src/channel.js +9 -0
- package/dist/src/http/handlers/messages.d.ts +16 -0
- package/dist/src/http/handlers/messages.js +28 -39
- 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/install.js +52 -5
- 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.canvas-snapshot.test.ts +51 -0
- package/src/http/handlers/messages.ts +32 -47
- 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: {
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
}
|
|
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:
|
|
459
|
+
const settings: FridaySessionSettingsUpdate = {};
|
|
476
460
|
if (modelRef) {
|
|
477
461
|
settings.modelRef = modelRef;
|
|
478
462
|
const split = splitModelRef(modelRef);
|
|
479
|
-
|
|
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
|
-
|
|
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
|
+
}
|