switchroom 0.18.32 → 0.19.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.
- package/dist/auth-broker/index.js +17 -1
- package/dist/cli/switchroom.js +847 -729
- package/dist/host-control/main.js +18 -2
- package/dist/vault/approvals/kernel-server.js +17 -1
- package/dist/vault/broker/server.js +44 -2
- package/package.json +2 -2
- package/profiles/_base/start.sh.hbs +105 -18
- package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
- package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
- package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
- package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
- package/telegram-plugin/gateway/boot-card.ts +169 -1
- package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
- package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
- package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
- package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
- package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
- package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
- package/telegram-plugin/gateway/gateway.ts +1347 -6758
- package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
- package/telegram-plugin/gateway/inbound-router.ts +400 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
- package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
- package/telegram-plugin/gateway/model-command.ts +23 -0
- package/telegram-plugin/gateway/narrative-lane.ts +865 -0
- package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
- package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
- package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
- package/telegram-plugin/gateway/secret-request-card.ts +45 -0
- package/telegram-plugin/gateway/stream-render.ts +2166 -0
- package/telegram-plugin/gateway/turn-end.ts +606 -0
- package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
- package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
- package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
- package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
- package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
- package/telegram-plugin/narrative-dedup.ts +24 -1
- package/telegram-plugin/narrative-flush.ts +2 -2
- package/telegram-plugin/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +186 -3
- package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
- package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
- package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
- package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
- package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
- package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
- package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +15 -6
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
- package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
- package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +47 -10
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
- package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +14 -3
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
- package/telegram-plugin/tests/model-command.test.ts +30 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +27 -9
- package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
- package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
- package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +16 -7
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
- package/telegram-plugin/tests/render/render.test.ts +42 -0
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +28 -18
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
- package/telegram-plugin/tests/stop-command.test.ts +22 -12
- package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
- package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +14 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
- package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
- package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
- package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
- package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +110 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +83 -35
- package/telegram-plugin/turn-flush-safety.ts +80 -14
- package/telegram-plugin/uat/restart-capability.ts +76 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
- package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
- package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
- package/telegram-plugin/worker-activity-feed.ts +10 -4
|
@@ -28,13 +28,21 @@ import { readFileSync } from "node:fs";
|
|
|
28
28
|
import { resolve } from "node:path";
|
|
29
29
|
|
|
30
30
|
// #2996 Phase 5: the callback-query handler families moved verbatim to
|
|
31
|
-
// gateway/callback-query-handlers.ts
|
|
32
|
-
//
|
|
33
|
-
//
|
|
31
|
+
// gateway/callback-query-handlers.ts, and the agent-facing card-STAGING
|
|
32
|
+
// execute* tool handlers (vault_request_save & co) moved verbatim to
|
|
33
|
+
// gateway/card-tool-handlers.ts (#2996 P5-tail); these pins read the gateway
|
|
34
|
+
// source COMBINED with those modules so the wiring assertions keep covering
|
|
35
|
+
// the same runtime source text.
|
|
34
36
|
const gatewaySrc =
|
|
35
37
|
readFileSync(resolve(__dirname, "..", "gateway", "gateway.ts"), "utf-8") +
|
|
36
38
|
"\n" +
|
|
37
|
-
readFileSync(resolve(__dirname, "..", "gateway", "callback-query-handlers.ts"), "utf-8")
|
|
39
|
+
readFileSync(resolve(__dirname, "..", "gateway", "callback-query-handlers.ts"), "utf-8") +
|
|
40
|
+
"\n" +
|
|
41
|
+
readFileSync(resolve(__dirname, "..", "gateway", "card-tool-handlers.ts"), "utf-8") +
|
|
42
|
+
"\n" +
|
|
43
|
+
// P7 PR-8 (#2996): the vault pending-op intercept (incl. the rename-staged-
|
|
44
|
+
// key validation) moved verbatim into gateway/inbound-interceptors.ts.
|
|
45
|
+
readFileSync(resolve(__dirname, "..", "gateway", "inbound-interceptors.ts"), "utf-8");
|
|
38
46
|
|
|
39
47
|
/** The exported regex literal — what the gateway actually validates against. */
|
|
40
48
|
function extractVaultKeyRegex(): RegExp {
|
|
@@ -49,7 +57,10 @@ function extractVaultKeyRegex(): RegExp {
|
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
/** Either inline literal with `/`, OR a reference to VAULT_KEY_REGEX. */
|
|
52
|
-
|
|
60
|
+
// `vaultKeyRegex` is the InboundInterceptorDeps field gateway.ts binds to the
|
|
61
|
+
// shared VAULT_KEY_REGEX constant (P7 PR-8) — same runtime regex, new name at
|
|
62
|
+
// the moved call site.
|
|
63
|
+
const ACCEPTS_SLASH = /\[A-Za-z0-9_(\/|\\\/|\.)+-\]|VAULT_KEY_REGEX|vaultKeyRegex/;
|
|
53
64
|
|
|
54
65
|
describe("vault-key regex accepts canonical slash-namespaced keys (#1047)", () => {
|
|
55
66
|
it("vault_request_save validation includes '/' in the charclass", () => {
|
|
@@ -24,13 +24,16 @@ const bridgeSrc = readFileSync(
|
|
|
24
24
|
'utf-8',
|
|
25
25
|
)
|
|
26
26
|
// #2996 Phase 5: the callback-query handler families moved verbatim to
|
|
27
|
-
// gateway/callback-query-handlers.ts
|
|
28
|
-
//
|
|
29
|
-
//
|
|
27
|
+
// gateway/callback-query-handlers.ts, and the agent-facing card-STAGING
|
|
28
|
+
// execute* tool handlers moved verbatim to gateway/card-tool-handlers.ts
|
|
29
|
+
// (#2996 P5-tail); these pins read the gateway source COMBINED with those
|
|
30
|
+
// modules so the wiring assertions keep covering the same runtime source text.
|
|
30
31
|
const gatewaySrc =
|
|
31
32
|
readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8') +
|
|
32
33
|
'\n' +
|
|
33
|
-
readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8')
|
|
34
|
+
readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8') +
|
|
35
|
+
'\n' +
|
|
36
|
+
readFileSync(resolve(__dirname, '..', 'gateway', 'card-tool-handlers.ts'), 'utf-8')
|
|
34
37
|
|
|
35
38
|
describe('vault_request_access (#1012)', () => {
|
|
36
39
|
it('bridge advertises the tool to MCP clients', () => {
|
|
@@ -64,7 +67,9 @@ describe('vault_request_access (#1012)', () => {
|
|
|
64
67
|
it('gateway routes vault_request_access in executeToolCall', () => {
|
|
65
68
|
// fails when: the switch arm is dropped. Tool would be accepted
|
|
66
69
|
// by ALLOWED_TOOLS but fall through to the `unknown tool` branch.
|
|
67
|
-
|
|
70
|
+
// #2996 P5-tail: the dispatcher arm now routes through the extracted
|
|
71
|
+
// card-tool-handlers factory instance.
|
|
72
|
+
expect(gatewaySrc).toMatch(/case\s+'vault_request_access':\s*\n\s*return\s+cardToolHandlers\.executeVaultRequestAccess/)
|
|
68
73
|
})
|
|
69
74
|
|
|
70
75
|
it('gateway dispatches vra: callback prefix', () => {
|
|
@@ -29,7 +29,10 @@ import { resolve } from 'node:path'
|
|
|
29
29
|
const gatewaySrc =
|
|
30
30
|
readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8') +
|
|
31
31
|
'\n' +
|
|
32
|
-
readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8')
|
|
32
|
+
readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8') +
|
|
33
|
+
'\n' +
|
|
34
|
+
// P7 PR-8 (#2996): vault pending-op intercept moved to inbound-interceptors.ts.
|
|
35
|
+
readFileSync(resolve(__dirname, '..', 'gateway', 'inbound-interceptors.ts'), 'utf-8')
|
|
33
36
|
|
|
34
37
|
describe('vault_request_access — tap-to-unlock-and-approve UX', () => {
|
|
35
38
|
it('declares the passphrase-for-access-approve PendingVaultOp variant', () => {
|
|
@@ -30,7 +30,12 @@ import { join, dirname } from 'node:path'
|
|
|
30
30
|
const __dir = dirname(fileURLToPath(import.meta.url))
|
|
31
31
|
const pluginDir = join(__dir, '..')
|
|
32
32
|
|
|
33
|
-
const gatewaySrc =
|
|
33
|
+
const gatewaySrc =
|
|
34
|
+
readFileSync(join(pluginDir, 'gateway', 'gateway.ts'), 'utf8') +
|
|
35
|
+
'\n' +
|
|
36
|
+
// P7 PR-8 (#2996): the vault pending-op intercept (incl. the unlock branch)
|
|
37
|
+
// moved verbatim into gateway/inbound-interceptors.ts (interceptVault).
|
|
38
|
+
readFileSync(join(pluginDir, 'gateway', 'inbound-interceptors.ts'), 'utf8')
|
|
34
39
|
|
|
35
40
|
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
36
41
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outcome pins for the voice inbound handler extracted from gateway.ts
|
|
3
|
+
* (switchroom#2996 P6 cluster E). Drives the engine-selection + transcription
|
|
4
|
+
* fallback branch against injected access / host-capability / transcribe
|
|
5
|
+
* surfaces + a mock coalescing dispatch. Asserts the EXACT envelope forwarded
|
|
6
|
+
* to handleInboundCoalesced for each path (local/cloud transcript, caption
|
|
7
|
+
* prefix, disabled, and every fallback to the legacy "(voice message)").
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
11
|
+
import { handleVoiceMessage, type VoiceHandlerDeps } from '../gateway/voice-message-handler.js'
|
|
12
|
+
|
|
13
|
+
const VOICE_META = { kind: 'voice', file_id: 'v1', size: 100, mime: 'audio/ogg' }
|
|
14
|
+
|
|
15
|
+
function makeDeps(over: Partial<VoiceHandlerDeps> = {}) {
|
|
16
|
+
const handleInboundCoalesced = vi.fn(async () => {})
|
|
17
|
+
const deps: VoiceHandlerDeps = {
|
|
18
|
+
loadAccess: () => ({ voice_in: { enabled: true, provider: 'openai', api_key: 'k', language: 'en' } }),
|
|
19
|
+
loadHostCapabilities: () => null,
|
|
20
|
+
maybeTranscribeVoiceLocal: vi.fn(async () => 'LOCAL TEXT'),
|
|
21
|
+
maybeTranscribeVoice: vi.fn(async () => 'CLOUD TEXT'),
|
|
22
|
+
handleInboundCoalesced,
|
|
23
|
+
...over,
|
|
24
|
+
}
|
|
25
|
+
return { deps, handleInboundCoalesced }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function ctxWith(caption?: string): any {
|
|
29
|
+
return { message: { voice: { file_id: 'v1', file_size: 100, mime_type: 'audio/ogg' }, caption }, chat: { id: 42 } }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.restoreAllMocks()
|
|
34
|
+
delete process.env.SWITCHROOM_VOICE_ENGINE
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('handleVoiceMessage — cloud path', () => {
|
|
38
|
+
it('transcribes via cloud and forwards a [voice transcript] envelope', async () => {
|
|
39
|
+
delete process.env.SWITCHROOM_VOICE_ENGINE
|
|
40
|
+
const { deps, handleInboundCoalesced } = makeDeps()
|
|
41
|
+
await handleVoiceMessage(ctxWith(), deps)
|
|
42
|
+
expect(deps.maybeTranscribeVoice).toHaveBeenCalledWith('v1', 'audio/ogg', 'en', 'k')
|
|
43
|
+
expect(deps.maybeTranscribeVoiceLocal).not.toHaveBeenCalled()
|
|
44
|
+
expect(handleInboundCoalesced).toHaveBeenCalledWith(
|
|
45
|
+
expect.anything(), '[voice transcript] CLOUD TEXT', undefined, VOICE_META,
|
|
46
|
+
)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('prefixes the caption to the transcript when present', async () => {
|
|
50
|
+
const { deps, handleInboundCoalesced } = makeDeps()
|
|
51
|
+
await handleVoiceMessage(ctxWith('note:'), deps)
|
|
52
|
+
expect(handleInboundCoalesced.mock.calls[0][1]).toBe('note:\n\n[voice transcript] CLOUD TEXT')
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('handleVoiceMessage — local path', () => {
|
|
57
|
+
it('routes to the local sidecar when the engine env is local', async () => {
|
|
58
|
+
process.env.SWITCHROOM_VOICE_ENGINE = 'local'
|
|
59
|
+
// voice_in.provider need not be openai for the local path.
|
|
60
|
+
const { deps, handleInboundCoalesced } = makeDeps({
|
|
61
|
+
loadAccess: () => ({ voice_in: { enabled: true, language: 'de' } }),
|
|
62
|
+
})
|
|
63
|
+
await handleVoiceMessage(ctxWith(), deps)
|
|
64
|
+
expect(deps.maybeTranscribeVoiceLocal).toHaveBeenCalledWith('v1', 'audio/ogg', 'de')
|
|
65
|
+
expect(deps.maybeTranscribeVoice).not.toHaveBeenCalled()
|
|
66
|
+
expect(handleInboundCoalesced.mock.calls[0][1]).toBe('[voice transcript] LOCAL TEXT')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('reads the engine from host-capabilities when env is unset', async () => {
|
|
70
|
+
delete process.env.SWITCHROOM_VOICE_ENGINE
|
|
71
|
+
const { deps } = makeDeps({
|
|
72
|
+
loadAccess: () => ({ voice_in: { enabled: true } }),
|
|
73
|
+
loadHostCapabilities: () => ({ voice: { engine: 'local' } }),
|
|
74
|
+
})
|
|
75
|
+
await handleVoiceMessage(ctxWith(), deps)
|
|
76
|
+
expect(deps.maybeTranscribeVoiceLocal).toHaveBeenCalled()
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('handleVoiceMessage — fallbacks to the legacy envelope', () => {
|
|
81
|
+
it('falls back when voice_in is disabled', async () => {
|
|
82
|
+
const { deps, handleInboundCoalesced } = makeDeps({ loadAccess: () => ({}) })
|
|
83
|
+
await handleVoiceMessage(ctxWith(), deps)
|
|
84
|
+
expect(deps.maybeTranscribeVoice).not.toHaveBeenCalled()
|
|
85
|
+
expect(handleInboundCoalesced).toHaveBeenCalledWith(
|
|
86
|
+
expect.anything(), '(voice message)', undefined, VOICE_META,
|
|
87
|
+
)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('falls back to the legacy envelope on transcription failure', async () => {
|
|
91
|
+
const { deps, handleInboundCoalesced } = makeDeps({ maybeTranscribeVoice: vi.fn(async () => null) })
|
|
92
|
+
await handleVoiceMessage(ctxWith(), deps)
|
|
93
|
+
expect(handleInboundCoalesced).toHaveBeenCalledWith(
|
|
94
|
+
expect.anything(), '(voice message)', undefined, VOICE_META,
|
|
95
|
+
)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('uses the caption as the legacy envelope when present', async () => {
|
|
99
|
+
const { deps, handleInboundCoalesced } = makeDeps({ loadAccess: () => ({}) })
|
|
100
|
+
await handleVoiceMessage(ctxWith('hi there'), deps)
|
|
101
|
+
expect(handleInboundCoalesced.mock.calls[0][1]).toBe('hi there')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('does not use the cloud path when provider is not openai', async () => {
|
|
105
|
+
delete process.env.SWITCHROOM_VOICE_ENGINE
|
|
106
|
+
const { deps } = makeDeps({ loadAccess: () => ({ voice_in: { enabled: true } }) })
|
|
107
|
+
await handleVoiceMessage(ctxWith(), deps)
|
|
108
|
+
expect(deps.maybeTranscribeVoice).not.toHaveBeenCalled()
|
|
109
|
+
expect(deps.maybeTranscribeVoiceLocal).not.toHaveBeenCalled()
|
|
110
|
+
})
|
|
111
|
+
})
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outcome pins for the on-demand voice ('🔊 Listen') callback family
|
|
3
|
+
* (`voice:<token>`) extracted from the gateway's callback_query dispatcher
|
|
4
|
+
* (switchroom#2996 P6). Uses the REAL voice-ondemand token helpers and the
|
|
5
|
+
* REAL sendVoiceReusingFileId contract via injected send fns: asserts the
|
|
6
|
+
* fall-through for non-voice data, the auth gate, expiry toast, the
|
|
7
|
+
* file_id fast path (no upload), the upload path capturing the returned
|
|
8
|
+
* file_id, and the single-use keyboard strip on success only.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
12
|
+
import {
|
|
13
|
+
handleVoiceOnDemandCallback,
|
|
14
|
+
type VoiceOnDemandCallbackDeps,
|
|
15
|
+
} from '../gateway/voice-ondemand-callback-handler.js'
|
|
16
|
+
|
|
17
|
+
const TOKEN = 'tok123'
|
|
18
|
+
const DATA = `voice:${TOKEN}`
|
|
19
|
+
|
|
20
|
+
function makeCtx(over: Record<string, unknown> = {}) {
|
|
21
|
+
return {
|
|
22
|
+
from: { id: 42 },
|
|
23
|
+
chat: { id: 42 },
|
|
24
|
+
callbackQuery: { message: { message_id: 77 } },
|
|
25
|
+
answerCallbackQuery: vi.fn(async () => {}),
|
|
26
|
+
...over,
|
|
27
|
+
} as any
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makeDeps(over: Partial<VoiceOnDemandCallbackDeps> = {}) {
|
|
31
|
+
const setTelegramFileId = vi.fn()
|
|
32
|
+
const sendVoiceByFileId = vi.fn(async () => ({ voice: { file_id: 'same' } }))
|
|
33
|
+
const sendVoiceByUpload = vi.fn(async () => ({ voice: { file_id: 'fresh-id' } }))
|
|
34
|
+
const stripListenKeyboard = vi.fn(async () => {})
|
|
35
|
+
const log = vi.fn()
|
|
36
|
+
const deps: VoiceOnDemandCallbackDeps = {
|
|
37
|
+
loadAccess: () => ({ allowFrom: ['42'] }),
|
|
38
|
+
voiceOnDemandCache: {
|
|
39
|
+
get: () => null,
|
|
40
|
+
setTelegramFileId,
|
|
41
|
+
},
|
|
42
|
+
sendVoiceByFileId,
|
|
43
|
+
sendVoiceByUpload,
|
|
44
|
+
stripListenKeyboard,
|
|
45
|
+
log,
|
|
46
|
+
...over,
|
|
47
|
+
}
|
|
48
|
+
return { deps, setTelegramFileId, sendVoiceByFileId, sendVoiceByUpload, stripListenKeyboard, log }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('handleVoiceOnDemandCallback — routing + gates', () => {
|
|
52
|
+
it('returns false for non-voice callback data', async () => {
|
|
53
|
+
const { deps } = makeDeps()
|
|
54
|
+
const ctx = makeCtx()
|
|
55
|
+
expect(await handleVoiceOnDemandCallback(ctx, 'agent:whatever', deps)).toBe(false)
|
|
56
|
+
expect(ctx.answerCallbackQuery).not.toHaveBeenCalled()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('gates non-allowlisted senders', async () => {
|
|
60
|
+
const { deps, sendVoiceByFileId } = makeDeps()
|
|
61
|
+
const ctx = makeCtx({ from: { id: 666 } })
|
|
62
|
+
expect(await handleVoiceOnDemandCallback(ctx, DATA, deps)).toBe(true)
|
|
63
|
+
expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Not authorized.' })
|
|
64
|
+
expect(sendVoiceByFileId).not.toHaveBeenCalled()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('acks an expired token politely', async () => {
|
|
68
|
+
const { deps } = makeDeps() // cache.get → null
|
|
69
|
+
const ctx = makeCtx()
|
|
70
|
+
expect(await handleVoiceOnDemandCallback(ctx, DATA, deps)).toBe(true)
|
|
71
|
+
expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({
|
|
72
|
+
text: 'Voice expired — send again to hear it.',
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('handleVoiceOnDemandCallback — send paths', () => {
|
|
78
|
+
it('file_id fast path: sends BY id, strips the keyboard, no upload', async () => {
|
|
79
|
+
const { deps, sendVoiceByFileId, sendVoiceByUpload, stripListenKeyboard } = makeDeps({
|
|
80
|
+
voiceOnDemandCache: {
|
|
81
|
+
get: () => ({ text: 'hello', speed: 1, telegramFileId: 'cached-id' }),
|
|
82
|
+
setTelegramFileId: vi.fn(),
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
const ctx = makeCtx()
|
|
86
|
+
expect(await handleVoiceOnDemandCallback(ctx, DATA, deps)).toBe(true)
|
|
87
|
+
expect(sendVoiceByFileId).toHaveBeenCalledWith(
|
|
88
|
+
'42',
|
|
89
|
+
'cached-id',
|
|
90
|
+
expect.objectContaining({ reply_parameters: { message_id: 77 } }),
|
|
91
|
+
undefined,
|
|
92
|
+
)
|
|
93
|
+
expect(sendVoiceByUpload).not.toHaveBeenCalled()
|
|
94
|
+
expect(stripListenKeyboard).toHaveBeenCalledWith('42', 77, undefined)
|
|
95
|
+
expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: '🔊' })
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('pre-synth file path: uploads the on-disk audio and captures the returned file_id', async () => {
|
|
99
|
+
const { mkdtempSync, writeFileSync } = await import('node:fs')
|
|
100
|
+
const { tmpdir } = await import('node:os')
|
|
101
|
+
const { join } = await import('node:path')
|
|
102
|
+
const dir = mkdtempSync(join(tmpdir(), 'voice-od-'))
|
|
103
|
+
const fp = join(dir, 'a.ogg')
|
|
104
|
+
writeFileSync(fp, Buffer.from([1, 2, 3]))
|
|
105
|
+
const setTelegramFileId = vi.fn()
|
|
106
|
+
const { deps, sendVoiceByUpload, stripListenKeyboard } = makeDeps({
|
|
107
|
+
voiceOnDemandCache: {
|
|
108
|
+
get: () => ({ text: 'hello', speed: 1, filePath: fp }),
|
|
109
|
+
setTelegramFileId,
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
const ctx = makeCtx()
|
|
113
|
+
await handleVoiceOnDemandCallback(ctx, DATA, deps)
|
|
114
|
+
expect(sendVoiceByUpload).toHaveBeenCalled()
|
|
115
|
+
// Returned message's file_id captured back into the cache for reuse.
|
|
116
|
+
expect(setTelegramFileId).toHaveBeenCalledWith(TOKEN, 'fresh-id')
|
|
117
|
+
expect(stripListenKeyboard).toHaveBeenCalled()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('send failure: logs non-fatally and leaves the keyboard intact (retryable)', async () => {
|
|
121
|
+
const { deps, stripListenKeyboard, log } = makeDeps({
|
|
122
|
+
voiceOnDemandCache: {
|
|
123
|
+
get: () => ({ text: 'hello', speed: 1, telegramFileId: 'cached-id' }),
|
|
124
|
+
setTelegramFileId: vi.fn(),
|
|
125
|
+
},
|
|
126
|
+
sendVoiceByFileId: vi.fn(async () => {
|
|
127
|
+
throw new Error('boom')
|
|
128
|
+
}),
|
|
129
|
+
// Stale-id fallback then tries upload; make it fail too so the send
|
|
130
|
+
// resolves as send-failed overall. filePath absent → lazy synth path
|
|
131
|
+
// would need the sidecar; keep it simple: upload throws.
|
|
132
|
+
sendVoiceByUpload: vi.fn(async () => {
|
|
133
|
+
throw new Error('boom2')
|
|
134
|
+
}),
|
|
135
|
+
})
|
|
136
|
+
const ctx = makeCtx()
|
|
137
|
+
expect(await handleVoiceOnDemandCallback(ctx, DATA, deps)).toBe(true)
|
|
138
|
+
expect(stripListenKeyboard).not.toHaveBeenCalled()
|
|
139
|
+
})
|
|
140
|
+
})
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
type WorkerActivityView,
|
|
7
7
|
type BotApiForWorkerFeed,
|
|
8
8
|
} from '../worker-activity-feed.js'
|
|
9
|
-
import { STATUS_ROLLING_LINES, STATUS_LINE_MAX } from '../status-no-truncate.js'
|
|
9
|
+
import { STATUS_ROLLING_LINES, WORKER_HISTORY_MAX, STATUS_LINE_MAX } from '../status-no-truncate.js'
|
|
10
10
|
import { SEND_GATE_SHED } from '../send-gate.js'
|
|
11
11
|
|
|
12
12
|
describe('isWorkerActivityFeedEnabled (default ON)', () => {
|
|
@@ -188,17 +188,31 @@ describe('renderWorkerActivity', () => {
|
|
|
188
188
|
expect(stepCount(out)).toBe(2)
|
|
189
189
|
})
|
|
190
190
|
|
|
191
|
-
it('shows a "+N earlier…" header when the feed exceeds
|
|
192
|
-
const total =
|
|
191
|
+
it('shows a "+N earlier…" header when the feed exceeds WORKER_HISTORY_MAX (worker surface, #3349)', () => {
|
|
192
|
+
const total = WORKER_HISTORY_MAX + 3
|
|
193
193
|
const lines = Array.from({ length: total }, (_, i) => `step ${i + 1}`)
|
|
194
194
|
const out = renderWorkerActivity(view({ narrativeLines: lines }))
|
|
195
|
-
expect(out).toContain(`_✓ +${total -
|
|
195
|
+
expect(out).toContain(`_✓ +${total - WORKER_HISTORY_MAX} earlier…_`)
|
|
196
196
|
expect(out).not.toContain('step 1<')
|
|
197
|
-
const firstVisible = total -
|
|
197
|
+
const firstVisible = total - WORKER_HISTORY_MAX + 1
|
|
198
198
|
expect(out).toContain(`~~_✓ step ${firstVisible}_~~`)
|
|
199
199
|
expect(out).toContain(`**→ step ${total}**`)
|
|
200
|
-
//
|
|
201
|
-
expect(out.match(/step \d/g) ?? []).toHaveLength(
|
|
200
|
+
// WORKER_HISTORY_MAX visible step lines (the overflow header isn't a step).
|
|
201
|
+
expect(out.match(/step \d/g) ?? []).toHaveLength(WORKER_HISTORY_MAX)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('the lone-worker card shows the full 6-step trail — the raised ceiling (#3349)', () => {
|
|
205
|
+
// Ken's curve at w=1 is 6; the single-worker surface must reach it (the old
|
|
206
|
+
// STATUS_ROLLING_LINES=5 cap could never show the 6th step).
|
|
207
|
+
expect(WORKER_HISTORY_MAX).toBe(6)
|
|
208
|
+
const lines = Array.from({ length: 8 }, (_, n) => `ln-${n + 1}`)
|
|
209
|
+
const out = renderWorkerActivity(view({ narrativeLines: lines }))
|
|
210
|
+
// Last 6 render (ln-3 … ln-8); the two oldest collapse into a "+2 earlier…".
|
|
211
|
+
for (let n = 3; n <= 8; n++) expect(out).toContain(`ln-${n}`)
|
|
212
|
+
expect(out).toContain('_✓ +2 earlier…_')
|
|
213
|
+
expect(out).toContain('**→ ln-8**') // newest last, bold in-progress
|
|
214
|
+
// Exactly 6 rendered step bullets (the +N header is not a bullet).
|
|
215
|
+
expect((out.match(/ln-\d/g) ?? []).length).toBe(6)
|
|
202
216
|
})
|
|
203
217
|
|
|
204
218
|
it('strips the CONTENT Markdown markup (the card keeps its own ** / _ wrappers, #2669)', () => {
|
|
@@ -457,7 +471,7 @@ describe('createWorkerActivityFeed', () => {
|
|
|
457
471
|
expect(last.text.match(/[✓→]/g) ?? []).toHaveLength(1)
|
|
458
472
|
})
|
|
459
473
|
|
|
460
|
-
it('rolls the narrative block to the last
|
|
474
|
+
it('rolls the narrative block to the last WORKER_HISTORY_MAX lines (#3349)', async () => {
|
|
461
475
|
const bot = makeFakeBot()
|
|
462
476
|
let clock = 10_000
|
|
463
477
|
const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
|
|
@@ -469,12 +483,34 @@ describe('createWorkerActivityFeed', () => {
|
|
|
469
483
|
}
|
|
470
484
|
|
|
471
485
|
const last = bot.edits.at(-1)!
|
|
472
|
-
expect(last.text.match(/[✓→]/g) ?? []).toHaveLength(
|
|
473
|
-
const firstVisible = total -
|
|
486
|
+
expect(last.text.match(/[✓→]/g) ?? []).toHaveLength(WORKER_HISTORY_MAX)
|
|
487
|
+
const firstVisible = total - WORKER_HISTORY_MAX + 1
|
|
474
488
|
for (let i = 1; i < firstVisible; i++) expect(last.text).not.toContain(`ln-${String(i).padStart(3, '0')}`)
|
|
475
489
|
for (let i = firstVisible; i <= total; i++) expect(last.text).toContain(`ln-${String(i).padStart(3, '0')}`)
|
|
476
490
|
})
|
|
477
491
|
|
|
492
|
+
it('the deeper 6-step trail does NOT add edits — one landed render per substantive step (#3349, no #3270 flood)', async () => {
|
|
493
|
+
// Render-only change: edit frequency is a function of SUBSTANTIVE changes
|
|
494
|
+
// (new narrative step), never of how many trail lines a render carries. Push
|
|
495
|
+
// 8 distinct steps to a lone worker; with the edit floor at 0 exactly one
|
|
496
|
+
// render lands per step: 1 send + 7 edits = 8 landed renders, regardless of
|
|
497
|
+
// the 6-line trail depth. A widened window that re-fired edits would break
|
|
498
|
+
// this (that is the #3270 flood we must not reintroduce).
|
|
499
|
+
const bot = makeFakeBot()
|
|
500
|
+
let clock = 10_000
|
|
501
|
+
const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
|
|
502
|
+
for (let i = 1; i <= 8; i++) {
|
|
503
|
+
clock += 1000
|
|
504
|
+
await feed.update('w1', 'chat', view({ toolCount: i, latestSummary: `edit-step-${i}` }))
|
|
505
|
+
}
|
|
506
|
+
expect(bot.sent).toHaveLength(1)
|
|
507
|
+
expect(bot.edits).toHaveLength(7)
|
|
508
|
+
// Re-pushing the SAME newest step (no substance change) fires NO extra edit.
|
|
509
|
+
clock += 1000
|
|
510
|
+
await feed.update('w1', 'chat', view({ toolCount: 8, latestSummary: 'edit-step-8' }))
|
|
511
|
+
expect(bot.edits).toHaveLength(7)
|
|
512
|
+
})
|
|
513
|
+
|
|
478
514
|
it('grows the narrative even while throttled (line surfaces on next edit)', async () => {
|
|
479
515
|
const bot = makeFakeBot()
|
|
480
516
|
let clock = 10_000
|
|
@@ -576,10 +612,10 @@ describe('createWorkerActivityFeed — log sink', () => {
|
|
|
576
612
|
// char-budget backstop is the only wire-limit ceiling.
|
|
577
613
|
|
|
578
614
|
describe('rolling window + STATUS_LINE_MAX — renderWorkerActivity', () => {
|
|
579
|
-
it('with 12 narrative lines, exactly the last
|
|
615
|
+
it('with 12 narrative lines, exactly the last WORKER_HISTORY_MAX render + a +N earlier header (#3349)', () => {
|
|
580
616
|
const narrativeLines = Array.from({ length: 12 }, (_, i) => `stp-${String(i + 1).padStart(3, '0')}`)
|
|
581
617
|
const out = renderWorkerActivity(view({ narrativeLines }))
|
|
582
|
-
const firstVisible = 12 -
|
|
618
|
+
const firstVisible = 12 - WORKER_HISTORY_MAX + 1
|
|
583
619
|
for (let i = firstVisible; i <= 12; i++) {
|
|
584
620
|
expect(out).toContain(`stp-${String(i).padStart(3, '0')}`)
|
|
585
621
|
}
|
|
@@ -587,7 +623,7 @@ describe('rolling window + STATUS_LINE_MAX — renderWorkerActivity', () => {
|
|
|
587
623
|
expect(out).not.toContain(`stp-${String(i).padStart(3, '0')}`)
|
|
588
624
|
}
|
|
589
625
|
// Overflow header now appears on the worker surface too.
|
|
590
|
-
expect(out).toContain(`_✓ +${12 -
|
|
626
|
+
expect(out).toContain(`_✓ +${12 - WORKER_HISTORY_MAX} earlier…_`)
|
|
591
627
|
expect(out).toContain('**→ stp-012**')
|
|
592
628
|
})
|
|
593
629
|
|
|
@@ -623,7 +659,7 @@ describe('rolling window + STATUS_LINE_MAX — renderWorkerActivity', () => {
|
|
|
623
659
|
})
|
|
624
660
|
|
|
625
661
|
describe('rolling window — createWorkerActivityFeed narrative accumulation', () => {
|
|
626
|
-
it('with 12 pushes, only the last
|
|
662
|
+
it('with 12 pushes, only the last WORKER_HISTORY_MAX appear in the render (#3349)', async () => {
|
|
627
663
|
const bot = makeFakeBot()
|
|
628
664
|
let clock = 10_000
|
|
629
665
|
const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
|
|
@@ -634,16 +670,16 @@ describe('rolling window — createWorkerActivityFeed narrative accumulation', (
|
|
|
634
670
|
}
|
|
635
671
|
|
|
636
672
|
const last = bot.edits.at(-1)!
|
|
637
|
-
const firstVisible = 12 -
|
|
673
|
+
const firstVisible = 12 - WORKER_HISTORY_MAX + 1
|
|
638
674
|
for (let i = firstVisible; i <= 12; i++) {
|
|
639
675
|
expect(last.text).toContain(`ln-${String(i).padStart(3, '0')}`)
|
|
640
676
|
}
|
|
641
677
|
for (let i = 1; i < firstVisible; i++) {
|
|
642
678
|
expect(last.text).not.toContain(`ln-${String(i).padStart(3, '0')}`)
|
|
643
679
|
}
|
|
644
|
-
// The manager caps the in-memory narrative at
|
|
680
|
+
// The manager caps the in-memory narrative at WORKER_HISTORY_MAX, so the
|
|
645
681
|
// render never sees overflow — no "+N earlier…" marker on the manager path
|
|
646
|
-
// (it surfaces only on direct renderWorkerActivity calls with >
|
|
682
|
+
// (it surfaces only on direct renderWorkerActivity calls with >6 lines).
|
|
647
683
|
expect(last.text).not.toContain('earlier…')
|
|
648
684
|
expect(last.text).toContain('**→ ln-012**')
|
|
649
685
|
})
|
|
@@ -1027,6 +1063,37 @@ describe('createWorkerActivityFeed — resurrection guard + deferred finalize',
|
|
|
1027
1063
|
expect(bot.sent[1].text).toContain('back to work')
|
|
1028
1064
|
})
|
|
1029
1065
|
|
|
1066
|
+
it('#3373: a GENUINE terminal then a SendMessage resume re-surfaces a live card (resurrect+update), while an un-resumed terminal stays dead', async () => {
|
|
1067
|
+
const bot = makeFakeBot()
|
|
1068
|
+
let clock = 10_000
|
|
1069
|
+
const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
|
|
1070
|
+
|
|
1071
|
+
// Worker runs, then GENUINELY completes (a real turn_end handback) — the
|
|
1072
|
+
// row is dropped and the agentId latched into the terminal `finalized` gate.
|
|
1073
|
+
await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'step one' }))
|
|
1074
|
+
await feed.finish('w1', view({ state: 'done', toolCount: 1, latestSummary: 'real result' }))
|
|
1075
|
+
expect(feed.has('w1')).toBe(false)
|
|
1076
|
+
|
|
1077
|
+
// Anti-zombie: with NO resume signal, later cues stay swallowed forever — a
|
|
1078
|
+
// genuinely-finished worker must never zombie-resurface on a stray tick.
|
|
1079
|
+
clock = 12_000
|
|
1080
|
+
await feed.update('w1', 'chat', view({ toolCount: 2, latestSummary: 'stray tick' }))
|
|
1081
|
+
expect(bot.sent).toHaveLength(1)
|
|
1082
|
+
expect(feed.has('w1')).toBe(false)
|
|
1083
|
+
|
|
1084
|
+
// Now the worker is RESUMED via SendMessage: the watcher observes jsonl
|
|
1085
|
+
// growth past the terminal boundary (#3315) and fires onResume, which the
|
|
1086
|
+
// gateway wires to feed.resurrect — clearing the finalized latch.
|
|
1087
|
+
feed.resurrect('w1')
|
|
1088
|
+
|
|
1089
|
+
// The resumed worker's next progress cue re-surfaces a fresh live card.
|
|
1090
|
+
clock = 13_000
|
|
1091
|
+
await feed.update('w1', 'chat', view({ toolCount: 3, latestSummary: 'resumed work' }))
|
|
1092
|
+
expect(bot.sent).toHaveLength(2)
|
|
1093
|
+
expect(feed.has('w1')).toBe(true)
|
|
1094
|
+
expect(bot.sent[1].text).toContain('resumed work')
|
|
1095
|
+
})
|
|
1096
|
+
|
|
1030
1097
|
it('#3023: resurrect() on a never-finalized worker is a harmless no-op', () => {
|
|
1031
1098
|
const bot = makeFakeBot()
|
|
1032
1099
|
const feed = createWorkerActivityFeed({ bot, now: () => 10_000 })
|
|
@@ -1337,8 +1404,8 @@ describe('narrative dedup — non-adjacent repeats collapse (A,B,A)', () => {
|
|
|
1337
1404
|
const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
|
|
1338
1405
|
|
|
1339
1406
|
await feed.update('w2', 'chat', view({ latestSummary: 'step-repeat' }))
|
|
1340
|
-
// Push
|
|
1341
|
-
for (let i = 0; i <
|
|
1407
|
+
// Push WORKER_HISTORY_MAX distinct lines so 'step-repeat' scrolls out (#3349).
|
|
1408
|
+
for (let i = 0; i < WORKER_HISTORY_MAX; i++) {
|
|
1342
1409
|
clock += 1000
|
|
1343
1410
|
await feed.update('w2', 'chat', view({ latestSummary: `filler-${i}` }))
|
|
1344
1411
|
}
|