@carlitose86/pi-telegram-reactive 0.1.0-next.0 → 0.1.0-next.1

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/CHANGELOG.md CHANGED
@@ -4,7 +4,11 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
- No additional changes beyond the preview candidate below.
7
+ No additional changes beyond the preview candidates below.
8
+
9
+ ## 0.1.0-next.1: Dropped-reply diagnostics
10
+
11
+ - `Dropped final replies`: When the bridge deliberately does not deliver a final reply that had text, attachments or an error notice, because the session was replaced, the transport changed or the active turn moved, it now records one redacted `delivery` event with a `final-reply-*` phase instead of leaving no trace. Runs with nothing to publish stay silent. A regression covers final replies after mid-run steers.
8
12
 
9
13
  ## 0.1.0-next.0: Pi Telegram Reactive preview candidate
10
14
 
package/README.md CHANGED
@@ -17,7 +17,7 @@ Pi Telegram Reactive is the distinct `@carlitose86/pi-telegram-reactive` preview
17
17
  From npm:
18
18
 
19
19
  ```bash
20
- pi install npm:@carlitose86/pi-telegram-reactive@0.1.0-next.0
20
+ pi install npm:@carlitose86/pi-telegram-reactive@0.1.0-next.1
21
21
  ```
22
22
 
23
23
  From git:
package/dist/lib/queue.js CHANGED
@@ -875,10 +875,25 @@ export function buildTelegramAgentEndPlan(options) {
875
875
  shouldSendAttachmentNotice: false,
876
876
  };
877
877
  }
