@moxxy/plugin-telegram 0.44.0 → 0.45.0

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.
Files changed (60) hide show
  1. package/dist/channel/callback-handler.d.ts +4 -0
  2. package/dist/channel/callback-handler.d.ts.map +1 -1
  3. package/dist/channel/callback-handler.js +8 -0
  4. package/dist/channel/callback-handler.js.map +1 -1
  5. package/dist/channel/frame-pump.d.ts +3 -1
  6. package/dist/channel/frame-pump.d.ts.map +1 -1
  7. package/dist/channel/frame-pump.js +3 -2
  8. package/dist/channel/frame-pump.js.map +1 -1
  9. package/dist/channel/mirror.d.ts +1 -1
  10. package/dist/channel/mirror.d.ts.map +1 -1
  11. package/dist/channel/mirror.js +1 -0
  12. package/dist/channel/mirror.js.map +1 -1
  13. package/dist/channel/question-cards.d.ts +37 -0
  14. package/dist/channel/question-cards.d.ts.map +1 -0
  15. package/dist/channel/question-cards.js +46 -0
  16. package/dist/channel/question-cards.js.map +1 -0
  17. package/dist/channel/send-images.d.ts +31 -0
  18. package/dist/channel/send-images.d.ts.map +1 -0
  19. package/dist/channel/send-images.js +58 -0
  20. package/dist/channel/send-images.js.map +1 -0
  21. package/dist/channel/slash-handler.js +1 -1
  22. package/dist/channel/slash-handler.js.map +1 -1
  23. package/dist/channel/text-handler.d.ts +4 -1
  24. package/dist/channel/text-handler.d.ts.map +1 -1
  25. package/dist/channel/text-handler.js +5 -1
  26. package/dist/channel/text-handler.js.map +1 -1
  27. package/dist/channel/turn-runner.d.ts.map +1 -1
  28. package/dist/channel/turn-runner.js +4 -0
  29. package/dist/channel/turn-runner.js.map +1 -1
  30. package/dist/channel.d.ts +3 -0
  31. package/dist/channel.d.ts.map +1 -1
  32. package/dist/channel.js +18 -1
  33. package/dist/channel.js.map +1 -1
  34. package/dist/permission.d.ts +1 -1
  35. package/dist/permission.d.ts.map +1 -1
  36. package/dist/permission.js +5 -3
  37. package/dist/permission.js.map +1 -1
  38. package/dist/render.d.ts +17 -5
  39. package/dist/render.d.ts.map +1 -1
  40. package/dist/render.js +51 -29
  41. package/dist/render.js.map +1 -1
  42. package/package.json +9 -9
  43. package/src/channel/callback-handler.test.ts +37 -0
  44. package/src/channel/callback-handler.ts +10 -0
  45. package/src/channel/frame-pump.ts +5 -2
  46. package/src/channel/mirror.ts +2 -1
  47. package/src/channel/question-cards.test.ts +61 -0
  48. package/src/channel/question-cards.ts +70 -0
  49. package/src/channel/send-images.test.ts +66 -0
  50. package/src/channel/send-images.ts +75 -0
  51. package/src/channel/slash-handler.ts +1 -1
  52. package/src/channel/text-handler.test.ts +34 -0
  53. package/src/channel/text-handler.ts +8 -2
  54. package/src/channel/turn-runner.test.ts +26 -3
  55. package/src/channel/turn-runner.ts +3 -0
  56. package/src/channel.ts +19 -0
  57. package/src/permission.test.ts +19 -0
  58. package/src/permission.ts +5 -3
  59. package/src/render.test.ts +100 -4
  60. package/src/render.ts +66 -32
@@ -151,6 +151,23 @@ describe('TurnRenderer', () => {
151
151
  expect(frame.diffHtml).not.toContain('<Foo');
152
152
  });
153
153
 
