switchroom 0.14.61 → 0.14.63
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/cli/switchroom.js +73 -62
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +2617 -2081
- package/telegram-plugin/gateway/auth-broker-client.ts +18 -8
- package/telegram-plugin/gateway/auto-classify-mid-turn.ts +119 -0
- package/telegram-plugin/gateway/escalation-drive.ts +79 -0
- package/telegram-plugin/gateway/gateway.ts +448 -43
- package/telegram-plugin/gateway/microsoft-connect-flow.ts +226 -0
- package/telegram-plugin/gateway/obligation-ledger.ts +45 -3
- package/telegram-plugin/gateway/with-deadline.ts +43 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +32 -12
- package/telegram-plugin/tests/auto-classify-mid-turn.test.ts +87 -0
- package/telegram-plugin/tests/escalation-drive.test.ts +123 -0
- package/telegram-plugin/tests/microsoft-connect-flow.test.ts +185 -0
- package/telegram-plugin/tests/obligation-determinism.test.ts +85 -25
- package/telegram-plugin/tests/obligation-ledger.test.ts +92 -0
- package/telegram-plugin/tests/with-deadline.test.ts +61 -0
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { AuthBrokerClient as BrokerClient, type AddAccountCredentials } from '../../src/auth/broker/client.js'
|
|
14
|
+
import type { ProviderName } from '../../src/auth/broker/protocol.js'
|
|
14
15
|
import type { AuthBrokerClient } from './auth-command.js'
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -56,21 +57,30 @@ export async function getAuthBrokerClient(
|
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
/**
|
|
59
|
-
* Add an account via the broker. Used
|
|
60
|
-
*
|
|
61
|
-
* `auth-command.ts` deliberately
|
|
62
|
-
* is gateway-routed (not
|
|
63
|
-
* one-shot {@link BrokerClient}
|
|
64
|
-
* long-lived handle just for this verb.
|
|
60
|
+
* Add an account via the broker. Used by the `/auth add` chat flow
|
|
61
|
+
* (Anthropic) and the `/connect microsoft` device-code flow — the narrow
|
|
62
|
+
* {@link AuthBrokerClient} surface in `auth-command.ts` deliberately
|
|
63
|
+
* omits `addAccount` because the verb is gateway-routed (not
|
|
64
|
+
* handler-routed). Constructs and closes a one-shot {@link BrokerClient}
|
|
65
|
+
* so the gateway doesn't need a long-lived handle just for this verb.
|
|
66
|
+
*
|
|
67
|
+
* `provider` defaults to Anthropic (back-compat with the `/auth add`
|
|
68
|
+
* caller, which omits it). The device-code flow passes `"microsoft"`
|
|
69
|
+
* with a `MicrosoftAddAccountCredentials` payload.
|
|
65
70
|
*/
|
|
66
71
|
export async function addAccountViaBroker(
|
|
67
72
|
label: string,
|
|
68
73
|
credentials: AddAccountCredentials,
|
|
69
|
-
opts: { replace?: boolean } = {},
|
|
74
|
+
opts: { replace?: boolean; provider?: ProviderName } = {},
|
|
70
75
|
): Promise<{ label: string; expiresAt?: number }> {
|
|
71
76
|
const broker = new BrokerClient()
|
|
72
77
|
try {
|
|
73
|
-
return await broker.addAccount(
|
|
78
|
+
return await broker.addAccount(
|
|
79
|
+
label,
|
|
80
|
+
credentials,
|
|
81
|
+
opts.replace,
|
|
82
|
+
opts.provider,
|
|
83
|
+
)
|
|
74
84
|
} finally {
|
|
75
85
|
await broker.close()
|
|
76
86
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auto-classify-mid-turn.ts — deterministic, model-free classification of a
|
|
3
|
+
* mid-turn inbound into STEER (amend the in-flight turn) vs QUEUE (new task),
|
|
4
|
+
* using TOPIC-vs-active-turn + reply-RECENCY as proxies for intent. No model
|
|
5
|
+
* inference: the gateway must decide at inbound time while the single CLI is
|
|
6
|
+
* busy.
|
|
7
|
+
*
|
|
8
|
+
* Today a no-prefix mid-turn message always QUEUES (the default flipped
|
|
9
|
+
* 2026-04-17 away from the blunt "everything steers" — see
|
|
10
|
+
* reference/steer-or-queue-mid-flight.md). This module is the basis for a
|
|
11
|
+
* smarter default. It ships first in SHADOW mode (the gateway logs what it WOULD
|
|
12
|
+
* decide but still queues), to gather real-world data — how often mid-turn
|
|
13
|
+
* messages are same-topic continuations vs cross-topic new tasks, and the
|
|
14
|
+
* recency distribution — before any behaviour flips on.
|
|
15
|
+
*
|
|
16
|
+
* Signal strength (be honest):
|
|
17
|
+
* - TOPIC (supergroup): STRONG + structural. A message in a DIFFERENT forum
|
|
18
|
+
* topic than the active turn is, by the supergroup-mode invariant, a separate
|
|
19
|
+
* conversation → queue. This needs no timing guess.
|
|
20
|
+
* - RECENCY: weaker. Within the same topic it cannot tell "also do X" (steer)
|
|
21
|
+
* from "new question, same topic" (queue) — only a tight window + the
|
|
22
|
+
* visible/correctable UX (the JTBD doc) makes auto-steer acceptable, and
|
|
23
|
+
* that is gated separately. The recency clock is the agent's LAST OUTPUT
|
|
24
|
+
* (msSinceLastAgentOutput), NOT turn age: a long actively-narrating worker
|
|
25
|
+
* turn must not read "stale".
|
|
26
|
+
* - DM: no topic at all → timing-only (the pre-2026-04-17 regime that
|
|
27
|
+
* over-steered). DM auto-steer is OFF by default (window 0).
|
|
28
|
+
*
|
|
29
|
+
* Pure (no gateway imports) ⇒ unit-testable.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export type MidTurnClass = 'steer' | 'queue'
|
|
33
|
+
|
|
34
|
+
export type MidTurnReason =
|
|
35
|
+
| 'steer_prefix'
|
|
36
|
+
| 'queue_prefix'
|
|
37
|
+
| 'not_mid_turn'
|
|
38
|
+
| 'cross_topic'
|
|
39
|
+
| 'same_topic_recent'
|
|
40
|
+
| 'same_topic_stale'
|
|
41
|
+
| 'dm_recent'
|
|
42
|
+
| 'dm_disabled'
|
|
43
|
+
| 'topic_disabled'
|
|
44
|
+
|
|
45
|
+
export interface AutoClassifyInput {
|
|
46
|
+
/** Explicit `/steer`|`/s` prefix present — the user's stated intent, authoritative. */
|
|
47
|
+
isSteerPrefix: boolean
|
|
48
|
+
/** Explicit `/queue`|`/q` prefix present. */
|
|
49
|
+
isQueuePrefix: boolean
|
|
50
|
+
/** Is a turn in flight for this chat/thread? (no → not our decision). */
|
|
51
|
+
priorTurnInFlight: boolean
|
|
52
|
+
/** DM (no forum topics) → timing-only. */
|
|
53
|
+
isDm: boolean
|
|
54
|
+
/** Incoming message's thread id (undefined/null in a DM). */
|
|
55
|
+
incomingThreadId: number | null | undefined
|
|
56
|
+
/** The in-flight turn's thread id (currentTurn.sessionThreadId). */
|
|
57
|
+
activeTurnThreadId: number | null | undefined
|
|
58
|
+
/** ms since the agent's LAST visible output in this chat/thread; null when no
|
|
59
|
+
* output has been recorded (cold topic) → treated as not-recent. */
|
|
60
|
+
msSinceLastAgentOutput: number | null
|
|
61
|
+
/** Auto-steer recency window in a DM. 0 (default) = DM auto-steer OFF. */
|
|
62
|
+
dmSteerWindowMs: number
|
|
63
|
+
/** Auto-steer recency window in a supergroup topic. 0 = topic auto-steer OFF. */
|
|
64
|
+
topicSteerWindowMs: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface AutoClassifyResult {
|
|
68
|
+
decision: MidTurnClass
|
|
69
|
+
reason: MidTurnReason
|
|
70
|
+
/** Whether the incoming message is in the SAME thread as the active turn
|
|
71
|
+
* (canonicalized). Undefined when not applicable (no prefix-free mid-turn). */
|
|
72
|
+
sameTopic?: boolean
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Canonical thread compare, matching chatKey's collapse (null/undefined/0 → same
|
|
76
|
+
* "no-thread" bucket) — never raw === on raw ids (the silence-poke key-mismatch
|
|
77
|
+
* bug class). */
|
|
78
|
+
function sameThread(a: number | null | undefined, b: number | null | undefined): boolean {
|
|
79
|
+
const norm = (t: number | null | undefined): number | null => (t == null || t === 0 ? null : t)
|
|
80
|
+
return norm(a) === norm(b)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Classify a mid-turn inbound. Precedence: explicit prefix → not-mid-turn →
|
|
85
|
+
* DM(timing) / supergroup(topic then timing). Defaults bias to QUEUE (the safe,
|
|
86
|
+
* reversible, current behaviour); STEER only on a strong/recent signal AND its
|
|
87
|
+
* window enabled.
|
|
88
|
+
*/
|
|
89
|
+
export function autoClassifyMidTurnInbound(i: AutoClassifyInput): AutoClassifyResult {
|
|
90
|
+
// Explicit prefixes always win — the user's stated intent is authoritative.
|
|
91
|
+
if (i.isSteerPrefix) return { decision: 'steer', reason: 'steer_prefix' }
|
|
92
|
+
if (i.isQueuePrefix) return { decision: 'queue', reason: 'queue_prefix' }
|
|
93
|
+
// No turn in flight → caller starts a fresh turn (not a steer/queue decision).
|
|
94
|
+
if (!i.priorTurnInFlight) return { decision: 'queue', reason: 'not_mid_turn' }
|
|
95
|
+
|
|
96
|
+
const recent =
|
|
97
|
+
i.msSinceLastAgentOutput != null && i.msSinceLastAgentOutput >= 0
|
|
98
|
+
|
|
99
|
+
if (i.isDm) {
|
|
100
|
+
// DM: no topic → timing-only. Default OFF (dmSteerWindowMs 0).
|
|
101
|
+
if (i.dmSteerWindowMs <= 0) return { decision: 'queue', reason: 'dm_disabled' }
|
|
102
|
+
return recent && i.msSinceLastAgentOutput! <= i.dmSteerWindowMs
|
|
103
|
+
? { decision: 'steer', reason: 'dm_recent' }
|
|
104
|
+
: { decision: 'queue', reason: 'dm_disabled' }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Supergroup: topic identity is the PRIMARY signal.
|
|
108
|
+
const topicMatch = sameThread(i.incomingThreadId, i.activeTurnThreadId)
|
|
109
|
+
if (i.topicSteerWindowMs <= 0) {
|
|
110
|
+
return { decision: 'queue', reason: 'topic_disabled', sameTopic: topicMatch }
|
|
111
|
+
}
|
|
112
|
+
// Different topic than the in-flight turn → ALWAYS queue (a separate
|
|
113
|
+
// conversation; never steer it into the wrong topic's turn).
|
|
114
|
+
if (!topicMatch) return { decision: 'queue', reason: 'cross_topic', sameTopic: false }
|
|
115
|
+
// Same topic: steer only if recent enough; else a new question → queue.
|
|
116
|
+
return recent && i.msSinceLastAgentOutput! <= i.topicSteerWindowMs
|
|
117
|
+
? { decision: 'steer', reason: 'same_topic_recent', sameTopic: true }
|
|
118
|
+
: { decision: 'queue', reason: 'same_topic_stale', sameTopic: true }
|
|
119
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* escalation-drive.ts — the obligation-ledger escalation step, extracted from
|
|
3
|
+
* obligationSweep so the hang → bounded → terminal behaviour is EXECUTABLE in a
|
|
4
|
+
* test with a fake hanging send. That path is unreachable by the two harnesses
|
|
5
|
+
* we have: mtcute can't make Telegram's API hang, and the synchronous property
|
|
6
|
+
* test can't model a promise that never settles — yet it is exactly the path the
|
|
7
|
+
* total proof flagged as the determinism hole. This file makes the real drive
|
|
8
|
+
* logic the sweep uses testable in isolation (escalation-drive.test.ts), so the
|
|
9
|
+
* fix is verified by execution, not only by reasoning + review.
|
|
10
|
+
*
|
|
11
|
+
* Invariant it upholds: a single escalation attempt
|
|
12
|
+
* - is guarded so a sweep tick cannot fire a second concurrent send for the
|
|
13
|
+
* same obligation while one is awaiting;
|
|
14
|
+
* - bounds the send with `withDeadline`, so the chain ALWAYS settles within
|
|
15
|
+
* `deadlineMs` ⇒ the in-flight flag ALWAYS clears (a hung send becomes a
|
|
16
|
+
* bounded reject, handled like any other failed attempt);
|
|
17
|
+
* - closes the obligation ONLY after a successful send; a transient failure
|
|
18
|
+
* leaves it OPEN (retried next sweep); a permanent failure
|
|
19
|
+
* (attempt ≥ maxAttempts) closes best-effort — so repeated hung/failed sends
|
|
20
|
+
* reach a terminal in a bounded number of sweeps, never an infinite loop.
|
|
21
|
+
*/
|
|
22
|
+
import { withDeadline } from './with-deadline.js'
|
|
23
|
+
|
|
24
|
+
/** The slice of the ledger the escalation step needs. */
|
|
25
|
+
export interface EscalationLedger {
|
|
26
|
+
markEscalateAttempt(originTurnId: string): number
|
|
27
|
+
close(originTurnId: string | null | undefined): boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DriveEscalationArgs {
|
|
31
|
+
escId: string
|
|
32
|
+
/** Set of origin ids with an escalation send in flight (concurrency guard). */
|
|
33
|
+
inFlight: Set<string>
|
|
34
|
+
ledger: EscalationLedger
|
|
35
|
+
/** Perform the operator-nudge send (already thread-fallback-wrapped). May hang. */
|
|
36
|
+
send: () => Promise<unknown>
|
|
37
|
+
/** Attempts before a permanent failure closes best-effort. */
|
|
38
|
+
maxAttempts: number
|
|
39
|
+
/** Bound on a single send so a hang can't leak the in-flight flag. */
|
|
40
|
+
deadlineMs: number
|
|
41
|
+
log?: (line: string) => void
|
|
42
|
+
/** Injectable for tests; defaults to the real withDeadline. */
|
|
43
|
+
withDeadlineFn?: typeof withDeadline
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Drive one escalation attempt. Returns the settling chain promise (so tests can
|
|
48
|
+
* await it) or `undefined` if the call was a no-op because a send is already in
|
|
49
|
+
* flight for `escId`. obligationSweep calls this as `void driveEscalation(...)`.
|
|
50
|
+
*/
|
|
51
|
+
export function driveEscalation(args: DriveEscalationArgs): Promise<void> | undefined {
|
|
52
|
+
const { escId, inFlight, ledger, send, maxAttempts, deadlineMs } = args
|
|
53
|
+
const log = args.log ?? ((l: string) => process.stderr.write(l))
|
|
54
|
+
const wd = args.withDeadlineFn ?? withDeadline
|
|
55
|
+
if (inFlight.has(escId)) return undefined // a send is already awaiting
|
|
56
|
+
const attempt = ledger.markEscalateAttempt(escId)
|
|
57
|
+
inFlight.add(escId)
|
|
58
|
+
log(`telegram gateway: obligation escalating origin=${escId} attempt=${attempt}/${maxAttempts}\n`)
|
|
59
|
+
return wd(send(), deadlineMs, 'obligation escalation send timed out')
|
|
60
|
+
.then(() => {
|
|
61
|
+
ledger.close(escId)
|
|
62
|
+
log(`telegram gateway: obligation escalation delivered + closed origin=${escId}\n`)
|
|
63
|
+
})
|
|
64
|
+
.catch((err: unknown) => {
|
|
65
|
+
if (attempt >= maxAttempts) {
|
|
66
|
+
ledger.close(escId)
|
|
67
|
+
log(
|
|
68
|
+
`telegram gateway: obligation escalation PERMANENTLY undeliverable after ${attempt} attempts — closing best-effort origin=${escId}: ${err}\n`,
|
|
69
|
+
)
|
|
70
|
+
} else {
|
|
71
|
+
log(
|
|
72
|
+
`telegram gateway: obligation escalation send failed (attempt ${attempt}/${maxAttempts}), retrying next sweep origin=${escId}: ${err}\n`,
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
.finally(() => {
|
|
77
|
+
inFlight.delete(escId)
|
|
78
|
+
})
|
|
79
|
+
}
|