switchroom 0.19.2 → 0.19.3
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 +2 -0
- package/dist/auth-broker/index.js +13 -0
- package/dist/cli/autoaccept-poll.js +2 -0
- package/dist/cli/drive-write-pretool.mjs +2 -0
- package/dist/cli/ms-365-write-pretool.mjs +2 -0
- package/dist/cli/switchroom.js +404 -245
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/default/CLAUDE.md.hbs +8 -0
- package/skills/mental-model-curator/SKILL.md +68 -2
- package/telegram-plugin/auth-snapshot-format.ts +104 -12
- package/telegram-plugin/dist/bridge/bridge.js +8 -2
- package/telegram-plugin/dist/gateway/gateway.js +1194 -794
- package/telegram-plugin/dist/server.js +8 -2
- package/telegram-plugin/flushed-turn-supersede.ts +117 -13
- package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
- package/telegram-plugin/gateway/auth-command.ts +138 -5
- package/telegram-plugin/gateway/gateway.ts +68 -101
- package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
- package/telegram-plugin/gateway/model-command.ts +203 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
- package/telegram-plugin/gateway/session-model-source.ts +90 -10
- package/telegram-plugin/gateway/stream-render.ts +22 -5
- package/telegram-plugin/quota-bar-format.ts +60 -12
- package/telegram-plugin/reply-owner-resolve.ts +76 -11
- package/telegram-plugin/session-tail.ts +27 -3
- package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +185 -29
- package/telegram-plugin/tests/model-command.test.ts +220 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
- package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
- package/telegram-plugin/tests/session-model-source.test.ts +142 -0
- package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
- package/vendor/hindsight-memory/CHANGELOG.md +102 -0
- package/vendor/hindsight-memory/README.md +2 -1
- package/vendor/hindsight-memory/hooks/hooks.json +12 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
- package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
- package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
- package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
- package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
- package/vendor/hindsight-memory/scripts/recall.py +789 -143
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
- package/vendor/hindsight-memory/scripts/retain.py +71 -2
- package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
- package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
- package/vendor/hindsight-memory/settings.json +3 -1
|
@@ -60,6 +60,10 @@ import {
|
|
|
60
60
|
handleAuthCommand,
|
|
61
61
|
isAuthAdmin,
|
|
62
62
|
validateAuthAddLabel,
|
|
63
|
+
readdPrecheckError,
|
|
64
|
+
runReaddPrecheck,
|
|
65
|
+
formatGrantedScopesReply,
|
|
66
|
+
REQUIRED_USAGE_SCOPE,
|
|
63
67
|
} from '../gateway/auth-command.js'
|
|
64
68
|
import {
|
|
65
69
|
pendingAuthAddFlows,
|
|
@@ -111,6 +115,7 @@ function makeMockTmuxOps(opts: {
|
|
|
111
115
|
} = {}): AuthAddTmuxOps & {
|
|
112
116
|
newSessionCalls: Array<{ socket: string; session: string; env: Record<string, string>; cmd: string }>
|
|
113
117
|
sendCalls: Array<{ socket: string; session: string; text: string }>
|
|
118
|
+
sendKeyCalls: Array<{ socket: string; session: string; key: string }>
|
|
114
119
|
killCalls: Array<{ socket: string; session: string }>
|
|
115
120
|
captureCallCount: number
|
|
116
121
|
sessionAlive: boolean
|
|
@@ -123,12 +128,14 @@ function makeMockTmuxOps(opts: {
|
|
|
123
128
|
let sessionAlive = opts.initialSessionAlive ?? true
|
|
124
129
|
const newSessionCalls: Array<{ socket: string; session: string; env: Record<string, string>; cmd: string }> = []
|
|
125
130
|
const sendCalls: Array<{ socket: string; session: string; text: string }> = []
|
|
131
|
+
const sendKeyCalls: Array<{ socket: string; session: string; key: string }> = []
|
|
126
132
|
const killCalls: Array<{ socket: string; session: string }> = []
|
|
127
133
|
let captureCallCount = 0
|
|
128
134
|
|
|
129
135
|
const mock = {
|
|
130
136
|
get newSessionCalls() { return newSessionCalls },
|
|
131
137
|
get sendCalls() { return sendCalls },
|
|
138
|
+
get sendKeyCalls() { return sendKeyCalls },
|
|
132
139
|
get killCalls() { return killCalls },
|
|
133
140
|
get captureCallCount() { return captureCallCount },
|
|
134
141
|
get sessionAlive() { return sessionAlive },
|
|
@@ -149,6 +156,9 @@ function makeMockTmuxOps(opts: {
|
|
|
149
156
|
sendCalls.push({ socket, session, text })
|
|
150
157
|
mock.onSend?.(socket, session, text)
|
|
151
158
|
},
|
|
159
|
+
sendKey(socket: string, session: string, key: string) {
|
|
160
|
+
sendKeyCalls.push({ socket, session, key })
|
|
161
|
+
},
|
|
152
162
|
hasSession(socket: string, session: string): boolean {
|
|
153
163
|
void socket; void session
|
|
154
164
|
return sessionAlive
|
|
@@ -166,12 +176,12 @@ function makeMockTmuxOps(opts: {
|
|
|
166
176
|
describe('parseAuthCommand — /auth add and /auth cancel', () => {
|
|
167
177
|
it('recognises "/auth add <label>" with a valid label', () => {
|
|
168
178
|
const p = parseAuthCommand('/auth add alice@example.com')
|
|
169
|
-
expect(p).toEqual({ kind: 'add', label: 'alice@example.com' })
|
|
179
|
+
expect(p).toEqual({ kind: 'add', label: 'alice@example.com', replace: false })
|
|
170
180
|
})
|
|
171
181
|
|
|
172
182
|
it('recognises gmail-tag labels (the + character)', () => {
|
|
173
183
|
const p = parseAuthCommand('/auth add alice+work@example.com')
|
|
174
|
-
expect(p).toEqual({ kind: 'add', label: 'alice+work@example.com' })
|
|
184
|
+
expect(p).toEqual({ kind: 'add', label: 'alice+work@example.com', replace: false })
|
|
175
185
|
})
|
|
176
186
|
|
|
177
187
|
it('treats "/auth add" with no label as a help reply', () => {
|
|
@@ -191,7 +201,7 @@ describe('parseAuthCommand — /auth add and /auth cancel', () => {
|
|
|
191
201
|
// whitespace is the parser's contract — the validator catches
|
|
192
202
|
// shape violations on the first token.
|
|
193
203
|
const p = parseAuthCommand('/auth add foo bar')
|
|
194
|
-
expect(p).toEqual({ kind: 'add', label: 'foo' })
|
|
204
|
+
expect(p).toEqual({ kind: 'add', label: 'foo', replace: false })
|
|
195
205
|
})
|
|
196
206
|
|
|
197
207
|
it('rejects an over-length label (>64 chars)', () => {
|
|
@@ -260,7 +270,7 @@ describe('isAuthAdmin', () => {
|
|
|
260
270
|
describe('handleAuthCommand — add/cancel are gateway-routed (defensive contract)', () => {
|
|
261
271
|
it('returns a "not routed" error for parsed.kind === "add" so the contract is loud if a future refactor forgets the gateway dispatch', async () => {
|
|
262
272
|
const reply = await handleAuthCommand(
|
|
263
|
-
{ kind: 'add', label: 'foo' },
|
|
273
|
+
{ kind: 'add', label: 'foo', replace: false },
|
|
264
274
|
{
|
|
265
275
|
agentName: 'clerk',
|
|
266
276
|
isAdmin: true,
|
|
@@ -272,7 +282,7 @@ describe('handleAuthCommand — add/cancel are gateway-routed (defensive contrac
|
|
|
272
282
|
|
|
273
283
|
it('refuses /auth add for non-admin before the not-routed branch', async () => {
|
|
274
284
|
const reply = await handleAuthCommand(
|
|
275
|
-
{ kind: 'add', label: 'foo' },
|
|
285
|
+
{ kind: 'add', label: 'foo', replace: false },
|
|
276
286
|
{
|
|
277
287
|
agentName: 'other',
|
|
278
288
|
isAdmin: false,
|
|
@@ -657,6 +667,161 @@ describe('mocked-broker addAccount integration sketch', () => {
|
|
|
657
667
|
expect(addAccountSpy).toHaveBeenCalledTimes(1)
|
|
658
668
|
})
|
|
659
669
|
})
|
|
670
|
+
|
|
671
|
+
it('readd threads replace=true through to the broker addAccount verb', async () => {
|
|
672
|
+
const fakeCredentials = {
|
|
673
|
+
claudeAiOauth: {
|
|
674
|
+
accessToken: 'sk-ant-oat01-test-' + 'x'.repeat(40),
|
|
675
|
+
expiresAt: Date.now() + 3600_000,
|
|
676
|
+
scopes: ['org:create_api_key', 'user:profile', 'user:inference'],
|
|
677
|
+
},
|
|
678
|
+
}
|
|
679
|
+
const addAccountSpy = vi.fn(async (label: string, _c: typeof fakeCredentials, replace?: boolean) => ({
|
|
680
|
+
label,
|
|
681
|
+
replace,
|
|
682
|
+
}))
|
|
683
|
+
// Plain add → replace false; readd → replace true. Both round-trip.
|
|
684
|
+
const added = await addAccountSpy('pooled@example.com', fakeCredentials, false)
|
|
685
|
+
const readded = await addAccountSpy('pooled@example.com', fakeCredentials, true)
|
|
686
|
+
expect(added.replace).toBe(false)
|
|
687
|
+
expect(readded.replace).toBe(true)
|
|
688
|
+
expect(addAccountSpy).toHaveBeenCalledTimes(2)
|
|
689
|
+
})
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
/* ── 9b. /auth readd parser + precheck + scope reply (PR C) ────────────── */
|
|
693
|
+
|
|
694
|
+
describe('parseAuthCommand — /auth readd and add --replace', () => {
|
|
695
|
+
it('parses "/auth readd <label>" as an add with replace=true', () => {
|
|
696
|
+
expect(parseAuthCommand('/auth readd pooled@example.com')).toEqual({
|
|
697
|
+
kind: 'add',
|
|
698
|
+
label: 'pooled@example.com',
|
|
699
|
+
replace: true,
|
|
700
|
+
})
|
|
701
|
+
})
|
|
702
|
+
|
|
703
|
+
it('parses "/auth add <label> --replace" as replace=true too', () => {
|
|
704
|
+
expect(parseAuthCommand('/auth add pooled@example.com --replace')).toEqual({
|
|
705
|
+
kind: 'add',
|
|
706
|
+
label: 'pooled@example.com',
|
|
707
|
+
replace: true,
|
|
708
|
+
})
|
|
709
|
+
})
|
|
710
|
+
|
|
711
|
+
it('plain "/auth add <label>" stays replace=false', () => {
|
|
712
|
+
expect(parseAuthCommand('/auth add fresh@example.com')).toEqual({
|
|
713
|
+
kind: 'add',
|
|
714
|
+
label: 'fresh@example.com',
|
|
715
|
+
replace: false,
|
|
716
|
+
})
|
|
717
|
+
})
|
|
718
|
+
|
|
719
|
+
it('"/auth readd" with no label is a help reply', () => {
|
|
720
|
+
const p = parseAuthCommand('/auth readd')
|
|
721
|
+
expect(p?.kind).toBe('help')
|
|
722
|
+
if (p?.kind === 'help') expect(p.reason).toMatch(/Usage: \/auth readd/)
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
it('rejects an unknown flag on add/readd', () => {
|
|
726
|
+
const p = parseAuthCommand('/auth add foo --wipe')
|
|
727
|
+
expect(p?.kind).toBe('help')
|
|
728
|
+
if (p?.kind === 'help') expect(p.reason).toMatch(/Unknown flag/i)
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
it('is case-insensitive on the readd verb', () => {
|
|
732
|
+
expect(parseAuthCommand('/auth READD foo')).toEqual({ kind: 'add', label: 'foo', replace: true })
|
|
733
|
+
})
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
describe('readdPrecheckError — exists gate', () => {
|
|
737
|
+
it('readd of a NONEXISTENT label errors clearly', () => {
|
|
738
|
+
const err = readdPrecheckError('ghost@example.com', true, false)
|
|
739
|
+
expect(err).toMatch(/no account named/i)
|
|
740
|
+
expect(err).toMatch(/ghost@example\.com/)
|
|
741
|
+
})
|
|
742
|
+
|
|
743
|
+
it('readd of an EXISTING label passes (null)', () => {
|
|
744
|
+
expect(readdPrecheckError('pooled@example.com', true, true)).toBeNull()
|
|
745
|
+
})
|
|
746
|
+
|
|
747
|
+
it('plain add of an EXISTING label errors and points at readd', () => {
|
|
748
|
+
const err = readdPrecheckError('pooled@example.com', false, true)
|
|
749
|
+
expect(err).toMatch(/already exists/i)
|
|
750
|
+
expect(err).toMatch(/\/auth readd/)
|
|
751
|
+
})
|
|
752
|
+
|
|
753
|
+
it('plain add of a NEW label passes (null)', () => {
|
|
754
|
+
expect(readdPrecheckError('fresh@example.com', false, false)).toBeNull()
|
|
755
|
+
})
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
describe('runReaddPrecheck — broker-backed wrapper (extracted from gateway.ts)', () => {
|
|
759
|
+
const clientWith = (labels: string[]) => ({
|
|
760
|
+
listState: async () => ({ accounts: labels.map((label) => ({ label })) }) as any,
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
it('readd of an EXISTING label queries the broker and passes (null)', async () => {
|
|
764
|
+
const err = await runReaddPrecheck(
|
|
765
|
+
async () => clientWith(['pooled@example.com']) as any,
|
|
766
|
+
'pooled@example.com',
|
|
767
|
+
true,
|
|
768
|
+
)
|
|
769
|
+
expect(err).toBeNull()
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
it('readd of a NONEXISTENT label surfaces the precheck error', async () => {
|
|
773
|
+
const err = await runReaddPrecheck(
|
|
774
|
+
async () => clientWith([]) as any,
|
|
775
|
+
'ghost@example.com',
|
|
776
|
+
true,
|
|
777
|
+
)
|
|
778
|
+
expect(err).toMatch(/no account named/i)
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
it('plain add of an EXISTING label surfaces the precheck error', async () => {
|
|
782
|
+
const err = await runReaddPrecheck(
|
|
783
|
+
async () => clientWith(['pooled@example.com']) as any,
|
|
784
|
+
'pooled@example.com',
|
|
785
|
+
false,
|
|
786
|
+
)
|
|
787
|
+
expect(err).toMatch(/already exists/i)
|
|
788
|
+
})
|
|
789
|
+
|
|
790
|
+
it('best-effort: null client (broker unreachable) skips the precheck (null)', async () => {
|
|
791
|
+
const err = await runReaddPrecheck(async () => null, 'x@example.com', true)
|
|
792
|
+
expect(err).toBeNull()
|
|
793
|
+
})
|
|
794
|
+
|
|
795
|
+
it('best-effort: a throwing broker is swallowed and skips the precheck (null)', async () => {
|
|
796
|
+
const err = await runReaddPrecheck(
|
|
797
|
+
async () => ({ listState: async () => { throw new Error('broker down') } }) as any,
|
|
798
|
+
'x@example.com',
|
|
799
|
+
false,
|
|
800
|
+
)
|
|
801
|
+
expect(err).toBeNull()
|
|
802
|
+
})
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
describe('formatGrantedScopesReply — scope-in-reply (structured, no scraping)', () => {
|
|
806
|
+
it('lists granted scopes and confirms when user:profile is present', () => {
|
|
807
|
+
const r = formatGrantedScopesReply(['org:create_api_key', 'user:profile', 'user:inference'])
|
|
808
|
+
expect(r.hasUsageScope).toBe(true)
|
|
809
|
+
expect(r.text).toContain('user:profile')
|
|
810
|
+
expect(r.text).toMatch(/unlocked/i)
|
|
811
|
+
})
|
|
812
|
+
|
|
813
|
+
it('warns LOUDLY when user:profile is absent (the setup-token footgun)', () => {
|
|
814
|
+
const r = formatGrantedScopesReply(['user:inference'])
|
|
815
|
+
expect(r.hasUsageScope).toBe(false)
|
|
816
|
+
expect(r.text).toMatch(/MISSING/)
|
|
817
|
+
expect(r.text).toContain(REQUIRED_USAGE_SCOPE)
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
it('warns when the token reports no scopes at all', () => {
|
|
821
|
+
const r = formatGrantedScopesReply(undefined)
|
|
822
|
+
expect(r.hasUsageScope).toBe(false)
|
|
823
|
+
expect(r.text).toMatch(/No scopes reported/i)
|
|
824
|
+
})
|
|
660
825
|
})
|
|
661
826
|
|
|
662
827
|
/* ── 10. Help text mentions add + cancel ─────────────────────────────── */
|
|
@@ -773,6 +938,9 @@ printf '{\\n "claudeAiOauth": {\\n "accessToken": "${fakeAccessToken}",\\n
|
|
|
773
938
|
send(_socket, session, text) {
|
|
774
939
|
return realOps.send(tmuxSocket, session, text)
|
|
775
940
|
},
|
|
941
|
+
sendKey(_socket, session, key) {
|
|
942
|
+
return realOps.sendKey(tmuxSocket, session, key)
|
|
943
|
+
},
|
|
776
944
|
hasSession(_socket, session) {
|
|
777
945
|
return realOps.hasSession(tmuxSocket, session)
|
|
778
946
|
},
|
|
@@ -816,6 +984,200 @@ printf '{\\n "claudeAiOauth": {\\n "accessToken": "${fakeAccessToken}",\\n
|
|
|
816
984
|
}, 30_000)
|
|
817
985
|
})
|
|
818
986
|
|
|
987
|
+
/* ── 12. via-claude broad-scope minter (PR B) ─────────────────────────── */
|
|
988
|
+
|
|
989
|
+
describe('startAccountAuthSession — via-claude mode (broad scope)', () => {
|
|
990
|
+
// The picker-rendered broad-scope authorize URL (org:create_api_key +
|
|
991
|
+
// user:profile + user:inference …). This is what `claude` (not setup-token)
|
|
992
|
+
// emits after "Claude account with subscription".
|
|
993
|
+
const BROAD_URL =
|
|
994
|
+
'https://claude.com/cai/oauth/authorize?code=true&client_id=x&response_type=code' +
|
|
995
|
+
'&scope=org%3Acreate_api_key+user%3Aprofile+user%3Ainference+user%3Asessions%3Aclaude_code' +
|
|
996
|
+
'&code_challenge=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-z&state=s'
|
|
997
|
+
|
|
998
|
+
it('defaults to via-claude mode: spawns bare `claude` (NOT `claude setup-token`) and reports mode', async () => {
|
|
999
|
+
const mock = makeMockTmuxOps({ captureResponses: [BROAD_URL] })
|
|
1000
|
+
const result = await startAccountAuthSession('broad@example.com', {
|
|
1001
|
+
home: workspace,
|
|
1002
|
+
tmuxOps: mock,
|
|
1003
|
+
urlTimeoutMs: 3_000,
|
|
1004
|
+
})
|
|
1005
|
+
expect(result.mode).toBe('via-claude')
|
|
1006
|
+
// cmd must be the bare login picker, not setup-token (setup-token mints
|
|
1007
|
+
// only user:inference, which server: agents refuse).
|
|
1008
|
+
expect(mock.newSessionCalls[0].cmd).toBe('claude')
|
|
1009
|
+
expect(mock.newSessionCalls[0].cmd).not.toMatch(/setup-token/)
|
|
1010
|
+
cleanScratchDir(result.scratchDir)
|
|
1011
|
+
})
|
|
1012
|
+
|
|
1013
|
+
it('dispatches the pre-paste picker choreography (theme + login-method Enter) via sendKey before the URL', async () => {
|
|
1014
|
+
// Pane walks: theme picker → login-method picker → URL. Each fires one
|
|
1015
|
+
// Enter via sendKey (bare key, never the literal-code `send`).
|
|
1016
|
+
const mock = makeMockTmuxOps({
|
|
1017
|
+
captureResponses: [
|
|
1018
|
+
'Choose the text style that looks best with your terminal',
|
|
1019
|
+
'Select login method:\n 1. Claude account with subscription',
|
|
1020
|
+
BROAD_URL,
|
|
1021
|
+
],
|
|
1022
|
+
})
|
|
1023
|
+
const result = await startAccountAuthSession('picker@example.com', {
|
|
1024
|
+
home: workspace,
|
|
1025
|
+
tmuxOps: mock,
|
|
1026
|
+
urlTimeoutMs: 5_000,
|
|
1027
|
+
})
|
|
1028
|
+
// Two pre-paste Enters dispatched via sendKey, both bare "Enter".
|
|
1029
|
+
expect(mock.sendKeyCalls.map((c) => c.key)).toEqual(['Enter', 'Enter'])
|
|
1030
|
+
// The literal-code `send` (which would echo a secret) is NOT used pre-paste.
|
|
1031
|
+
expect(mock.sendCalls).toHaveLength(0)
|
|
1032
|
+
expect(result.loginUrl).toMatch(/^https:\/\/claude\.com\/cai\/oauth\/authorize\?/)
|
|
1033
|
+
cleanScratchDir(result.scratchDir)
|
|
1034
|
+
})
|
|
1035
|
+
|
|
1036
|
+
it('surfaces both URL shapes the picker can render (claude.ai/ and claude.com/cai/)', async () => {
|
|
1037
|
+
for (const url of [
|
|
1038
|
+
'https://claude.ai/oauth/authorize?client_id=x&code=true&scope=user%3Aprofile',
|
|
1039
|
+
BROAD_URL,
|
|
1040
|
+
]) {
|
|
1041
|
+
const mock = makeMockTmuxOps({ captureResponses: [`\x1b[0m${url}\nPaste code here:\n`] })
|
|
1042
|
+
const result = await startAccountAuthSession('urlshape', {
|
|
1043
|
+
home: workspace,
|
|
1044
|
+
tmuxOps: mock,
|
|
1045
|
+
urlTimeoutMs: 3_000,
|
|
1046
|
+
})
|
|
1047
|
+
expect(result.loginUrl).toContain('/oauth/authorize?')
|
|
1048
|
+
expect(result.loginUrl).not.toMatch(/\s/) // line-wrap collapsed
|
|
1049
|
+
cleanScratchDir(result.scratchDir)
|
|
1050
|
+
}
|
|
1051
|
+
})
|
|
1052
|
+
|
|
1053
|
+
it('setup-token mode is still available and uses `claude setup-token` with no picker dispatch', async () => {
|
|
1054
|
+
const url =
|
|
1055
|
+
'https://claude.com/cai/oauth/authorize?code=true&client_id=y&response_type=code&code_challenge=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-z'
|
|
1056
|
+
const mock = makeMockTmuxOps({ captureResponses: [url] })
|
|
1057
|
+
const result = await startAccountAuthSession('narrow@example.com', {
|
|
1058
|
+
home: workspace,
|
|
1059
|
+
tmuxOps: mock,
|
|
1060
|
+
urlTimeoutMs: 3_000,
|
|
1061
|
+
mode: 'setup-token',
|
|
1062
|
+
})
|
|
1063
|
+
expect(result.mode).toBe('setup-token')
|
|
1064
|
+
expect(mock.newSessionCalls[0].cmd).toBe('claude setup-token')
|
|
1065
|
+
expect(mock.sendKeyCalls).toHaveLength(0) // no picker choreography
|
|
1066
|
+
cleanScratchDir(result.scratchDir)
|
|
1067
|
+
})
|
|
1068
|
+
})
|
|
1069
|
+
|
|
1070
|
+
describe('submitAccountAuthCode — via-claude broad-scope + blind post-paste Enter (PR B)', () => {
|
|
1071
|
+
function makeFlow(scratchDir: string, mode: 'via-claude' | 'setup-token'): PendingAuthAddFlow {
|
|
1072
|
+
return {
|
|
1073
|
+
label: 'broad@example.com',
|
|
1074
|
+
scratchDir,
|
|
1075
|
+
tmuxSocket: 'switchroom-test',
|
|
1076
|
+
tmuxSession: 'auth-add-broad-abc123',
|
|
1077
|
+
startedAt: Date.now(),
|
|
1078
|
+
mode,
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
it('returns the minted broad scopes (user:profile present) and NEVER captures after the code', async () => {
|
|
1083
|
+
const scratchDir = mkdtempSync(join(workspace, 'broad-'))
|
|
1084
|
+
const credPath = join(scratchDir, '.credentials.json')
|
|
1085
|
+
const broadCreds = JSON.stringify({
|
|
1086
|
+
claudeAiOauth: {
|
|
1087
|
+
accessToken: 'sk-ant-oat01-test-' + 'e'.repeat(40),
|
|
1088
|
+
refreshToken: 'sk-ant-ort01-test',
|
|
1089
|
+
expiresAt: Date.now() + 8 * 3600_000,
|
|
1090
|
+
scopes: [
|
|
1091
|
+
'org:create_api_key',
|
|
1092
|
+
'user:profile',
|
|
1093
|
+
'user:inference',
|
|
1094
|
+
'user:sessions:claude_code',
|
|
1095
|
+
'user:mcp_servers',
|
|
1096
|
+
'user:file_upload',
|
|
1097
|
+
],
|
|
1098
|
+
subscriptionType: 'max',
|
|
1099
|
+
},
|
|
1100
|
+
})
|
|
1101
|
+
const mock = makeMockTmuxOps({ initialSessionAlive: true })
|
|
1102
|
+
let sendCalled = false
|
|
1103
|
+
let captureAfterSend = false
|
|
1104
|
+
mock.onSend = () => {
|
|
1105
|
+
sendCalled = true
|
|
1106
|
+
writeFileSync(credPath, broadCreds, 'utf8')
|
|
1107
|
+
}
|
|
1108
|
+
mock.onCapture = () => { if (sendCalled) captureAfterSend = true }
|
|
1109
|
+
|
|
1110
|
+
const creds = await submitAccountAuthCode(makeFlow(scratchDir, 'via-claude'), 'browser-code-xyz', {
|
|
1111
|
+
pollIntervalMs: 20,
|
|
1112
|
+
pollTimeoutMs: 3_000,
|
|
1113
|
+
tmuxOps: mock,
|
|
1114
|
+
})
|
|
1115
|
+
|
|
1116
|
+
// The actual mission outcome: user:profile made it through.
|
|
1117
|
+
expect(creds.claudeAiOauth.scopes).toContain('user:profile')
|
|
1118
|
+
expect(creds.claudeAiOauth.scopes).toEqual(
|
|
1119
|
+
expect.arrayContaining(['org:create_api_key', 'user:profile', 'user:inference']),
|
|
1120
|
+
)
|
|
1121
|
+
// Invariant preserved: no capture-pane after the code paste.
|
|
1122
|
+
expect(captureAfterSend).toBe(false)
|
|
1123
|
+
expect(mock.captureCallCount).toBe(0)
|
|
1124
|
+
})
|
|
1125
|
+
|
|
1126
|
+
it('dispatches BLIND Enter key-presses after the code (no capture) to clear picker screens', async () => {
|
|
1127
|
+
const scratchDir = mkdtempSync(join(workspace, 'blind-'))
|
|
1128
|
+
const credPath = join(scratchDir, '.credentials.json')
|
|
1129
|
+
const mock = makeMockTmuxOps({ initialSessionAlive: true })
|
|
1130
|
+
// Materialise creds only AFTER the blind Enters have had a chance to fire
|
|
1131
|
+
// (mimics a picker screen Enter-gating the credentials flush).
|
|
1132
|
+
let ticks = 0
|
|
1133
|
+
mock.onSend = () => {
|
|
1134
|
+
// schedule cred write a few polls later via the poll loop below
|
|
1135
|
+
ticks = 0
|
|
1136
|
+
}
|
|
1137
|
+
// Use a tight blind-enter schedule so the test is fast.
|
|
1138
|
+
const submitP = submitAccountAuthCode(makeFlow(scratchDir, 'via-claude'), 'code-abc', {
|
|
1139
|
+
pollIntervalMs: 10,
|
|
1140
|
+
pollTimeoutMs: 3_000,
|
|
1141
|
+
tmuxOps: mock,
|
|
1142
|
+
blindEnterDelaysMs: [15, 30],
|
|
1143
|
+
})
|
|
1144
|
+
// Write creds after the blind Enters would have fired.
|
|
1145
|
+
const timer = setInterval(() => {
|
|
1146
|
+
ticks++
|
|
1147
|
+
if (ticks >= 6) {
|
|
1148
|
+
writeFileSync(credPath, JSON.stringify({
|
|
1149
|
+
claudeAiOauth: { accessToken: 'sk-ant-oat01-test-' + 'f'.repeat(40), scopes: ['user:profile'] },
|
|
1150
|
+
}), 'utf8')
|
|
1151
|
+
clearInterval(timer)
|
|
1152
|
+
}
|
|
1153
|
+
}, 10)
|
|
1154
|
+
const creds = await submitP
|
|
1155
|
+
clearInterval(timer)
|
|
1156
|
+
// Blind Enters were dispatched via sendKey, and capture-pane never was.
|
|
1157
|
+
expect(mock.sendKeyCalls.every((c) => c.key === 'Enter')).toBe(true)
|
|
1158
|
+
expect(mock.sendKeyCalls.length).toBeGreaterThanOrEqual(1)
|
|
1159
|
+
expect(mock.captureCallCount).toBe(0)
|
|
1160
|
+
expect(creds.claudeAiOauth.scopes).toContain('user:profile')
|
|
1161
|
+
})
|
|
1162
|
+
|
|
1163
|
+
it('setup-token mode dispatches NO blind Enters (session exits on its own)', async () => {
|
|
1164
|
+
const scratchDir = mkdtempSync(join(workspace, 'st-'))
|
|
1165
|
+
const credPath = join(scratchDir, '.credentials.json')
|
|
1166
|
+
const mock = makeMockTmuxOps({ initialSessionAlive: true })
|
|
1167
|
+
mock.onSend = () => {
|
|
1168
|
+
writeFileSync(credPath, JSON.stringify({
|
|
1169
|
+
claudeAiOauth: { accessToken: 'sk-ant-oat01-test-' + 'g'.repeat(40), scopes: ['user:inference'] },
|
|
1170
|
+
}), 'utf8')
|
|
1171
|
+
}
|
|
1172
|
+
await submitAccountAuthCode(makeFlow(scratchDir, 'setup-token'), 'code', {
|
|
1173
|
+
pollIntervalMs: 20,
|
|
1174
|
+
pollTimeoutMs: 2_000,
|
|
1175
|
+
tmuxOps: mock,
|
|
1176
|
+
})
|
|
1177
|
+
expect(mock.sendKeyCalls).toHaveLength(0)
|
|
1178
|
+
})
|
|
1179
|
+
})
|
|
1180
|
+
|
|
819
1181
|
/* ── helpers ─────────────────────────────────────────────────────────── */
|
|
820
1182
|
|
|
821
1183
|
function randomHex(): string {
|
|
@@ -1199,3 +1199,44 @@ describe('#2494 — renderAuthSnapshotFormat2 row rendering (out_of_credits demo
|
|
|
1199
1199
|
expect(out).not.toContain('All accounts blocked');
|
|
1200
1200
|
});
|
|
1201
1201
|
});
|
|
1202
|
+
|
|
1203
|
+
// ── retired / org-blocked (in-service classification, PR1) ────────────
|
|
1204
|
+
describe('classifyHealth — retired / org-blocked precedence', () => {
|
|
1205
|
+
// A retired/org-disabled account must NEVER read as available: config and
|
|
1206
|
+
// entitlement outrank the quota windows for any non-active account.
|
|
1207
|
+
const healthyQ = quota({ fiveHourUtilizationPct: 5, sevenDayUtilizationPct: 10 });
|
|
1208
|
+
|
|
1209
|
+
it('non-active account with inService:false → retired, even on healthy quota', () => {
|
|
1210
|
+
expect(classifyHealth(snap({ inService: false, quota: healthyQ }))).toBe('retired');
|
|
1211
|
+
});
|
|
1212
|
+
it('non-active account with entitlementBlocked → org-blocked', () => {
|
|
1213
|
+
expect(classifyHealth(snap({ entitlementBlocked: true, quota: healthyQ }))).toBe('org-blocked');
|
|
1214
|
+
});
|
|
1215
|
+
it('entitlement block outranks retirement', () => {
|
|
1216
|
+
expect(
|
|
1217
|
+
classifyHealth(snap({ inService: false, entitlementBlocked: true, quota: healthyQ })),
|
|
1218
|
+
).toBe('org-blocked');
|
|
1219
|
+
});
|
|
1220
|
+
it('absent inService defaults to in-service (a pre-field broker is never falsely retired)', () => {
|
|
1221
|
+
expect(classifyHealth(snap({ quota: healthyQ }))).toBe('healthy');
|
|
1222
|
+
});
|
|
1223
|
+
it('the active account keeps its quota-derived health even if inService is false', () => {
|
|
1224
|
+
// active is in-service by definition; the guard skips retirement for it.
|
|
1225
|
+
expect(classifyHealth(snap({ isActive: true, inService: false, quota: healthyQ }))).toBe('healthy');
|
|
1226
|
+
});
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
describe('recommendation — excludes retired/org-blocked accounts', () => {
|
|
1230
|
+
it('a retired account (even with healthy quota) is never offered as a switch target', () => {
|
|
1231
|
+
// active is throttling; the only OTHER account is retired but at 0% util.
|
|
1232
|
+
// Without the exclusion it would be recommended as a healthy switch target;
|
|
1233
|
+
// with it, the retired account is invisible → "no healthy alternative".
|
|
1234
|
+
const snaps: AccountSnapshot[] = [
|
|
1235
|
+
snap({ label: 'a@x', isActive: true, quota: quota({ fiveHourUtilizationPct: 90 }) }),
|
|
1236
|
+
snap({ label: 'retired@x', inService: false, quota: quota({ fiveHourUtilizationPct: 0 }) }),
|
|
1237
|
+
];
|
|
1238
|
+
const out = recommendation(snaps, NOW);
|
|
1239
|
+
expect(out).not.toContain('retired@x');
|
|
1240
|
+
expect(out).toContain('no healthy alternative');
|
|
1241
|
+
});
|
|
1242
|
+
});
|
|
@@ -21,8 +21,10 @@ import { describe, it, expect } from 'vitest'
|
|
|
21
21
|
import {
|
|
22
22
|
decideSupersede,
|
|
23
23
|
decideSupersedeCorrection,
|
|
24
|
+
flushedAnswerMatchesReply,
|
|
24
25
|
FlushedTurnSupersedeRegistry,
|
|
25
26
|
DEFAULT_SUPERSEDE_TTL_MS,
|
|
27
|
+
SUPERSEDE_MATCH_MIN_CONTAINMENT_CHARS,
|
|
26
28
|
type FlushedTurnRecord,
|
|
27
29
|
} from '../flushed-turn-supersede.js'
|
|
28
30
|
|
|
@@ -264,3 +266,118 @@ describe('FlushedTurnSupersedeRegistry — record / peek / take lifecycle', () =
|
|
|
264
266
|
expect(reg.peek('chat1', undefined, { liveTurnId: 'turn-A', now: 5000 }).reason).toBe('no-record')
|
|
265
267
|
})
|
|
266
268
|
})
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* #3429 — async handback edit-in-place supersede of a flush-delivered message
|
|
272
|
+
* can fail to surface client-side.
|
|
273
|
+
*
|
|
274
|
+
* The flush ends its turn synchronously BEFORE recording, so EVERY superseding
|
|
275
|
+
* reply is a late reply — and an async sub-agent handback landing within the
|
|
276
|
+
* 60 s TTL with no live gateway turn resolves the flush-delivered ENDED turn
|
|
277
|
+
* as its owner via the latest-ended tier, the SAME identity as the turn's own
|
|
278
|
+
* canonical late replay. Identity-only supersede then consumed the record and
|
|
279
|
+
* EDITED the flushed message in place with the handback's unrelated content
|
|
280
|
+
* (msgs 10482/10486, 2026-07-20) — Telegram edits never push-notify, so the
|
|
281
|
+
* handback silently failed to surface AND the flushed answer was destroyed.
|
|
282
|
+
*
|
|
283
|
+
* The content gate: `replyText` is compared against the record's flushed text
|
|
284
|
+
* (`flushedAnswerMatchesReply` — whitespace-normalized equality, or containment
|
|
285
|
+
* with a minimum-length guard on the contained side). Same answer → supersede
|
|
286
|
+
* (the wanted correction); different content → 'new-content', send fresh,
|
|
287
|
+
* record NOT consumed.
|
|
288
|
+
*/
|
|
289
|
+
describe('#3429 — flushedAnswerMatchesReply (content discriminator)', () => {
|
|
290
|
+
const ANSWER = 'The deploy is green: all 12 services rolled out and health checks pass.'
|
|
291
|
+
const FLUSH_BLOB = `Let me check the rollout status.\n\n${ANSWER}`
|
|
292
|
+
|
|
293
|
+
it('matches whitespace-normalized equality', () => {
|
|
294
|
+
expect(flushedAnswerMatchesReply(ANSWER, ANSWER)).toBe(true)
|
|
295
|
+
expect(flushedAnswerMatchesReply(`${ANSWER}\n`, ANSWER.replace(': ', ': '))).toBe(true)
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('matches the classic containment class: flush = narration+answer ⊇ clean reply', () => {
|
|
299
|
+
expect(flushedAnswerMatchesReply(FLUSH_BLOB, ANSWER)).toBe(true)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('matches reverse containment: reply ⊇ partially-delivered flush text', () => {
|
|
303
|
+
expect(flushedAnswerMatchesReply(ANSWER, FLUSH_BLOB)).toBe(true)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('does NOT match genuinely different content (the handback)', () => {
|
|
307
|
+
const handback =
|
|
308
|
+
'Worker finished: PR #3430 is up with the fix for the vault broker timeout, ' +
|
|
309
|
+
'tests are green, ready for your review.'
|
|
310
|
+
expect(flushedAnswerMatchesReply(FLUSH_BLOB, handback)).toBe(false)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('short containment below the minimum-length guard does NOT match ' +
|
|
314
|
+
'(a coincidental substring must never claim a handback)', () => {
|
|
315
|
+
const shortReply = 'rolled out'
|
|
316
|
+
expect(shortReply.length).toBeLessThan(SUPERSEDE_MATCH_MIN_CONTAINMENT_CHARS)
|
|
317
|
+
expect(FLUSH_BLOB.includes(shortReply)).toBe(true)
|
|
318
|
+
expect(flushedAnswerMatchesReply(FLUSH_BLOB, shortReply)).toBe(false)
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('short EQUAL texts still match (equality has no length floor)', () => {
|
|
322
|
+
expect(flushedAnswerMatchesReply('yes, done', 'yes, done')).toBe(true)
|
|
323
|
+
})
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
describe('#3429 — decideSupersede new-content gate', () => {
|
|
327
|
+
const FLUSHED = 'Narration first.\n\nHere is the finished summary of the incident you asked about.'
|
|
328
|
+
const HANDBACK =
|
|
329
|
+
'Sub-agent handback: the researcher finished and found three root causes, ' +
|
|
330
|
+
'written up in the report at /tmp/report.md — want the highlights?'
|
|
331
|
+
|
|
332
|
+
it('same-turn same-answer late reply still supersedes (the wanted correction)', () => {
|
|
333
|
+
const d = decideSupersede(rec({ text: FLUSHED }), {
|
|
334
|
+
liveTurnId: 'turn-A',
|
|
335
|
+
replyText: 'Here is the finished summary of the incident you asked about.',
|
|
336
|
+
now: 1_000_010,
|
|
337
|
+
})
|
|
338
|
+
expect(d.supersede).toBe(true)
|
|
339
|
+
expect(d.reason).toBe('supersede')
|
|
340
|
+
expect(d.recordText).toBe(FLUSHED)
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('CORE #3429: same turn identity + DIFFERENT content → new-content, NO supersede', () => {
|
|
344
|
+
const d = decideSupersede(rec({ text: FLUSHED }), {
|
|
345
|
+
liveTurnId: 'turn-A',
|
|
346
|
+
replyText: HANDBACK,
|
|
347
|
+
now: 1_000_010,
|
|
348
|
+
})
|
|
349
|
+
expect(d.supersede).toBe(false)
|
|
350
|
+
expect(d.reason).toBe('new-content')
|
|
351
|
+
expect(d.deleteMessageIds).toEqual([])
|
|
352
|
+
expect(d.recordText).toBe(FLUSHED)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('legacy identity-only callers (no replyText) keep the pre-#3429 behaviour', () => {
|
|
356
|
+
const d = decideSupersede(rec({ text: FLUSHED }), { liveTurnId: 'turn-A', now: 1_000_010 })
|
|
357
|
+
expect(d.supersede).toBe(true)
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it('take() does NOT consume the record on new-content — the genuine replay ' +
|
|
361
|
+
'can still correct the flushed message afterwards', () => {
|
|
362
|
+
const reg = new FlushedTurnSupersedeRegistry()
|
|
363
|
+
const now = 1_000_000
|
|
364
|
+
reg.record('chat9', undefined, { turnId: 'turn-F', messageIds: [7001], text: FLUSHED }, now)
|
|
365
|
+
|
|
366
|
+
// The handback lands first: new-content, nothing consumed, nothing deleted.
|
|
367
|
+
const d1 = reg.take('chat9', undefined, { liveTurnId: 'turn-F', replyText: HANDBACK, now: now + 10_000 })
|
|
368
|
+
expect(d1.supersede).toBe(false)
|
|
369
|
+
expect(d1.reason).toBe('new-content')
|
|
370
|
+
|
|
371
|
+
// The turn's own canonical replay lands later: record still there, supersede
|
|
372
|
+
// fires and consumes it.
|
|
373
|
+
const d2 = reg.take('chat9', undefined, {
|
|
374
|
+
liveTurnId: 'turn-F',
|
|
375
|
+
replyText: 'Here is the finished summary of the incident you asked about.',
|
|
376
|
+
now: now + 20_000,
|
|
377
|
+
})
|
|
378
|
+
expect(d2.supersede).toBe(true)
|
|
379
|
+
expect(d2.deleteMessageIds).toEqual([7001])
|
|
380
|
+
// Now consumed.
|
|
381
|
+
expect(reg.peek('chat9', undefined, { liveTurnId: 'turn-F', now: now + 21_000 }).reason).toBe('no-record')
|
|
382
|
+
})
|
|
383
|
+
})
|