switchroom 0.14.57 → 0.14.59
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/agent-scheduler/index.js +1 -1
- package/dist/auth-broker/index.js +19 -9
- package/dist/cli/notion-write-pretool.mjs +1 -1
- package/dist/cli/switchroom.js +29 -70
- package/dist/host-control/main.js +1 -1
- package/dist/vault/approvals/kernel-server.js +1 -1
- package/dist/vault/broker/server.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +268 -14
- package/telegram-plugin/final-answer-detect.ts +34 -0
- package/telegram-plugin/gateway/feed-reopen-gate.ts +162 -0
- package/telegram-plugin/gateway/gateway.ts +304 -4
- package/telegram-plugin/gateway/obligation-ledger.ts +216 -0
- package/telegram-plugin/tests/feed-reopen-gate.test.ts +133 -0
- package/telegram-plugin/tests/final-answer-detect.test.ts +67 -1
- package/telegram-plugin/tests/obligation-ledger.test.ts +167 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +44 -0
- package/telegram-plugin/tool-activity-summary.ts +14 -4
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
decideFeedReopen,
|
|
5
|
+
shouldReopenFeedAfterAck,
|
|
6
|
+
} from '../gateway/feed-reopen-gate.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Feed-reopen-after-ack — pure decision gate.
|
|
10
|
+
*
|
|
11
|
+
* A supergroup agent that ACKS FIRST ("on it, checking Brevo…") then works
|
|
12
|
+
* had its live activity feed go dark for the real work: the ack reply is
|
|
13
|
+
* classified as the final answer by isFinalAnswerReply (it pings or is ≥200
|
|
14
|
+
* chars), setting turn.finalAnswerDelivered=true, and the tool_label handler
|
|
15
|
+
* then dropped every subsequent label. This predicate decides whether a tool
|
|
16
|
+
* label arriving after finalAnswerDelivered (the model is still working)
|
|
17
|
+
* should RE-OPEN the feed.
|
|
18
|
+
*
|
|
19
|
+
* ACK-ONLY refinement: finalAnswerDelivered latches true for BOTH a short
|
|
20
|
+
* pinging ack AND a substantive answer. Reopening after a GENUINE final
|
|
21
|
+
* answer is harmful — post-answer housekeeping (memory write / TodoWrite /
|
|
22
|
+
* Bash) would reset finalAnswerDelivered=false and trip the silent-end
|
|
23
|
+
* re-prompt → duplicate answer. So the gate reopens ONLY when the prior
|
|
24
|
+
* final was a short ack (finalAnswerSubstantive=false).
|
|
25
|
+
*/
|
|
26
|
+
describe('shouldReopenFeedAfterAck', () => {
|
|
27
|
+
it('reopens when delivered AND NOT substantive AND enabled (the ack-first fix)', () => {
|
|
28
|
+
expect(
|
|
29
|
+
shouldReopenFeedAfterAck({
|
|
30
|
+
finalAnswerDelivered: true,
|
|
31
|
+
finalAnswerSubstantive: false,
|
|
32
|
+
enabled: true,
|
|
33
|
+
}),
|
|
34
|
+
).toBe(true)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('does NOT reopen when the prior final was SUBSTANTIVE (the new guard)', () => {
|
|
38
|
+
// A real final answer followed by post-answer housekeeping tool work:
|
|
39
|
+
// keep the legacy gate (no reopen) so the silent-end re-prompt and the
|
|
40
|
+
// #2137 drain see the delivered final correctly. This is the harmful
|
|
41
|
+
// case the refinement closes.
|
|
42
|
+
expect(
|
|
43
|
+
shouldReopenFeedAfterAck({
|
|
44
|
+
finalAnswerDelivered: true,
|
|
45
|
+
finalAnswerSubstantive: true,
|
|
46
|
+
enabled: true,
|
|
47
|
+
}),
|
|
48
|
+
).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('does NOT reopen when the kill switch is off (legacy: drop the label)', () => {
|
|
52
|
+
expect(
|
|
53
|
+
shouldReopenFeedAfterAck({
|
|
54
|
+
finalAnswerDelivered: true,
|
|
55
|
+
finalAnswerSubstantive: false,
|
|
56
|
+
enabled: false,
|
|
57
|
+
}),
|
|
58
|
+
).toBe(false)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('does NOT reopen when the final answer was never delivered (no reopen needed)', () => {
|
|
62
|
+
// The feed was never gated off — the normal append/drain path applies.
|
|
63
|
+
expect(
|
|
64
|
+
shouldReopenFeedAfterAck({
|
|
65
|
+
finalAnswerDelivered: false,
|
|
66
|
+
finalAnswerSubstantive: false,
|
|
67
|
+
enabled: true,
|
|
68
|
+
}),
|
|
69
|
+
).toBe(false)
|
|
70
|
+
expect(
|
|
71
|
+
shouldReopenFeedAfterAck({
|
|
72
|
+
finalAnswerDelivered: false,
|
|
73
|
+
finalAnswerSubstantive: false,
|
|
74
|
+
enabled: false,
|
|
75
|
+
}),
|
|
76
|
+
).toBe(false)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('decideFeedReopen — tool_label branch outcome for a delivered turn', () => {
|
|
81
|
+
it('tool_label after a SHORT ACK (not substantive, kill switch ON) → reset + render proceeds', () => {
|
|
82
|
+
// The exact contract the gateway tool_label handler applies: the interim
|
|
83
|
+
// ack is reclassified — finalAnswerDelivered back to false, a FRESH feed
|
|
84
|
+
// message (activityMessageId null), last-sent render cleared so the drain
|
|
85
|
+
// re-sends. dropLabel false → the handler proceeds to append + drain.
|
|
86
|
+
const outcome = decideFeedReopen({
|
|
87
|
+
finalAnswerDelivered: true,
|
|
88
|
+
finalAnswerSubstantive: false,
|
|
89
|
+
enabled: true,
|
|
90
|
+
})
|
|
91
|
+
expect(outcome.dropLabel).toBe(false)
|
|
92
|
+
expect(outcome.reset).toEqual({
|
|
93
|
+
finalAnswerDelivered: false,
|
|
94
|
+
activityMessageId: null,
|
|
95
|
+
activityLastSentRender: null,
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('tool_label after a SUBSTANTIVE final → drops the label (the new guard, feed stays gated)', () => {
|
|
100
|
+
// Genuine final answer + post-answer housekeeping: NO reopen, NO reset.
|
|
101
|
+
// finalAnswerDelivered stays true so the silent-end re-prompt does not
|
|
102
|
+
// fire and the #2137 drain proceeds correctly.
|
|
103
|
+
const outcome = decideFeedReopen({
|
|
104
|
+
finalAnswerDelivered: true,
|
|
105
|
+
finalAnswerSubstantive: true,
|
|
106
|
+
enabled: true,
|
|
107
|
+
})
|
|
108
|
+
expect(outcome.dropLabel).toBe(true)
|
|
109
|
+
expect(outcome.reset).toBeUndefined()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('kill switch OFF → drops the label (legacy early return, feed stays dark)', () => {
|
|
113
|
+
const outcome = decideFeedReopen({
|
|
114
|
+
finalAnswerDelivered: true,
|
|
115
|
+
finalAnswerSubstantive: false,
|
|
116
|
+
enabled: false,
|
|
117
|
+
})
|
|
118
|
+
expect(outcome.dropLabel).toBe(true)
|
|
119
|
+
expect(outcome.reset).toBeUndefined()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('finalAnswerDelivered false → no reopen branch (handler never reaches it)', () => {
|
|
123
|
+
// The handler only calls decideFeedReopen inside `if (finalAnswerDelivered)`,
|
|
124
|
+
// but the predicate is total: a false flag yields dropLabel (no reset).
|
|
125
|
+
const outcome = decideFeedReopen({
|
|
126
|
+
finalAnswerDelivered: false,
|
|
127
|
+
finalAnswerSubstantive: false,
|
|
128
|
+
enabled: true,
|
|
129
|
+
})
|
|
130
|
+
expect(outcome.dropLabel).toBe(true)
|
|
131
|
+
expect(outcome.reset).toBeUndefined()
|
|
132
|
+
})
|
|
133
|
+
})
|
|
@@ -16,7 +16,11 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { describe, it, expect } from 'vitest'
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
isFinalAnswerReply,
|
|
21
|
+
isSubstantiveFinalReply,
|
|
22
|
+
FINAL_ANSWER_MIN_CHARS,
|
|
23
|
+
} from '../final-answer-detect.js'
|
|
20
24
|
|
|
21
25
|
describe('isFinalAnswerReply — #1664 final-answer classification', () => {
|
|
22
26
|
it('classifies a notification-bearing reply as the final answer', () => {
|
|
@@ -87,3 +91,65 @@ describe('isFinalAnswerReply — #1664 final-answer classification', () => {
|
|
|
87
91
|
expect(FINAL_ANSWER_MIN_CHARS).toBe(200)
|
|
88
92
|
})
|
|
89
93
|
})
|
|
94
|
+
|
|
95
|
+
describe('isSubstantiveFinalReply — feed-reopen ACK-ONLY distinction', () => {
|
|
96
|
+
// isSubstantiveFinalReply is isFinalAnswerReply MINUS the ping-only path.
|
|
97
|
+
// It tells "genuine final answer" (stream-done or ≥200 chars) apart from
|
|
98
|
+
// "final only because it pinged" (a short interim ack). The feed-reopen
|
|
99
|
+
// gate reopens only when finalAnswerDelivered && !substantive, so a real
|
|
100
|
+
// answer + post-answer housekeeping does NOT spuriously reopen / trip the
|
|
101
|
+
// silent-end re-prompt.
|
|
102
|
+
|
|
103
|
+
it('stream_reply done=true → substantive (closes the stream = the answer)', () => {
|
|
104
|
+
expect(
|
|
105
|
+
isSubstantiveFinalReply({ text: 'ok', disableNotification: true, done: true }),
|
|
106
|
+
).toBe(true)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('a reply at/over the length backstop → substantive', () => {
|
|
110
|
+
expect(
|
|
111
|
+
isSubstantiveFinalReply({
|
|
112
|
+
text: 'x'.repeat(FINAL_ANSWER_MIN_CHARS),
|
|
113
|
+
disableNotification: true,
|
|
114
|
+
}),
|
|
115
|
+
).toBe(true)
|
|
116
|
+
// One under the threshold, silent → not substantive.
|
|
117
|
+
expect(
|
|
118
|
+
isSubstantiveFinalReply({
|
|
119
|
+
text: 'x'.repeat(FINAL_ANSWER_MIN_CHARS - 1),
|
|
120
|
+
disableNotification: true,
|
|
121
|
+
}),
|
|
122
|
+
).toBe(false)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('a short PINGING reply is final but NOT substantive (the ack case)', () => {
|
|
126
|
+
// The crux: isFinalAnswerReply says true (it pings), but this is the
|
|
127
|
+
// ack the feed-reopen gate must treat as reopen-eligible — NOT a real
|
|
128
|
+
// answer. So isSubstantiveFinalReply must say false.
|
|
129
|
+
expect(
|
|
130
|
+
isFinalAnswerReply({ text: 'on it, checking Brevo…', disableNotification: false }),
|
|
131
|
+
).toBe(true)
|
|
132
|
+
expect(
|
|
133
|
+
isSubstantiveFinalReply({ text: 'on it, checking Brevo…', disableNotification: false }),
|
|
134
|
+
).toBe(false)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('a short SILENT interim reply is neither final nor substantive', () => {
|
|
138
|
+
expect(
|
|
139
|
+
isFinalAnswerReply({ text: 'thinking…', disableNotification: true }),
|
|
140
|
+
).toBe(false)
|
|
141
|
+
expect(
|
|
142
|
+
isSubstantiveFinalReply({ text: 'thinking…', disableNotification: true }),
|
|
143
|
+
).toBe(false)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('a long reply is substantive regardless of the ping flag', () => {
|
|
147
|
+
const longText = 'x'.repeat(FINAL_ANSWER_MIN_CHARS)
|
|
148
|
+
expect(
|
|
149
|
+
isSubstantiveFinalReply({ text: longText, disableNotification: false }),
|
|
150
|
+
).toBe(true)
|
|
151
|
+
expect(
|
|
152
|
+
isSubstantiveFinalReply({ text: longText, disableNotification: true }),
|
|
153
|
+
).toBe(true)
|
|
154
|
+
})
|
|
155
|
+
})
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
ObligationLedger,
|
|
4
|
+
buildObligationRepresentInbound,
|
|
5
|
+
obligationEscalationText,
|
|
6
|
+
type Obligation,
|
|
7
|
+
} from "../gateway/obligation-ledger.js";
|
|
8
|
+
|
|
9
|
+
function input(id: string, openedAt: number, text = "do the thing") {
|
|
10
|
+
return { originTurnId: id, chatId: "-100123", threadId: 3, messageId: Number(id.split("#").pop() ?? 0), text, openedAt };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("ObligationLedger", () => {
|
|
14
|
+
it("opens, reports open, and closes by origin id", () => {
|
|
15
|
+
const L = new ObligationLedger();
|
|
16
|
+
expect(L.hasOpen()).toBe(false);
|
|
17
|
+
expect(L.openIfAbsent(input("c:3#715", 1000))).toBe(true);
|
|
18
|
+
expect(L.hasOpen()).toBe(true);
|
|
19
|
+
expect(L.isOpen("c:3#715")).toBe(true);
|
|
20
|
+
expect(L.close("c:3#715")).toBe(true);
|
|
21
|
+
expect(L.hasOpen()).toBe(false);
|
|
22
|
+
expect(L.close("c:3#715")).toBe(false); // already closed
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("openIfAbsent is idempotent — buffer-then-enqueue opens once, keeps the first", () => {
|
|
26
|
+
const L = new ObligationLedger();
|
|
27
|
+
expect(L.openIfAbsent(input("c:3#715", 1000, "first"))).toBe(true);
|
|
28
|
+
expect(L.openIfAbsent(input("c:3#715", 2000, "second"))).toBe(false);
|
|
29
|
+
expect(L.size()).toBe(1);
|
|
30
|
+
expect(L.list()[0].text).toBe("first");
|
|
31
|
+
expect(L.list()[0].openedAt).toBe(1000);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("close(null/undefined) is a safe no-op", () => {
|
|
35
|
+
const L = new ObligationLedger();
|
|
36
|
+
L.openIfAbsent(input("c:3#715", 1000));
|
|
37
|
+
expect(L.close(null)).toBe(false);
|
|
38
|
+
expect(L.close(undefined)).toBe(false);
|
|
39
|
+
expect(L.hasOpen()).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("decideAtIdle returns 'none' when nothing is open", () => {
|
|
43
|
+
expect(new ObligationLedger().decideAtIdle()).toEqual({ action: "none" });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("decideAtIdle picks the OLDEST open obligation to re-present", () => {
|
|
47
|
+
const L = new ObligationLedger();
|
|
48
|
+
L.openIfAbsent(input("c:3#715", 2000));
|
|
49
|
+
L.openIfAbsent(input("c:4#690", 1000)); // older
|
|
50
|
+
const d = L.decideAtIdle();
|
|
51
|
+
expect(d.action).toBe("represent");
|
|
52
|
+
expect(d.obligation?.originTurnId).toBe("c:4#690");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("re-presents up to maxRepresents, then escalates (no infinite loop)", () => {
|
|
56
|
+
const L = new ObligationLedger(2); // max 2 represents
|
|
57
|
+
L.openIfAbsent(input("c:3#715", 1000));
|
|
58
|
+
// represent #1
|
|
59
|
+
expect(L.decideAtIdle().action).toBe("represent");
|
|
60
|
+
expect(L.markRepresented("c:3#715")).toBe(1);
|
|
61
|
+
// represent #2
|
|
62
|
+
expect(L.decideAtIdle().action).toBe("represent");
|
|
63
|
+
expect(L.markRepresented("c:3#715")).toBe(2);
|
|
64
|
+
// now exhausted → escalate
|
|
65
|
+
const d = L.decideAtIdle();
|
|
66
|
+
expect(d.action).toBe("escalate");
|
|
67
|
+
expect(d.obligation?.originTurnId).toBe("c:3#715");
|
|
68
|
+
// caller closes on escalate → ledger empties (no loop)
|
|
69
|
+
expect(L.close("c:3#715")).toBe(true);
|
|
70
|
+
expect(L.decideAtIdle().action).toBe("none");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("the 715 scenario: open at receipt, NOT closed by an unrelated reply, re-presented", () => {
|
|
74
|
+
const L = new ObligationLedger();
|
|
75
|
+
// 713 (video, topic 635) and 715 (Meta report, topic 3) both open
|
|
76
|
+
L.openIfAbsent(input("c:635#713", 1000, "video swap task"));
|
|
77
|
+
L.openIfAbsent(input("c:3#715", 1100, "do the Meta report"));
|
|
78
|
+
// 713 gets a substantive reply resolving to its origin → close 713 only
|
|
79
|
+
expect(L.close("c:635#713")).toBe(true);
|
|
80
|
+
// 715 was only verbally deferred (no reply resolved to it) → still OPEN
|
|
81
|
+
expect(L.isOpen("c:3#715")).toBe(true);
|
|
82
|
+
// at idle, 715 is re-presented (this is the fix — the drop becomes a re-ask)
|
|
83
|
+
const d = L.decideAtIdle();
|
|
84
|
+
expect(d.action).toBe("represent");
|
|
85
|
+
expect(d.obligation?.originTurnId).toBe("c:3#715");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("markRepresented on an unknown/closed id is a harmless 0", () => {
|
|
89
|
+
const L = new ObligationLedger();
|
|
90
|
+
expect(L.markRepresented("nope")).toBe(0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("resolveCloseTarget — deterministic, holds for any model behavior", () => {
|
|
94
|
+
it("an echoed origin is authoritative (closes exactly that)", () => {
|
|
95
|
+
const L = new ObligationLedger();
|
|
96
|
+
L.openIfAbsent(input("c:635#713", 1000));
|
|
97
|
+
L.openIfAbsent(input("c:3#715", 1100));
|
|
98
|
+
// model echoed 713 while live turn is 715 → close 713, NOT the live turn
|
|
99
|
+
expect(L.resolveCloseTarget("c:635#713", "c:3#715")).toBe("c:635#713");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("no echo + exactly ONE open → close the live turn (unambiguous)", () => {
|
|
103
|
+
const L = new ObligationLedger();
|
|
104
|
+
L.openIfAbsent(input("c:3#715", 1100));
|
|
105
|
+
expect(L.resolveCloseTarget(undefined, "c:3#715")).toBe("c:3#715");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("no echo + MULTIPLE open → close NOTHING (never wrong-close/drop)", () => {
|
|
109
|
+
const L = new ObligationLedger();
|
|
110
|
+
L.openIfAbsent(input("c:635#713", 1000));
|
|
111
|
+
L.openIfAbsent(input("c:3#715", 1100));
|
|
112
|
+
// the marko race: 713's un-echoed reply lands while currentTurn=715.
|
|
113
|
+
// Closing 715 would silently drop it → resolveCloseTarget refuses.
|
|
114
|
+
expect(L.resolveCloseTarget(undefined, "c:3#715")).toBeNull();
|
|
115
|
+
expect(L.isOpen("c:3#715")).toBe(true); // 715 stays open → re-presented
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("no echo + live turn not an open obligation → null", () => {
|
|
119
|
+
const L = new ObligationLedger();
|
|
120
|
+
L.openIfAbsent(input("c:3#715", 1100));
|
|
121
|
+
expect(L.resolveCloseTarget(undefined, "c:9#999")).toBeNull();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("buildObligationRepresentInbound", () => {
|
|
127
|
+
const ob: Obligation = {
|
|
128
|
+
originTurnId: "-100123:3#715",
|
|
129
|
+
chatId: "-100123",
|
|
130
|
+
threadId: 3,
|
|
131
|
+
messageId: 715,
|
|
132
|
+
text: "do the Meta report",
|
|
133
|
+
openedAt: 1000,
|
|
134
|
+
representCount: 0,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
it("carries the original message_id + origin_turn_id so the reply resolves back", () => {
|
|
138
|
+
const m = buildObligationRepresentInbound(ob, 5000);
|
|
139
|
+
expect(m.type).toBe("inbound");
|
|
140
|
+
expect(m.chatId).toBe("-100123");
|
|
141
|
+
expect(m.threadId).toBe(3);
|
|
142
|
+
expect(m.messageId).toBe(715); // ORIGINAL id → reply-quote + origin routing
|
|
143
|
+
expect(m.meta.origin_turn_id).toBe("-100123:3#715");
|
|
144
|
+
expect(m.meta.source).toBe("obligation_represent"); // synthetic → not tracked, no new obligation
|
|
145
|
+
expect(m.meta.represent_count).toBe("1");
|
|
146
|
+
expect(m.text).toContain("do the Meta report");
|
|
147
|
+
expect(m.text).toMatch(/answer it now|reply tool/i);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("omits threadId for a DM obligation", () => {
|
|
151
|
+
const m = buildObligationRepresentInbound({ ...ob, threadId: undefined }, 5000);
|
|
152
|
+
expect(m.threadId).toBeUndefined();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("truncates a long original to ~200 chars", () => {
|
|
156
|
+
const long = "x".repeat(500);
|
|
157
|
+
const m = buildObligationRepresentInbound({ ...ob, text: long }, 5000);
|
|
158
|
+
expect(m.text).toContain("…");
|
|
159
|
+
expect(m.text).not.toContain("x".repeat(201));
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("escalation text names the message and asks to re-send", () => {
|
|
163
|
+
expect(obligationEscalationText(ob)).toMatch(/missed|not sure/i);
|
|
164
|
+
expect(obligationEscalationText(ob)).toContain("do the Meta report");
|
|
165
|
+
expect(obligationEscalationText(ob)).toMatch(/re-?send/i);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -146,6 +146,33 @@ describe("appendActivityLine + renderActivityFeed — accumulating activity feed
|
|
|
146
146
|
it("final defaults false (live render keeps the → in-progress newest line)", () => {
|
|
147
147
|
expect(renderActivityFeed(["Reading a.ts"])).toBe("<b>→ Reading a.ts</b>");
|
|
148
148
|
});
|
|
149
|
+
|
|
150
|
+
// liveSuffix (PR1 heartbeat): appended INSIDE the newest in-progress line so a
|
|
151
|
+
// long single step visibly advances ("→ Pulling Meta data · 18s") even though
|
|
152
|
+
// the feed is pull-only and no new tool label arrived.
|
|
153
|
+
describe("liveSuffix (heartbeat)", () => {
|
|
154
|
+
it("appends the suffix to the newest in-progress line only", () => {
|
|
155
|
+
expect(renderActivityFeed(["Reading a.ts", "Running a command"], false, " · 18s")).toBe(
|
|
156
|
+
"<i>✓ Reading a.ts</i>\n<b>→ Running a command · 18s</b>",
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
it("single live line gets the suffix", () => {
|
|
160
|
+
expect(renderActivityFeed(["Pulling Meta data"], false, " · 1m05s")).toBe(
|
|
161
|
+
"<b>→ Pulling Meta data · 1m05s</b>",
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
it("final=true ignores the suffix (a finalized record never ticks)", () => {
|
|
165
|
+
const out = renderActivityFeed(["Reading a.ts", "Running a command"], true, " · 18s")!;
|
|
166
|
+
expect(out).not.toContain("·");
|
|
167
|
+
expect(out).not.toContain("→");
|
|
168
|
+
expect(out).toBe("<i>✓ Reading a.ts</i>\n<i>✓ Running a command</i>");
|
|
169
|
+
});
|
|
170
|
+
it("default empty suffix is byte-identical to no suffix", () => {
|
|
171
|
+
expect(renderActivityFeed(["Reading a.ts"], false, "")).toBe(
|
|
172
|
+
renderActivityFeed(["Reading a.ts"]),
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
149
176
|
});
|
|
150
177
|
|
|
151
178
|
describe("appendActivityLabel — precomputed label feed (tool_label path)", () => {
|
|
@@ -223,6 +250,23 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
|
|
|
223
250
|
);
|
|
224
251
|
});
|
|
225
252
|
|
|
253
|
+
it("liveSuffix (heartbeat) lands on the nested newest in-progress step", () => {
|
|
254
|
+
const out = renderActivityFeedWithNested(
|
|
255
|
+
["Delegating: x"],
|
|
256
|
+
["Reading schema.ts", "Looking for foreign keys"],
|
|
257
|
+
false,
|
|
258
|
+
" · 22s",
|
|
259
|
+
)!;
|
|
260
|
+
expect(out).toContain(" ↳ <b>→ Looking for foreign keys · 22s</b>");
|
|
261
|
+
expect(out).not.toContain("Reading schema.ts · "); // only the newest line ticks
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("liveSuffix passes through to the flat render when there are no children", () => {
|
|
265
|
+
expect(renderActivityFeedWithNested(["Reading a.ts"], [], false, " · 9s")).toBe(
|
|
266
|
+
"<b>→ Reading a.ts · 9s</b>",
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
|
|
226
270
|
// Pins the invariant the gateway's foreground handoff-clear path relies on:
|
|
227
271
|
// on an ack-first turn the parent feed is empty (mirrorLines=[]) and the only
|
|
228
272
|
// content is the foreground sub-agent's nested narrative. The finalized
|
|
@@ -200,7 +200,11 @@ function escapeFeedHtml(s: string): string {
|
|
|
200
200
|
* `✓ +N earlier…` header when the turn ran longer. Returns null when empty.
|
|
201
201
|
* Callers send the result verbatim — do NOT re-escape or re-wrap it.
|
|
202
202
|
*/
|
|
203
|
-
export function renderActivityFeed(
|
|
203
|
+
export function renderActivityFeed(
|
|
204
|
+
lines: string[],
|
|
205
|
+
final = false,
|
|
206
|
+
liveSuffix = "",
|
|
207
|
+
): string | null {
|
|
204
208
|
if (lines.length === 0) return null;
|
|
205
209
|
const shown = lines.slice(-MIRROR_MAX_LINES);
|
|
206
210
|
const hidden = lines.length - shown.length;
|
|
@@ -210,10 +214,14 @@ export function renderActivityFeed(lines: string[], final = false): string | nul
|
|
|
210
214
|
// Newest line = in-progress step (bold, →); earlier = done (italic, ✓).
|
|
211
215
|
// `final` (turn complete, feed left as a record): ALL lines render done (✓)
|
|
212
216
|
// so the persisted message doesn't freeze on a misleading "→ in-progress".
|
|
217
|
+
// `liveSuffix` (heartbeat): appended INSIDE the newest in-progress line only
|
|
218
|
+
// (e.g. " · 18s") so the feed visibly advances during a long single step that
|
|
219
|
+
// emits no new tool label — the feed is otherwise pull-only and freezes.
|
|
220
|
+
// Caller passes framework-generated, HTML-safe text; never final + suffix.
|
|
213
221
|
// Returns ready Telegram HTML — callers must NOT re-escape or re-wrap it.
|
|
214
222
|
shown.forEach((l, i) => {
|
|
215
223
|
const esc = escapeFeedHtml(l);
|
|
216
|
-
out.push(i === lastIdx && !final ? `<b>→ ${esc}</b>` : `<i>✓ ${esc}</i>`);
|
|
224
|
+
out.push(i === lastIdx && !final ? `<b>→ ${esc}${liveSuffix}</b>` : `<i>✓ ${esc}</i>`);
|
|
217
225
|
});
|
|
218
226
|
return out.join("\n");
|
|
219
227
|
}
|
|
@@ -248,9 +256,10 @@ export function renderActivityFeedWithNested(
|
|
|
248
256
|
lines: string[],
|
|
249
257
|
childLines: string[],
|
|
250
258
|
final = false,
|
|
259
|
+
liveSuffix = "",
|
|
251
260
|
): string | null {
|
|
252
261
|
const children = childLines.map((s) => s.trim()).filter((s) => s.length > 0);
|
|
253
|
-
if (children.length === 0) return renderActivityFeed(lines, final);
|
|
262
|
+
if (children.length === 0) return renderActivityFeed(lines, final, liveSuffix);
|
|
254
263
|
|
|
255
264
|
const out: string[] = [];
|
|
256
265
|
const shownParent = lines.slice(-MIRROR_MAX_LINES);
|
|
@@ -264,12 +273,13 @@ export function renderActivityFeedWithNested(
|
|
|
264
273
|
const lastChildIdx = shownChild.length - 1;
|
|
265
274
|
// `final`: the nested newest step also renders done (✓) so the left-behind
|
|
266
275
|
// feed reads as completed, not stuck on a "→ in-progress" child step.
|
|
276
|
+
// `liveSuffix` (heartbeat): appended to the nested newest in-progress step.
|
|
267
277
|
shownChild.forEach((l, i) => {
|
|
268
278
|
const t = l.length > NESTED_LINE_MAX ? l.slice(0, NESTED_LINE_MAX - 1) + "…" : l;
|
|
269
279
|
const esc = escapeFeedHtml(t);
|
|
270
280
|
out.push(
|
|
271
281
|
i === lastChildIdx && !final
|
|
272
|
-
? `${NESTED_PREFIX}<b>→ ${esc}</b>`
|
|
282
|
+
? `${NESTED_PREFIX}<b>→ ${esc}${liveSuffix}</b>`
|
|
273
283
|
: `${NESTED_PREFIX}<i>${esc}</i>`,
|
|
274
284
|
);
|
|
275
285
|
});
|