@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 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
- if (fridayNextToolHooksRegistered) {
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
- if (fridayNextToolHooksRegistered) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syengup/friday-channel-next",
3
- "version": "1.0.1",
3
+ "version": "1.0.4",
4
4
  "description": "OpenClaw Friday Next Apple channel plugin",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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,
@@ -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