878
+ /** Records a deliberately undelivered final reply without its content. */
879
+ function recordTelegramDroppedFinalReply(recordRuntimeEvent, phase, turn, content) {
880
+ recordRuntimeEvent?.("delivery", new Error("Telegram final reply was not delivered."), {
881
+ phase,
882
+ hasTurn: !!turn,
883
+ hasThread: turn?.target?.threadId !== undefined,
884
+ textLength: content.text?.length ?? 0,
885
+ attachmentCount: content.attachmentCount ?? 0,
886
+ });
887
+ }
878
888
  export function createTelegramAgentEndHook(deps) {
879
889
  return async (event, ctx, assistantOverride) => {
880
- if (deps.isSessionActive?.(ctx) === false)
890
+ if (deps.isSessionActive?.(ctx) === false) {
891
+ const activeTurn = deps.getActiveTurn();
892
+ if (activeTurn) {
893
+ recordTelegramDroppedFinalReply(deps.recordRuntimeEvent, "final-reply-session-inactive", activeTurn, { attachmentCount: activeTurn.queuedAttachments.length });
894
+ }
881
895
  return;
896
+ }
882
897
  const turn = deps.getActiveTurn();
883
898
  const extractedAssistant = assistantOverride ?? (turn ? deps.extractAssistant(event.messages) : {});
884
899
  const assistant = deps.isAssistantAlreadyPublished?.(extractedAssistant)
@@ -889,8 +904,15 @@ export function createTelegramAgentEndHook(deps) {
889
904
  const scheduleDelivery = reservation?.schedule ?? deps.scheduleActiveTurnDelivery;
890
905
  try {
891
906
  await deps.loadConfig?.();
892
- if (deps.isSessionActive?.(ctx) === false || deps.getActiveTurn() !== turn)
907
+ const sessionInactive = deps.isSessionActive?.(ctx) === false;
908
+ if (sessionInactive || deps.getActiveTurn() !== turn) {
909
+ if (hasPublication) {
910
+ recordTelegramDroppedFinalReply(deps.recordRuntimeEvent, sessionInactive
911
+ ? "final-reply-session-inactive"
912
+ : "final-reply-turn-replaced", turn, { text: assistant.text, attachmentCount: turn?.queuedAttachments.length });
913
+ }
893
914
  return;
915
+ }
894
916
  await handleTelegramAgentEndRuntime({
895
917
  turn,
896
918
  assistant,
@@ -964,8 +986,26 @@ export async function handleTelegramAgentEndRuntime(deps) {
964
986
  const finalText = outboundReply ? outboundReply.markdown : rawFinalText;
965
987
  const hasOutboundArtifacts = !!outboundReply?.voiceText || !!outboundReply?.voiceReplies?.length;
966
988
  const replyMarkup = outboundReply?.replyMarkup;
967
- const isDeliveryActive = () => deps.isSessionActive?.() !== false &&
968
- (!turn || deps.isTurnTransportActive?.(turn) !== false);
989
+ const hasPublishableContent = !!finalText ||
990
+ hasOutboundArtifacts ||
991
+ (turn?.queuedAttachments.length ?? 0) > 0 ||
992
+ assistant.stopReason === "error";
993
+ let dropRecorded = false;
994
+ // The first failed fence that would lose content leaves one diagnostic event;
995
+ // without it a dropped final reply is indistinguishable from a sent one.
996
+ const isDeliveryActive = () => {
997
+ const sessionActive = deps.isSessionActive?.() !== false;
998
+ const transportActive = !turn || deps.isTurnTransportActive?.(turn) !== false;
999
+ if (sessionActive && transportActive)
1000
+ return true;
1001
+ if (!dropRecorded && hasPublishableContent) {
1002
+ dropRecorded = true;
1003
+ recordTelegramDroppedFinalReply(deps.recordRuntimeEvent, sessionActive
1004
+ ? "final-reply-transport-inactive"
1005
+ : "final-reply-session-inactive", turn, { text: finalText, attachmentCount: turn?.queuedAttachments.length });
1006
+ }
1007
+ return false;
1008
+ };
969
1009
  const preview = turn && !turn.guestQueryId ? deps.preparePreviewDelivery?.(isDeliveryActive) : undefined;
970
1010
  const setPreviewPendingText = preview?.setPreviewPendingText ?? deps.setPreviewPendingText;
971
1011
  const finalizeMarkdownPreview = preview?.finalizeMarkdownPreview ?? deps.finalizeMarkdownPreview;
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlitose86/pi-telegram-reactive",
3
- "version": "0.1.0-next.0",
3
+ "version": "0.1.0-next.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -0,0 +1,59 @@
1
+ # Telegram final replies dropped without a trace
2
+
3
+ ## Artifact Graph
4
+ - Artifact ID: `artifact:telegram-silent-reply-drop-spec`
5
+ - Role: `spec`
6
+ - Standalone: true
7
+ ### Children
8
+ - [01 Record every dropped final reply](../tickets/telegram-silent-reply-drop/01-record-dropped-final-replies.md)
9
+ - [02 Reproduce and fix the steered-run drop](../tickets/telegram-silent-reply-drop/02-reproduce-steered-run-drop.md)
10
+
11
+ ## Type and evidence
12
+ Bug analysis. On 2026-10-07 a registered follower session (installed `@carlitose86/pi-telegram-reactive` `0.1.0-next.0`, built from `release/reactive-next-0`) produced two final assistant replies that never reached its Telegram Thread. Its session file shows the exact sequence (UTC):
13
+
14
+ | Time | Event |
15
+ | --- | --- |
16
+ | 13:39:39 | Telegram prompt (from a prompt button) starts a run |
17
+ | 13:39:46 | assistant `toolUse` |
18
+ | 13:40:09 | Telegram prompt "Chi sarebbe il collega?" enters the same run |
19
+ | 13:40:27 | assistant `stop`, answering both prompts |
20
+ | 13:40:27 | Telegram prompt "Poi spiegami…" enters at that last `turn_end`, so the run continues |
21
+ | 13:40:32–34 | assistant `toolUse` ×2 |
22
+ | 13:40:59 | assistant `stop`; run settles |
23
+
24
+ Neither reply was delivered, and `tmp/telegram/logs.jsonl` has no event after 13:33:28. Earlier replies of the same session, without mid-run prompts, arrived and left voice-fallback events. Inbound and leader polling stayed healthy.
25
+
26
+ The final-reply path ends silently in several places:
27
+ - `createTelegramAgentEndHook` returns when the session context is no longer current, or when the active turn changed while config loaded.
28
+ - `handleTelegramAgentEndRuntime` returns when `isDeliveryActive()` is false (session inactive, or the turn's transport stamp is no longer current) and when there is no active turn at all.
29
+ - An assistant text already admitted as an intermediate publication is reduced to its stop reason.
30
+
31
+ None of these exits records a runtime event, so the logs cannot say which one fired. The transport stamp changes only with profile or bot-token changes, so a stale stamp is unlikely here; a missing active turn or a replaced turn during a run with mid-run steers (`createTelegramMidRunSteerRuntime`, added with the busy-run steering feature) is the leading hypothesis. It is not proven.
32
+
33
+ ## Goal
34
+ A final reply produced for a Telegram turn reaches its exact Telegram target, and when the bridge deliberately does not deliver one it leaves a redacted diagnostic event naming the reason.
35
+
36
+ ## Non-goals
37
+ - No change to voice fallback, prompt buttons or the leader/follower bus protocol unless the reproduction proves they cause the drop.
38
+ - No replay of an uncertain send and no delivery to a different target, profile or session.
39
+ - No mutation of journals, owners, locks or state files to recover lost replies.
40
+
41
+ ## Target behavior and invariants
42
+ 1. Every non-delivery exit of the final-reply path that discards assistant text, attachments or an error notice records one `delivery` runtime event with a stable `phase` per reason: `final-reply-session-inactive`, `final-reply-transport-inactive` or `final-reply-turn-replaced`. Event details hold only bounded, redacted facts: reason, turn and Thread presence, text length, attachment count; never the reply text.
43
+ 2. Exits that discard nothing (no text, no attachments, no error) stay silent. A run with no active Telegram turn is an ordinary local run whose output belongs to companion projection, and an answer already admitted as an intermediate publication is intentional deduplication; neither records an event, so routine runs do not flood the log. Slice 02 decides whether the observed drop went through the no-active-turn exit.
44
+ 3. A run started by a Telegram prompt that absorbs one or more mid-run Telegram steers, including a steer queued at the final `turn_end`, delivers its final reply to the original turn's target exactly once.
45
+ 4. Existing fences keep their meaning: a truly replaced session, profile or bot token still prevents delivery, now with an event.
46
+
47
+ ## Implementation slices
48
+ - **01:** Add the diagnostic events of invariants 1–2 with unit tests for each reason. This alone makes any recurrence attributable.
49
+ - **02:** Reproduce the observed sequence (prompt-button turn, mid-run steer after a tool turn, second steer at the final `turn_end`) in a lifecycle-level test with real bridge wiring and fake Pi events. If it reproduces, fix the root cause and keep the test. If it does not, record which paths were exercised, keep the test as a regression, and leave the ticket open for live evidence from slice 01's events.
50
+
51
+ ## Verification
52
+ Unit tests for each event reason; a lifecycle/integration test for invariant 3; `npm run build`, `npm run typecheck`, `npm test`, `npm run build:check`. Live confirmation needs a paired Telegram Thread and is a manual step after installation; no live behavior is claimed from tests.
53
+
54
+ ## Gates
55
+ - **Attempt:** one pull request in `carlitose/pi-telegram` targeting `release/reactive-next-0`.
56
+ - **Budget and time per attempt:** at most 3 hours; no external spend. **Maximum attempts:** 2.
57
+ - **Approvals:** the agent may merge the pull request once its required checks are green. Publishing `0.1.0-next.1` to npm is done by the user (`npm publish`, browser 2FA). Updating the pin in `pi-personal-config` and installing happen only after the running `dbh-crew` benchmark lot ends.
58
+ - **Exact version:** none.
59
+ - **Existing blocks found:** the `dbh-crew` lot is running (blocks only installation); open pull request #7 `release/reactive-next-0 → main` (does not block this branch). Searched: open pull requests, `package.json` version, repository workflows `validate.yml` and `release.yml`.
@@ -0,0 +1,42 @@
1
+ ---
2
+ ticket_schema: 1
3
+ ticket_id: "01"
4
+ execution_mode: AFK
5
+ blocked_by: []
6
+ ---
7
+
8
+ # Record every dropped final reply
9
+
10
+ ## Artifact Graph
11
+ - Artifact ID: `artifact:telegram-silent-reply-drop-01`
12
+ - Role: `ticket`
13
+ - Parent: [telegram-silent-reply-drop.md](../../specs/telegram-silent-reply-drop.md)
14
+
15
+ ## Parent Spec
16
+ [Telegram final replies dropped without a trace](../../specs/telegram-silent-reply-drop.md), target behavior 1, 2 and 4; slice 01.
17
+
18
+ ## What to Build
19
+ Make each non-delivery exit of the final-reply path (`createTelegramAgentEndHook` and `handleTelegramAgentEndRuntime` in `lib/queue.ts`) that discards assistant text, queued attachments or an error notice record one redacted `delivery` runtime event with a stable `final-reply-*` phase naming the reason. Exits that discard nothing stay silent. Delivery behavior and fences do not change.
20
+
21
+ ## Acceptance Criteria
22
+ - [ ] Session-inactive, transport-inactive, turn-replaced, no-active-turn and already-published exits each record exactly one event with their own phase when content would be lost.
23
+ - [ ] Event details contain no reply text; only reason, text presence/length, attachment count and target presence.
24
+ - [ ] A run with nothing to publish records no event.
25
+ - [ ] Existing queue/lifecycle tests stay green; `dist/` is rebuilt.
26
+
27
+ ## Frontier
28
+ Done: merged in #8 (`298f291`), CI green on Linux, macOS and Windows. Live events appear only after the package is published and installed.
29
+
30
+ ## Gates
31
+ As the parent spec: one PR to `release/reactive-next-0`, 3 hours per attempt, at most 2 attempts; the agent may merge on green checks; npm publication by the user; installation only after the `dbh-crew` lot.
32
+
33
+ ## Step-by-Step Implementation Plan
34
+ 1. Add a failing unit test per reason in `tests/queue.test.ts`.
35
+ 2. Record the events at each exit through the existing `recordRuntimeEvent` port.
36
+ 3. `npm run build`, typecheck, focused then full tests.
37
+
38
+ ## Testing Plan
39
+ Unit tests in `tests/queue.test.ts`; `npm run typecheck`; `npm test`; `npm run build:check`.
40
+
41
+ ## Out of Scope
42
+ - Finding or fixing the root cause (ticket 02).
@@ -0,0 +1,43 @@
1
+ ---
2
+ ticket_schema: 1
3
+ ticket_id: "02"
4
+ execution_mode: AFK
5
+ blocked_by:
6
+ - "01"
7
+ ---
8
+
9
+ # Reproduce and fix the steered-run drop
10
+
11
+ ## Artifact Graph
12
+ - Artifact ID: `artifact:telegram-silent-reply-drop-02`
13
+ - Role: `ticket`
14
+ - Parent: [telegram-silent-reply-drop.md](../../specs/telegram-silent-reply-drop.md)
15
+
16
+ ## Parent Spec
17
+ [Telegram final replies dropped without a trace](../../specs/telegram-silent-reply-drop.md), target behavior 3 and 4; slice 02.
18
+
19
+ ## What to Build
20
+ A lifecycle-level test with real bridge wiring and fake Pi events that replays the observed sequence: a Telegram prompt (from a prompt button) starts a run, one mid-run steer is injected after a tool turn, a second steer is injected at the final `turn_end` so the run continues, then the run settles. The final reply must reach the original turn's target exactly once. If the test reproduces the drop, fix the root cause. If it does not, keep it as a regression, document the exercised paths here, and leave the ticket open for live evidence from ticket 01's events.
21
+
22
+ ## Acceptance Criteria
23
+ - [ ] The test covers both steers, including the one queued at the final `turn_end`.
24
+ - [ ] With the fix, the final reply is sent once to the original target; no duplicate and no other target.
25
+ - [ ] Replaced session, profile or token still blocks delivery and now records ticket 01's event.
26
+ - [ ] `npm run typecheck`, `npm test` and `npm run build:check` pass.
27
+
28
+ ## Frontier
29
+ Investigated, not reproduced. In the classic single-process runtime the final reply reaches the turn target once with 0, 1 and 2 mid-run steers, including one queued at the final `turn_end` (regression in `tests/integration.test.ts`). Not exercised: registered-follower delivery through the leader and a prompt-button origin, both present in the incident. Open until a live recurrence yields ticket 01's `final-reply-*` event, which names the failing fence.
30
+
31
+ ## Gates
32
+ As the parent spec: one PR to `release/reactive-next-0`, 3 hours per attempt, at most 2 attempts; the agent may merge on green checks; npm publication by the user; installation only after the `dbh-crew` lot.
33
+
34
+ ## Step-by-Step Implementation Plan
35
+ 1. Locate the lifecycle test harness that drives `agent_start`/`turn_end`/`message_start`/`agent_end` with the mid-run steer runtime.
36
+ 2. Write the failing test for the observed sequence.
37
+ 3. Fix the smallest owning module; rebuild `dist/`.
38
+
39
+ ## Testing Plan
40
+ Lifecycle/integration test in the mirrored suite; full `npm test`; live check in a paired Thread after installation (manual, not claimed).
41
+
42
+ ## Out of Scope
43
+ - Voice fallback and leader/follower protocol changes unless the reproduction proves them causal.
package/lib/queue.ts CHANGED
@@ -1759,6 +1759,28 @@ export function buildTelegramAgentEndPlan(options: {
1759
1759
  };
1760
1760
  }
1761
1761
 
1762
+ /** Records a deliberately undelivered final reply without its content. */
1763
+ function recordTelegramDroppedFinalReply(
1764
+ recordRuntimeEvent:
1765
+ | ((category: string, error: unknown, details?: Record<string, unknown>) => void)
1766
+ | undefined,
1767
+ phase: string,
1768
+ turn: PendingTelegramTurn | undefined,
1769
+ content: { text?: string; attachmentCount?: number },
1770
+ ): void {
1771
+ recordRuntimeEvent?.(
1772
+ "delivery",
1773
+ new Error("Telegram final reply was not delivered."),
1774
+ {
1775
+ phase,
1776
+ hasTurn: !!turn,
1777
+ hasThread: turn?.target?.threadId !== undefined,
1778
+ textLength: content.text?.length ?? 0,
1779
+ attachmentCount: content.attachmentCount ?? 0,
1780
+ },
1781
+ );
1782
+ }
1783
+
1762
1784
  export function createTelegramAgentEndHook<
1763
1785
  TTurn extends PendingTelegramTurn,
1764
1786
  TContext,
@@ -1777,7 +1799,18 @@ export function createTelegramAgentEndHook<
1777
1799
  ctx: TContext,
1778
1800
  assistantOverride?: TelegramAgentEndAssistantResult,
1779
1801
  ): Promise<void> => {
1780
- if (deps.isSessionActive?.(ctx) === false) return;
1802
+ if (deps.isSessionActive?.(ctx) === false) {
1803
+ const activeTurn = deps.getActiveTurn();
1804
+ if (activeTurn) {
1805
+ recordTelegramDroppedFinalReply(
1806
+ deps.recordRuntimeEvent,
1807
+ "final-reply-session-inactive",
1808
+ activeTurn,
1809
+ { attachmentCount: activeTurn.queuedAttachments.length },
1810
+ );
1811
+ }
1812
+ return;
1813
+ }
1781
1814
  const turn = deps.getActiveTurn();
1782
1815
  const extractedAssistant = assistantOverride ?? (turn ? deps.extractAssistant(event.messages) : {});
1783
1816
  const assistant = deps.isAssistantAlreadyPublished?.(extractedAssistant)
@@ -1788,7 +1821,20 @@ export function createTelegramAgentEndHook<
1788
1821
  const scheduleDelivery = reservation?.schedule ?? deps.scheduleActiveTurnDelivery;
1789
1822
  try {
1790
1823
  await deps.loadConfig?.();
1791
- if (deps.isSessionActive?.(ctx) === false || deps.getActiveTurn() !== turn) return;
1824
+ const sessionInactive = deps.isSessionActive?.(ctx) === false;
1825
+ if (sessionInactive || deps.getActiveTurn() !== turn) {
1826
+ if (hasPublication) {
1827
+ recordTelegramDroppedFinalReply(
1828
+ deps.recordRuntimeEvent,
1829
+ sessionInactive
1830
+ ? "final-reply-session-inactive"
1831
+ : "final-reply-turn-replaced",
1832
+ turn,
1833
+ { text: assistant.text, attachmentCount: turn?.queuedAttachments.length },
1834
+ );
1835
+ }
1836
+ return;
1837
+ }
1792
1838
  await handleTelegramAgentEndRuntime({
1793
1839
  turn,
1794
1840
  assistant,
@@ -1872,9 +1918,32 @@ export async function handleTelegramAgentEndRuntime<
1872
1918
  const hasOutboundArtifacts =
1873
1919
  !!outboundReply?.voiceText || !!outboundReply?.voiceReplies?.length;
1874
1920
  const replyMarkup = outboundReply?.replyMarkup;
1875
- const isDeliveryActive = (): boolean =>
1876
- deps.isSessionActive?.() !== false &&
1877
- (!turn || deps.isTurnTransportActive?.(turn) !== false);
1921
+ const hasPublishableContent =
1922
+ !!finalText ||
1923
+ hasOutboundArtifacts ||
1924
+ (turn?.queuedAttachments.length ?? 0) > 0 ||
1925
+ assistant.stopReason === "error";
1926
+ let dropRecorded = false;
1927
+ // The first failed fence that would lose content leaves one diagnostic event;
1928
+ // without it a dropped final reply is indistinguishable from a sent one.
1929
+ const isDeliveryActive = (): boolean => {
1930
+ const sessionActive = deps.isSessionActive?.() !== false;
1931
+ const transportActive =
1932
+ !turn || deps.isTurnTransportActive?.(turn) !== false;
1933
+ if (sessionActive && transportActive) return true;
1934
+ if (!dropRecorded && hasPublishableContent) {
1935
+ dropRecorded = true;
1936
+ recordTelegramDroppedFinalReply(
1937
+ deps.recordRuntimeEvent,
1938
+ sessionActive
1939
+ ? "final-reply-transport-inactive"
1940
+ : "final-reply-session-inactive",
1941
+ turn,
1942
+ { text: finalText, attachmentCount: turn?.queuedAttachments.length },
1943
+ );
1944
+ }
1945
+ return false;
1946
+ };
1878
1947
  const preview = turn && !turn.guestQueryId ? deps.preparePreviewDelivery?.(isDeliveryActive) : undefined;
1879
1948
  const setPreviewPendingText = preview?.setPreviewPendingText ?? deps.setPreviewPendingText;
1880
1949
  const finalizeMarkdownPreview = preview?.finalizeMarkdownPreview ?? deps.finalizeMarkdownPreview;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlitose86/pi-telegram-reactive",
3
- "version": "0.1.0-next.0",
3
+ "version": "0.1.0-next.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public",