154
+ it("says in the footer that the agent's limit is used up and when it resets, as the desktop does", () => {
155
+ const r = new TurnRenderer();
156
+ const out = r.accept(
157
+ baseEvent(
158
+ {
159
+ type: 'plugin_event',
160
+ source: 'system',
161
+ pluginId: asPluginId('@moxxy/agent-connection'),
162
+ subtype: 'account_limit',
163
+ payload: { provider: 'codex-agent', agent: 'Codex', limit: { state: 'reached', windowMinutes: 10080 } },
164
+ },
165
+ 0,
166
+ ),
167
+ );
168
+ expect(out.errorHtml).toBe("⛔ <b>Codex's weekly limit is used up</b>");
169
+ });
170
+
154
171
  it('appends an error footer when an error event arrives', () => {
155
172
  const r = new TurnRenderer();
156
173
  const out = r.accept(baseEvent({ type: 'error', kind: 'fatal', message: 'boom', source: 'system' }, 0));
@@ -176,9 +193,88 @@ describe('TurnRenderer', () => {
176
193
  return r;
177
194
  };
178
195
 
196
+ const stepList = (steps: ReadonlyArray<{ text: string; status: string }>) => ({
197
+ forModel: 'list updated',
198
+ display: { kind: 'step-list', steps },
199
+ });
200
+
201
+ it('folds every step-list tool result into one list of the latest steps', () => {
202
+ const r = new TurnRenderer();
203
+ const first = asToolCallId('s1');
204
+ const second = asToolCallId('s2');
205
+ r.accept(baseEvent({ type: 'tool_call_requested', callId: first, name: 'TodoWrite', input: {}, source: 'model' }, 0));
206
+ r.accept(baseEvent({
207
+ type: 'tool_result', callId: first, ok: true, source: 'tool',
208
+ output: stepList([{ text: 'Read the logs', status: 'in_progress' }, { text: 'Fix <the> bug', status: 'pending' }]),
209
+ }, 1));
210
+ r.accept(baseEvent({ type: 'tool_call_requested', callId: second, name: 'TodoWrite', input: {}, source: 'model' }, 2));
211
+ const frame = r.accept(baseEvent({
212
+ type: 'tool_result', callId: second, ok: true, source: 'tool',
213
+ output: stepList([
214
+ { text: 'Read the logs', status: 'completed' },
215
+ { text: 'Fix <the> bug', status: 'in_progress' },
216
+ { text: 'Run the tests', status: 'pending' },
217
+ { text: 'Ask for review', status: 'cancelled' },
218
+ ]),
219
+ }, 3));
220
+
221
+ expect(frame.activityHtml).toContain('<b>Steps · 2 of 4 done</b>');
222
+ expect(frame.activityHtml).toContain('✓ Read the logs');
223
+ expect(frame.activityHtml).toContain('● <b>Fix &lt;the&gt; bug</b>');
224
+ expect(frame.activityHtml).toContain('○ Run the tests');
225
+ expect(frame.activityHtml).toContain('✗ <s>Ask for review</s>');
226
+ expect(frame.activityHtml.match(/Steps ·/g)).toHaveLength(1);
227
+ expect(frame.activityHtml).not.toContain('<code>TodoWrite</code>');
228
+ });
229
+
230
+ it('does not count step-list calls as steps of the collapsed trace', () => {
231
+ const r = withTools(4);
232
+ const id = asToolCallId('list');
233
+ r.accept(baseEvent({ type: 'tool_call_requested', callId: id, name: 'update_plan', input: {}, source: 'model' }, 20));
234
+ r.accept(baseEvent({
235
+ type: 'tool_result', callId: id, ok: true, source: 'tool',
236
+ output: stepList([{ text: 'One', status: 'completed' }]),
237
+ }, 21));
238
+ expect(r.snapshot({ final: true }).activityHtml).toContain('🔧 <b>4 steps</b>');
239
+ });
240
+
241
+ it('names how many images the tools returned and keeps each image once', () => {
242
+ const r = new TurnRenderer();
243
+ const shot = { mediaType: 'image/png', base64: 'AAAA' };
244
+ for (const [i, output] of [
245
+ { forModel: 'screen', ...shot, moreImages: [{ mediaType: 'image/jpeg', base64: 'BBBB' }] },
246
+ { forModel: 'screen again', ...shot },
247
+ ].entries()) {
248
+ const id = asToolCallId(`shot${i}`);
249
+ r.accept(baseEvent({ type: 'tool_call_requested', callId: id, name: 'screenshot', input: {}, source: 'model' }, i * 2));
250
+ r.accept(baseEvent({ type: 'tool_result', callId: id, ok: true, output, source: 'tool' }, i * 2 + 1));
251
+ }
252
+ expect(r.images()).toEqual([shot, { mediaType: 'image/jpeg', base64: 'BBBB' }]);
253
+ expect(r.snapshot().activityHtml).toContain('🖼 2 images came back');
254
+ });
255
+
256
+ it('says while the turn runs that the agent of a connection is using the computer, and how to stop it', () => {
257
+ const r = new TurnRenderer({ agentName: () => 'Codex' });
258
+ const frame = r.accept(baseEvent({
259
+ type: 'tool_call_requested', callId: c1, name: 'mcp__computer-use__click', input: { app: 'Arc' },
260
+ executedBy: 'provider', source: 'model',
261
+ }, 0));
262
+ expect(frame.activityHtml).toContain('🖥 <b>Codex is using your computer · Arc</b> — send /cancel to stop');
263
+ // The turn is over on the final frame: nothing is using the computer any more.
264
+ expect(r.snapshot({ final: true }).activityHtml).not.toContain('using your computer');
265
+ });
266
+
267
+ it('does not report Moxxy’s own computer tools as an agent using the computer', () => {
268
+ const r = new TurnRenderer({ agentName: () => 'Codex' });
269
+ const frame = r.accept(baseEvent({
270
+ type: 'tool_call_requested', callId: c1, name: 'mcp__computer-use__click', input: { app: 'Arc' }, source: 'model',
271
+ }, 0));
272
+ expect(frame.activityHtml).not.toContain('using your computer');
273
+ });
274
+
179
275
  it('folds a long activity trace into an expandable box with a summary on the final frame', () => {
180
276
  const r = withTools(4);
181
- const snap = r.snapshot({ collapse: true });
277
+ const snap = r.snapshot({ final: true });
182
278
  expect(snap.activityHtml).toContain('<blockquote expandable>');
183
279
  expect(snap.activityHtml).toContain('🔧 <b>4 steps</b>');
184
280
  // The tool lines are still in the (collapsed) box.
@@ -187,7 +283,7 @@ describe('TurnRenderer', () => {
187
283
 
188
284
  it('keeps the activity open (no expandable) while streaming', () => {
189
285
  const r = withTools(5);
190
- const snap = r.snapshot(); // default collapse=false
286
+ const snap = r.snapshot(); // streaming frame
191
287
  expect(snap.activityHtml).toContain('<blockquote>');
192
288
  expect(snap.activityHtml).not.toContain('expandable');
193
289
  expect(snap.activityHtml).not.toContain('🔧');
@@ -195,7 +291,7 @@ describe('TurnRenderer', () => {
195
291
 
196
292
  it('keeps a short activity trace inline even on the final frame', () => {
197
293
  const r = withTools(2); // below the collapse threshold
198
- const snap = r.snapshot({ collapse: true });
294
+ const snap = r.snapshot({ final: true });
199
295
  expect(snap.activityHtml).toContain('<blockquote>');
200
296
  expect(snap.activityHtml).not.toContain('expandable');
201
297
  });
@@ -210,7 +306,7 @@ describe('TurnRenderer', () => {
210
306
  r.accept(baseEvent({ type: 'tool_result', callId: id, ok: true, output: 'ok', source: 'tool' }, 2));
211
307
  r.accept(baseEvent({ type: 'skill_created', name: 's1', source: 'system' }, 3));
212
308
  r.accept(baseEvent({ type: 'skill_created', name: 's2', source: 'system' }, 4));
213
- const snap = r.snapshot({ collapse: true });
309
+ const snap = r.snapshot({ final: true });
214
310
  expect(snap.activityHtml).toContain('<blockquote expandable>');
215
311
  expect(snap.activityHtml).toContain('🔧 <b>1 step</b>');
216
312
  });
package/src/render.ts CHANGED
@@ -1,11 +1,21 @@
1
- import type { MoxxyEvent } from '@moxxy/sdk';
1
+ import type { MoxxyEvent, StepListDisplay } from '@moxxy/sdk';
2
2
  import {
3
3
  assertDefined,
4
4
  fileDiffSummary,
5
5
  fileDiffVerb,
6
6
  isFileDiffDisplay,
7
7
  type FileDiffDisplay,
8
+ type ToolImage,
8
9
  } from '@moxxy/sdk';
10
+ import {
11
+ STEP_MARKS,
12
+ TurnAgentWork,
13
+ agentComputerText,
14
+ imagesLine,
15
+ stepListHeading,
16
+ unifiedDiffText,
17
+ usedUpLimitText,
18
+ } from '@moxxy/channel-kit';
9
19
 
10
20
  const TELEGRAM_MESSAGE_LIMIT = 4000; // 4096 minus a safety margin
11
21
 
@@ -74,6 +84,11 @@ interface SkillEntry {
74
84
  readonly toolCount: number;
75
85
  }
76
86
 
87
+ export interface TurnRendererOptions {
88
+ /** The name of the agent of the session's active agent connection ("Codex"), read when a frame is drawn. */
89
+ readonly agentName?: () => string | undefined;
90
+ }
91
+
77
92
  export class TurnRenderer {
78
93
  private chunks: string[] = [];
79
94
  /** In-flight + completed tool calls keyed by callId, in arrival order. */
@@ -84,9 +99,17 @@ export class TurnRenderer {
84
99
  private errorLine: string | null = null;
85
100
  /** Pre-formatted HTML diff blocks, one per file-diff tool result, in arrival order. */
86
101
  private diffBlocks: string[] = [];
102
+ /** The step list, images and computer use of the turn, as every channel reads them. */
103
+ private readonly work = new TurnAgentWork();
104
+ private readonly agentName: () => string | undefined;
87
105
  private lastFrame = '';
88
106
 
107
+ constructor(opts: TurnRendererOptions = {}) {
108
+ this.agentName = opts.agentName ?? (() => undefined);
109
+ }
110
+
89
111
  accept(event: MoxxyEvent): RenderedFrame {
112
+ this.work.accept(event);
90
113
  switch (event.type) {
91
114
  case 'assistant_chunk':
92
115
  this.chunks.push(event.delta);
@@ -143,13 +166,17 @@ export class TurnRenderer {
143
166
  case 'skill_created':
144
167
  this.notices.push(`💡 created skill: <b>${escape(event.name)}</b>`);
145
168
  break;
146
- case 'plugin_event':
169
+ case 'plugin_event': {
147
170
  if (event.pluginId === '@moxxy/subagents' && event.subtype === 'subagent_started') {
148
171
  const payload = (event.payload ?? {}) as Record<string, unknown>;
149
172
  const label = typeof payload.label === 'string' ? payload.label : 'agent';
150
173
  this.notices.push(`🤖 spawned agent <b>${escape(label)}</b>`);
151
174
  }
175
+ // The agent's used-up account limit ends the turn in place of an error.
176
+ const limit = usedUpLimitText(event);
177
+ if (limit) this.errorLine = `⛔ <b>${escape(limit)}</b>`;
152
178
  break;
179
+ }
153
180
  case 'error':
154
181
  this.errorLine = `❗ <b>${escape(event.kind)}</b>: ${escape(event.message)}`;
155
182
  break;
@@ -164,13 +191,14 @@ export class TurnRenderer {
164
191
  }
165
192
 
166
193
  /**
167
- * Compose the current frame. `collapse` (set on the final flush) folds a
168
- * long tool-activity trace into an expandable box so the finished message
169
- * leads with the answer; during streaming it's left false so the activity
194
+ * Compose the current frame. `final` (set on the last flush, once the turn is
195
+ * over) folds a long tool-activity trace into an expandable box so the
196
+ * finished message leads with the answer, and drops what is only true while
197
+ * the turn runs (the agent using the computer); during streaming the activity
170
198
  * stays fully visible while work is in flight.
171
199
  */
172
- snapshot(opts: { collapse?: boolean } = {}): RenderedFrame {
173
- const activityHtml = this.renderActivity(opts.collapse ?? false) ?? '';
200
+ snapshot(opts: { final?: boolean } = {}): RenderedFrame {
201
+ const activityHtml = this.renderActivity(opts.final ?? false) ?? '';
174
202
  const body = this.finalAssistant ?? this.chunks.join('');
175
203
  const diffHtml = this.diffBlocks.join('\n\n');
176
204
  const errorHtml = this.errorLine ?? '';
@@ -187,18 +215,25 @@ export class TurnRenderer {
187
215
  * the fence/inline placeholder dance). Use `__inert__` markers so
188
216
  * already-formed HTML doesn't get re-escaped.
189
217
  *
190
- * When `collapse` is set (final frame) and the trace ran long, fold it
218
+ * On the final frame, when the trace ran long, fold it
191
219
  * into a `<blockquote expandable>` topped by a one-line summary — the
192
220
  * tool noise becomes a tap-to-open box and the assistant's answer leads.
193
221
  */
194
- private renderActivity(collapse: boolean): string | null {
222
+ private renderActivity(final: boolean): string | null {
195
223
  const lines: string[] = [];
224
+ const computer = this.work.computerUse;
225
+ if (computer && !final) {
226
+ lines.push(`🖥 <b>${escape(agentComputerText(computer, this.agentName()))}</b> — send /cancel to stop`);
227
+ }
196
228
  if (this.skillBanner) {
197
229
  lines.push(`💡 <b>${escape(this.skillBanner.name)}</b>`);
198
230
  }
231
+ const steps = this.work.stepList;
232
+ if (steps) lines.push(...stepListLines(steps));
199
233
  // Show last N tool entries to keep the message compact when a turn
200
234
  // makes many calls. Most-recent at the bottom (matches chronology).
201
- const tools = [...this.tools.values()];
235
+ // A call that changed the list of steps shows in the list, not here.
236
+ const tools = [...this.tools.values()].filter((t) => !this.work.isStepListCall(t.id));
202
237
  const visible = tools.slice(-10);
203
238
  const hidden = tools.length - visible.length;
204
239
  if (hidden > 0) {
@@ -207,9 +242,11 @@ export class TurnRenderer {
207
242
  for (const t of visible) {
208
243
  lines.push(`• <code>${escape(t.name)}</code>(${escape(t.inputPreview)}) ${statusBadge(t.status)}`);
209
244
  }
245
+ const images = this.work.images.length;
246
+ if (images > 0) lines.push(`🖼 ${imagesLine(images)}`);
210
247
  for (const n of this.notices.slice(-5)) lines.push(n);
211
248
  if (lines.length === 0) return null;
212
- if (collapse && lines.length >= COLLAPSE_ACTIVITY_LINES) {
249
+ if (final && lines.length >= COLLAPSE_ACTIVITY_LINES) {
213
250
  const summary = activitySummary(tools.length);
214
251
  const inner = summary ? `${summary}\n${lines.join('\n')}` : lines.join('\n');
215
252
  return `<blockquote expandable>${inner}</blockquote>`;
@@ -217,7 +254,13 @@ export class TurnRenderer {
217
254
  return `<blockquote>${lines.join('\n')}</blockquote>`;
218
255
  }
219
256
 
257
+ /** The images tools returned this turn, each once, for the channel to send after the reply. */
258
+ images(): ReadonlyArray<ToolImage> {
259
+ return this.work.images;
260
+ }
261
+
220
262
  reset(): void {
263
+ this.work.reset();
221
264
  this.chunks = [];
222
265
  this.tools.clear();
223
266
  this.skillBanner = null;
@@ -239,6 +282,18 @@ function activitySummary(toolCount: number): string {
239
282
  return `🔧 <b>${toolCount} step${toolCount === 1 ? '' : 's'}</b>`;
240
283
  }
241
284
 
285
+ /** An agent's list of steps: its heading, an optional reason it changed, then one marked line per step. */
286
+ function stepListLines(list: StepListDisplay): string[] {
287
+ const lines = [`📋 <b>${escape(stepListHeading(list))}</b>`];
288
+ if (list.explanation) lines.push(`<i>${escape(list.explanation)}</i>`);
289
+ for (const step of list.steps) {
290
+ const text = escape(step.text);
291
+ const shown = step.status === 'in_progress' ? `<b>${text}</b>` : step.status === 'cancelled' ? `<s>${text}</s>` : text;
292
+ lines.push(`${STEP_MARKS[step.status]} ${shown}`);
293
+ }
294
+ return lines;
295
+ }
296
+
242
297
  function statusBadge(status: ToolStatus): string {
243
298
  // Pair the glyph with a text word ('done'/'error') so the pass/fail
244
299
  // distinction survives screen-reader announcement / glyph-stripping —
@@ -288,27 +343,6 @@ function renderFileDiff(d: FileDiffDisplay): string {
288
343
  );
289
344
  }
290
345
 
291
- /**
292
- * Build a classic unified-diff body from a file-diff display: a
293
- * `@@ -oldStart,oldLines +newStart,newLines @@` header per hunk, then each
294
- * line prefixed with ` `/`+`/`-`. Hunks are separated by a blank line.
295
- * Returns '' when there are no hunks.
296
- */
297
- function unifiedDiffText(d: FileDiffDisplay): string {
298
- const blocks: string[] = [];
299
- for (const hunk of d.hunks) {
300
- const lines: string[] = [
301
- `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`,
302
- ];
303
- for (const line of hunk.lines) {
304
- const marker = line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' ';
305
- lines.push(`${marker}${line.text}`);
306
- }
307
- blocks.push(lines.join('\n'));
308
- }
309
- return blocks.join('\n\n');
310
- }
311
-
312
346
  function truncJson(value: unknown, max = 60): string {
313
347
  const s = typeof value === 'string' ? value : JSON.stringify(value) ?? '';
314
348
  if (s.length <= max) return s;