@syengup/friday-channel-next 1.0.1 → 1.0.4
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/index.js +9 -4
- package/dist/src/friday-session.js +17 -0
- package/dist/src/history/normalize-message.js +15 -0
- package/dist/src/http/handlers/messages.js +10 -0
- package/index.ts +9 -4
- package/package.json +1 -1
- package/src/friday-session.forward-agent.test.ts +52 -0
- package/src/friday-session.ts +20 -0
- package/src/history/normalize-message.test.ts +27 -0
- package/src/history/normalize-message.ts +15 -0
- package/src/http/handlers/messages.ts +10 -0
package/dist/index.js
CHANGED
|
@@ -16,8 +16,6 @@ import { ensureCodexReasoningSummary } from "./src/codex-reasoning-config.js";
|
|
|
16
16
|
const hookLogger = createFridayNextLogger("hook");
|
|
17
17
|
export { fridayNextChannelPlugin } from "./src/channel.js";
|
|
18
18
|
export { setFridayNextRuntime } from "./src/runtime.js";
|
|
19
|
-
/** `api.on` returns void — register tool hooks at most once per process. */
|
|
20
|
-
let fridayNextToolHooksRegistered = false;
|
|
21
19
|
let disposeAgentEventListener = null;
|
|
22
20
|
/**
|
|
23
21
|
* Track the last `api` instance on which HTTP routes were registered.
|
|
@@ -142,10 +140,17 @@ export default defineChannelPluginEntry({
|
|
|
142
140
|
total: event.usage?.total,
|
|
143
141
|
}, event.model, event.provider);
|
|
144
142
|
});
|
|
145
|
-
|
|
143
|
+
// Tool hooks (subagent_delivery_target / before_tool_call / after_tool_call) must follow the
|
|
144
|
+
// SAME re-registration discipline as the HTTP routes and onAgentEvent listener above. When the
|
|
145
|
+
// health-monitor restarts the plugin, `registerFull` receives a fresh `api` and the host
|
|
146
|
+
// dispatches hooks on THAT api; hooks left bound to the first api go silent for the rest of the
|
|
147
|
+
// process. The old one-time boolean guard bound them to whichever api arrived first — which is
|
|
148
|
+
// exactly why Codex `command_output` (the A3 after_tool_call stdout synthesis) fired on some
|
|
149
|
+
// gateway processes and not others. Re-register on every genuinely-new api; skip only a repeat
|
|
150
|
+
// call with the same api (matching the `!sameApi` gate the routes use).
|
|
151
|
+
if (sameApi) {
|
|
146
152
|
return;
|
|
147
153
|
}
|
|
148
|
-
fridayNextToolHooksRegistered = true;
|
|
149
154
|
// Make Codex (ChatGPT/OAuth) models emit reasoning summary text so the app can stream
|
|
150
155
|
// "thinking". OpenClaw never sets this; we assert it on the plugin side. Best-effort.
|
|
151
156
|
ensureCodexReasoningSummary((msg) => hookLogger.info(msg));
|
|
@@ -343,6 +343,23 @@ export function forwardAgentEventRaw(evt) {
|
|
|
343
343
|
});
|
|
344
344
|
}
|
|
345
345
|
}
|
|
346
|
+
// Codex app-server projects every tool/command call onto BOTH the standard `tool` stream
|
|
347
|
+
// (carrying args + the real result) AND a redundant `item` event (kind:"tool"/"command"),
|
|
348
|
+
// and core flags that item `suppressChannelProgress: true` ("do not surface in channel
|
|
349
|
+
// progress"). Forwarding the suppressed *tool* item double-renders every non-exec tool in the
|
|
350
|
+
// app — the `tool`-stream row plus a second `item kind:tool` row, with the result landing only
|
|
351
|
+
// on the first. So drop ONLY suppressed `kind:"tool"` items.
|
|
352
|
+
//
|
|
353
|
+
// BUT keep suppressed `kind:"command"`/`"process"` items: the `tool` stream's exec result is
|
|
354
|
+
// just {exitCode,duration} (no stdout), and the app bootstraps its command-terminal row from
|
|
355
|
+
// the `item kind:command` event — that row is what the synthesized `command_output` end event
|
|
356
|
+
// then attaches the real stdout to. Dropping it left exec tools with a command line and no
|
|
357
|
+
// output in the trace. (Reasoning items preamble/analysis are never suppressed.)
|
|
358
|
+
if (evt.stream === "item" &&
|
|
359
|
+
evt.data.suppressChannelProgress === true &&
|
|
360
|
+
evt.data.kind === "tool") {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
346
363
|
// Register sessionKey → runId so we can resolve parentRunId
|
|
347
364
|
if (sk && evt.stream === "lifecycle" && evt.data.phase === "start") {
|
|
348
365
|
registerSessionKeyForRun(sk, evt.runId);
|
|
@@ -91,6 +91,21 @@ function parseContent(content) {
|
|
|
91
91
|
}
|
|
92
92
|
break;
|
|
93
93
|
}
|
|
94
|
+
case "toolResult":
|
|
95
|
+
case "tool_result": {
|
|
96
|
+
// Codex (app-server backend) persists an exec/bash tool's STDOUT in a content
|
|
97
|
+
// block whose `type` is "toolResult" (not "text") — the output lives in the
|
|
98
|
+
// block's `text`/`content`. The native/Anthropic path uses plain "text" blocks,
|
|
99
|
+
// so this case was never hit and the command output was silently dropped from
|
|
100
|
+
// history (`parsed.text` stayed ""), leaving the app's trace tool row with no
|
|
101
|
+
// result. ControlUI shows it because its projection reads these blocks.
|
|
102
|
+
const t = readString(block.text) || readString(block.content);
|
|
103
|
+
if (t) {
|
|
104
|
+
textParts.push(t);
|
|
105
|
+
out.images.push(...extractMediaMarkers(t));
|
|
106
|
+
}
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
94
109
|
case "thinking": {
|
|
95
110
|
const t = readString(block.thinking);
|
|
96
111
|
if (t)
|
|
@@ -467,6 +467,16 @@ export async function handleMessages(req, res) {
|
|
|
467
467
|
runId,
|
|
468
468
|
suppressTyping: true,
|
|
469
469
|
disableBlockStreaming: true,
|
|
470
|
+
// friday-next is a direct device channel: the final assistant reply auto-delivers
|
|
471
|
+
// to the app live over SSE (sendText), and the channel already declares ChatType
|
|
472
|
+
// "direct" + outbound.deliveryMode "direct". But OpenClaw core's source-reply policy
|
|
473
|
+
// (resolveSourceReplyDeliveryMode) can still resolve `message_tool_only` for this
|
|
474
|
+
// channel from its own defaults — when it does, the agent prompt tells the model
|
|
475
|
+
// "visible replies are NOT auto-delivered; use message(action=send) for everything",
|
|
476
|
+
// which makes Codex route its whole answer through the `message` tool (and that tool
|
|
477
|
+
// crashes on friday-next). Pin `automatic` so core honors the channel's own direct
|
|
478
|
+
// delivery and never instructs the model to deliver via the message tool.
|
|
479
|
+
sourceReplyDeliveryMode: "automatic",
|
|
470
480
|
// A1: feed the chosen thinking level into the run as a one-shot override so the model
|
|
471
481
|
// request asks for a reasoning summary. The session-stored `thinkingLevel` alone is NOT
|
|
472
482
|
// honored by the reply dispatch; `thinkingLevelOverride` has top priority in OpenClaw's
|
package/index.ts
CHANGED
|
@@ -30,8 +30,6 @@ const hookLogger = createFridayNextLogger("hook");
|
|
|
30
30
|
export { fridayNextChannelPlugin } from "./src/channel.js";
|
|
31
31
|
export { setFridayNextRuntime } from "./src/runtime.js";
|
|
32
32
|
|
|
33
|
-
/** `api.on` returns void — register tool hooks at most once per process. */
|
|
34
|
-
let fridayNextToolHooksRegistered = false;
|
|
35
33
|
let disposeAgentEventListener: (() => void) | null = null;
|
|
36
34
|
/**
|
|
37
35
|
* Track the last `api` instance on which HTTP routes were registered.
|
|
@@ -163,10 +161,17 @@ export default defineChannelPluginEntry({
|
|
|
163
161
|
);
|
|
164
162
|
});
|
|
165
163
|
|
|
166
|
-
|
|
164
|
+
// Tool hooks (subagent_delivery_target / before_tool_call / after_tool_call) must follow the
|
|
165
|
+
// SAME re-registration discipline as the HTTP routes and onAgentEvent listener above. When the
|
|
166
|
+
// health-monitor restarts the plugin, `registerFull` receives a fresh `api` and the host
|
|
167
|
+
// dispatches hooks on THAT api; hooks left bound to the first api go silent for the rest of the
|
|
168
|
+
// process. The old one-time boolean guard bound them to whichever api arrived first — which is
|
|
169
|
+
// exactly why Codex `command_output` (the A3 after_tool_call stdout synthesis) fired on some
|
|
170
|
+
// gateway processes and not others. Re-register on every genuinely-new api; skip only a repeat
|
|
171
|
+
// call with the same api (matching the `!sameApi` gate the routes use).
|
|
172
|
+
if (sameApi) {
|
|
167
173
|
return;
|
|
168
174
|
}
|
|
169
|
-
fridayNextToolHooksRegistered = true;
|
|
170
175
|
|
|
171
176
|
// Make Codex (ChatGPT/OAuth) models emit reasoning summary text so the app can stream
|
|
172
177
|
// "thinking". OpenClaw never sets this; we assert it on the plugin side. Best-effort.
|
package/package.json
CHANGED
|
@@ -175,6 +175,58 @@ describe("forwardAgentEventRaw (thinking delta rewrite)", () => {
|
|
|
175
175
|
expect(thinking[1].data.reasoningPrefixChars).toBe(2);
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
+
it("drops item events flagged suppressChannelProgress (Codex tool/command duplicates)", () => {
|
|
179
|
+
// Codex app-server projects every tool/command onto both the `tool` stream and a redundant
|
|
180
|
+
// `item` event flagged suppressChannelProgress:true. Forwarding the item double-renders the
|
|
181
|
+
// tool in the app. We honor the flag and drop it.
|
|
182
|
+
forwardAgentEventRaw({
|
|
183
|
+
runId,
|
|
184
|
+
seq: 1,
|
|
185
|
+
stream: "item",
|
|
186
|
+
sessionKey,
|
|
187
|
+
data: {
|
|
188
|
+
itemId: "call_abc",
|
|
189
|
+
kind: "tool",
|
|
190
|
+
phase: "start",
|
|
191
|
+
name: "web_search",
|
|
192
|
+
suppressChannelProgress: true,
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
expect(sseEmitter.broadcastToRun).not.toHaveBeenCalled();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("KEEPS suppressed item kind:command (exec row the command_output stdout attaches to)", () => {
|
|
199
|
+
// The `tool`-stream exec result is just {exitCode,duration}; the real stdout arrives on the
|
|
200
|
+
// synthesized `command_output` end event, which the app attaches to the command-terminal row
|
|
201
|
+
// bootstrapped from THIS `item kind:command`. Dropping it (as the old broad filter did) left
|
|
202
|
+
// exec tools with a command line and no output in the trace.
|
|
203
|
+
forwardAgentEventRaw({
|
|
204
|
+
runId,
|
|
205
|
+
seq: 1,
|
|
206
|
+
stream: "item",
|
|
207
|
+
sessionKey,
|
|
208
|
+
data: {
|
|
209
|
+
itemId: "call_exec1",
|
|
210
|
+
kind: "command",
|
|
211
|
+
phase: "start",
|
|
212
|
+
name: "bash",
|
|
213
|
+
suppressChannelProgress: true,
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
expect(sseEmitter.broadcastToRun).toHaveBeenCalledTimes(1);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("forwards item events that are not suppressed (e.g. reasoning analysis markers)", () => {
|
|
220
|
+
forwardAgentEventRaw({
|
|
221
|
+
runId,
|
|
222
|
+
seq: 1,
|
|
223
|
+
stream: "item",
|
|
224
|
+
sessionKey,
|
|
225
|
+
data: { itemId: "rs_1", kind: "analysis", phase: "start", title: "Reasoning" },
|
|
226
|
+
});
|
|
227
|
+
expect(sseEmitter.broadcastToRun).toHaveBeenCalledTimes(1);
|
|
228
|
+
});
|
|
229
|
+
|
|
178
230
|
it("does not translate preamble items from a non-Codex source", () => {
|
|
179
231
|
forwardAgentEventRaw({
|
|
180
232
|
runId,
|
package/src/friday-session.ts
CHANGED
|
@@ -398,6 +398,26 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
|
|
|
398
398
|
}
|
|
399
399
|
}
|
|
400
400
|
|
|
401
|
+
// Codex app-server projects every tool/command call onto BOTH the standard `tool` stream
|
|
402
|
+
// (carrying args + the real result) AND a redundant `item` event (kind:"tool"/"command"),
|
|
403
|
+
// and core flags that item `suppressChannelProgress: true` ("do not surface in channel
|
|
404
|
+
// progress"). Forwarding the suppressed *tool* item double-renders every non-exec tool in the
|
|
405
|
+
// app — the `tool`-stream row plus a second `item kind:tool` row, with the result landing only
|
|
406
|
+
// on the first. So drop ONLY suppressed `kind:"tool"` items.
|
|
407
|
+
//
|
|
408
|
+
// BUT keep suppressed `kind:"command"`/`"process"` items: the `tool` stream's exec result is
|
|
409
|
+
// just {exitCode,duration} (no stdout), and the app bootstraps its command-terminal row from
|
|
410
|
+
// the `item kind:command` event — that row is what the synthesized `command_output` end event
|
|
411
|
+
// then attaches the real stdout to. Dropping it left exec tools with a command line and no
|
|
412
|
+
// output in the trace. (Reasoning items preamble/analysis are never suppressed.)
|
|
413
|
+
if (
|
|
414
|
+
evt.stream === "item" &&
|
|
415
|
+
evt.data.suppressChannelProgress === true &&
|
|
416
|
+
evt.data.kind === "tool"
|
|
417
|
+
) {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
401
421
|
// Register sessionKey → runId so we can resolve parentRunId
|
|
402
422
|
if (sk && evt.stream === "lifecycle" && evt.data.phase === "start") {
|
|
403
423
|
registerSessionKeyForRun(sk, evt.runId);
|
|
@@ -185,6 +185,33 @@ describe("normalizeHistoryMessage", () => {
|
|
|
185
185
|
expect(plain?.mediaPaths).toBeUndefined();
|
|
186
186
|
});
|
|
187
187
|
|
|
188
|
+
it("extracts Codex exec stdout from a `toolResult`-typed content block", () => {
|
|
189
|
+
// Codex (app-server) persists bash/exec stdout in a content block whose `type` is
|
|
190
|
+
// "toolResult" (not "text"); the native path only ever emits "text" blocks. Before the
|
|
191
|
+
// fix this fell through `default` and the command output was dropped (text="").
|
|
192
|
+
const out = normalizeHistoryMessage(
|
|
193
|
+
{
|
|
194
|
+
role: "toolResult",
|
|
195
|
+
toolCallId: "call_GSu3",
|
|
196
|
+
toolName: "bash",
|
|
197
|
+
content: [
|
|
198
|
+
{
|
|
199
|
+
type: "toolResult",
|
|
200
|
+
toolCallId: "call_GSu3",
|
|
201
|
+
name: "bash",
|
|
202
|
+
content: "Applications\nDesktop\nDocuments",
|
|
203
|
+
text: "Applications\nDesktop\nDocuments",
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
...meta("tr-1", 3),
|
|
207
|
+
},
|
|
208
|
+
0,
|
|
209
|
+
);
|
|
210
|
+
expect(out?.role).toBe("toolResult");
|
|
211
|
+
expect(out?.toolResult?.text).toBe("Applications\nDesktop\nDocuments");
|
|
212
|
+
expect(out?.toolResult?.toolCallId).toBe("call_GSu3");
|
|
213
|
+
});
|
|
214
|
+
|
|
188
215
|
it("flags compaction records via __openclaw.kind", () => {
|
|
189
216
|
const out = normalizeHistoryMessage(
|
|
190
217
|
{
|
|
@@ -158,6 +158,21 @@ function parseContent(content: unknown): ParsedContent {
|
|
|
158
158
|
}
|
|
159
159
|
break;
|
|
160
160
|
}
|
|
161
|
+
case "toolResult":
|
|
162
|
+
case "tool_result": {
|
|
163
|
+
// Codex (app-server backend) persists an exec/bash tool's STDOUT in a content
|
|
164
|
+
// block whose `type` is "toolResult" (not "text") — the output lives in the
|
|
165
|
+
// block's `text`/`content`. The native/Anthropic path uses plain "text" blocks,
|
|
166
|
+
// so this case was never hit and the command output was silently dropped from
|
|
167
|
+
// history (`parsed.text` stayed ""), leaving the app's trace tool row with no
|
|
168
|
+
// result. ControlUI shows it because its projection reads these blocks.
|
|
169
|
+
const t = readString(block.text) || readString(block.content);
|
|
170
|
+
if (t) {
|
|
171
|
+
textParts.push(t);
|
|
172
|
+
out.images.push(...extractMediaMarkers(t));
|
|
173
|
+
}
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
161
176
|
case "thinking": {
|
|
162
177
|
const t = readString(block.thinking);
|
|
163
178
|
if (t) thinkingParts.push(t);
|
|
@@ -635,6 +635,16 @@ export async function handleMessages(req: IncomingMessage, res: ServerResponse):
|
|
|
635
635
|
runId,
|
|
636
636
|
suppressTyping: true,
|
|
637
637
|
disableBlockStreaming: true,
|
|
638
|
+
// friday-next is a direct device channel: the final assistant reply auto-delivers
|
|
639
|
+
// to the app live over SSE (sendText), and the channel already declares ChatType
|
|
640
|
+
// "direct" + outbound.deliveryMode "direct". But OpenClaw core's source-reply policy
|
|
641
|
+
// (resolveSourceReplyDeliveryMode) can still resolve `message_tool_only` for this
|
|
642
|
+
// channel from its own defaults — when it does, the agent prompt tells the model
|
|
643
|
+
// "visible replies are NOT auto-delivered; use message(action=send) for everything",
|
|
644
|
+
// which makes Codex route its whole answer through the `message` tool (and that tool
|
|
645
|
+
// crashes on friday-next). Pin `automatic` so core honors the channel's own direct
|
|
646
|
+
// delivery and never instructs the model to deliver via the message tool.
|
|
647
|
+
sourceReplyDeliveryMode: "automatic",
|
|
638
648
|
// A1: feed the chosen thinking level into the run as a one-shot override so the model
|
|
639
649
|
// request asks for a reasoning summary. The session-stored `thinkingLevel` alone is NOT
|
|
640
650
|
// honored by the reply dispatch; `thinkingLevelOverride` has top priority in OpenClaw's
|