@hybridaione/hybridclaw 0.2.3 → 0.2.7
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/.github/workflows/ci.yml +70 -0
- package/.husky/pre-commit +1 -0
- package/CHANGELOG.md +95 -0
- package/CONTRIBUTING.md +33 -0
- package/README.md +57 -19
- package/SECURITY.md +17 -0
- package/biome.json +35 -0
- package/config.example.json +72 -7
- package/container/package-lock.json +2 -2
- package/container/package.json +1 -1
- package/container/src/approval-policy.ts +1405 -0
- package/container/src/browser-tools.ts +672 -136
- package/container/src/extensions.ts +36 -12
- package/container/src/hybridai-client.ts +65 -16
- package/container/src/index.ts +463 -110
- package/container/src/ipc.ts +5 -3
- package/container/src/token-usage.ts +107 -18
- package/container/src/tools.ts +600 -225
- package/container/src/types.ts +49 -2
- package/container/src/web-fetch.ts +89 -32
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +15 -4
- package/dist/agent.js.map +1 -1
- package/dist/audit-cli.d.ts.map +1 -1
- package/dist/audit-cli.js +4 -2
- package/dist/audit-cli.js.map +1 -1
- package/dist/audit-events.d.ts.map +1 -1
- package/dist/audit-events.js +53 -3
- package/dist/audit-events.js.map +1 -1
- package/dist/audit-trail.d.ts.map +1 -1
- package/dist/audit-trail.js +17 -8
- package/dist/audit-trail.js.map +1 -1
- package/dist/channels/discord/attachments.d.ts.map +1 -1
- package/dist/channels/discord/attachments.js +14 -7
- package/dist/channels/discord/attachments.js.map +1 -1
- package/dist/channels/discord/debounce.d.ts +9 -0
- package/dist/channels/discord/debounce.d.ts.map +1 -0
- package/dist/channels/discord/debounce.js +20 -0
- package/dist/channels/discord/debounce.js.map +1 -0
- package/dist/channels/discord/delivery.d.ts +4 -1
- package/dist/channels/discord/delivery.d.ts.map +1 -1
- package/dist/channels/discord/delivery.js +24 -4
- package/dist/channels/discord/delivery.js.map +1 -1
- package/dist/channels/discord/human-delay.d.ts +16 -0
- package/dist/channels/discord/human-delay.d.ts.map +1 -0
- package/dist/channels/discord/human-delay.js +29 -0
- package/dist/channels/discord/human-delay.js.map +1 -0
- package/dist/channels/discord/inbound.d.ts +31 -0
- package/dist/channels/discord/inbound.d.ts.map +1 -1
- package/dist/channels/discord/inbound.js +156 -6
- package/dist/channels/discord/inbound.js.map +1 -1
- package/dist/channels/discord/mentions.d.ts.map +1 -1
- package/dist/channels/discord/mentions.js +16 -4
- package/dist/channels/discord/mentions.js.map +1 -1
- package/dist/channels/discord/presence.d.ts +33 -0
- package/dist/channels/discord/presence.d.ts.map +1 -0
- package/dist/channels/discord/presence.js +111 -0
- package/dist/channels/discord/presence.js.map +1 -0
- package/dist/channels/discord/rate-limiter.d.ts +14 -0
- package/dist/channels/discord/rate-limiter.d.ts.map +1 -0
- package/dist/channels/discord/rate-limiter.js +49 -0
- package/dist/channels/discord/rate-limiter.js.map +1 -0
- package/dist/channels/discord/reactions.d.ts +38 -0
- package/dist/channels/discord/reactions.d.ts.map +1 -0
- package/dist/channels/discord/reactions.js +151 -0
- package/dist/channels/discord/reactions.js.map +1 -0
- package/dist/channels/discord/runtime.d.ts +7 -4
- package/dist/channels/discord/runtime.d.ts.map +1 -1
- package/dist/channels/discord/runtime.js +755 -150
- package/dist/channels/discord/runtime.js.map +1 -1
- package/dist/channels/discord/stream.d.ts +4 -1
- package/dist/channels/discord/stream.d.ts.map +1 -1
- package/dist/channels/discord/stream.js +19 -12
- package/dist/channels/discord/stream.js.map +1 -1
- package/dist/channels/discord/tool-actions.d.ts.map +1 -1
- package/dist/channels/discord/tool-actions.js +24 -12
- package/dist/channels/discord/tool-actions.js.map +1 -1
- package/dist/channels/discord/typing.d.ts +15 -0
- package/dist/channels/discord/typing.d.ts.map +1 -0
- package/dist/channels/discord/typing.js +106 -0
- package/dist/channels/discord/typing.js.map +1 -0
- package/dist/chunk.d.ts.map +1 -1
- package/dist/chunk.js +4 -2
- package/dist/chunk.js.map +1 -1
- package/dist/cli.js +77 -23
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +24 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +113 -18
- package/dist/config.js.map +1 -1
- package/dist/container-runner.d.ts.map +1 -1
- package/dist/container-runner.js +59 -26
- package/dist/container-runner.js.map +1 -1
- package/dist/container-setup.d.ts.map +1 -1
- package/dist/container-setup.js +10 -9
- package/dist/container-setup.js.map +1 -1
- package/dist/conversation.d.ts +2 -2
- package/dist/conversation.d.ts.map +1 -1
- package/dist/conversation.js +1 -1
- package/dist/conversation.js.map +1 -1
- package/dist/db.d.ts +118 -2
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +1568 -50
- package/dist/db.js.map +1 -1
- package/dist/delegation-manager.d.ts.map +1 -1
- package/dist/delegation-manager.js +3 -2
- package/dist/delegation-manager.js.map +1 -1
- package/dist/gateway-client.d.ts +2 -2
- package/dist/gateway-client.d.ts.map +1 -1
- package/dist/gateway-client.js +10 -4
- package/dist/gateway-client.js.map +1 -1
- package/dist/gateway-service.d.ts +3 -3
- package/dist/gateway-service.d.ts.map +1 -1
- package/dist/gateway-service.js +624 -87
- package/dist/gateway-service.js.map +1 -1
- package/dist/gateway-types.d.ts +24 -0
- package/dist/gateway-types.d.ts.map +1 -1
- package/dist/gateway-types.js.map +1 -1
- package/dist/gateway.js +303 -26
- package/dist/gateway.js.map +1 -1
- package/dist/health.d.ts.map +1 -1
- package/dist/health.js +20 -10
- package/dist/health.js.map +1 -1
- package/dist/heartbeat.d.ts +4 -0
- package/dist/heartbeat.d.ts.map +1 -1
- package/dist/heartbeat.js +62 -20
- package/dist/heartbeat.js.map +1 -1
- package/dist/hybridai-bots.d.ts.map +1 -1
- package/dist/hybridai-bots.js +4 -2
- package/dist/hybridai-bots.js.map +1 -1
- package/dist/hybridai-models.d.ts +8 -0
- package/dist/hybridai-models.d.ts.map +1 -0
- package/dist/hybridai-models.js +94 -0
- package/dist/hybridai-models.js.map +1 -0
- package/dist/instruction-approval-audit.d.ts.map +1 -1
- package/dist/instruction-approval-audit.js.map +1 -1
- package/dist/instruction-integrity.d.ts.map +1 -1
- package/dist/instruction-integrity.js +8 -2
- package/dist/instruction-integrity.js.map +1 -1
- package/dist/ipc.d.ts.map +1 -1
- package/dist/ipc.js +6 -1
- package/dist/ipc.js.map +1 -1
- package/dist/logger.js.map +1 -1
- package/dist/memory-consolidation.d.ts +17 -0
- package/dist/memory-consolidation.d.ts.map +1 -0
- package/dist/memory-consolidation.js +25 -0
- package/dist/memory-consolidation.js.map +1 -0
- package/dist/memory-service.d.ts +200 -0
- package/dist/memory-service.d.ts.map +1 -0
- package/dist/memory-service.js +294 -0
- package/dist/memory-service.js.map +1 -0
- package/dist/mount-security.d.ts.map +1 -1
- package/dist/mount-security.js +31 -7
- package/dist/mount-security.js.map +1 -1
- package/dist/observability-ingest.d.ts.map +1 -1
- package/dist/observability-ingest.js +32 -11
- package/dist/observability-ingest.js.map +1 -1
- package/dist/onboarding.d.ts.map +1 -1
- package/dist/onboarding.js +32 -9
- package/dist/onboarding.js.map +1 -1
- package/dist/proactive-policy.d.ts.map +1 -1
- package/dist/proactive-policy.js +2 -1
- package/dist/proactive-policy.js.map +1 -1
- package/dist/prompt-hooks.d.ts.map +1 -1
- package/dist/prompt-hooks.js +11 -7
- package/dist/prompt-hooks.js.map +1 -1
- package/dist/runtime-config.d.ts +104 -1
- package/dist/runtime-config.d.ts.map +1 -1
- package/dist/runtime-config.js +503 -24
- package/dist/runtime-config.js.map +1 -1
- package/dist/scheduled-task-runner.d.ts +1 -0
- package/dist/scheduled-task-runner.d.ts.map +1 -1
- package/dist/scheduled-task-runner.js +43 -10
- package/dist/scheduled-task-runner.js.map +1 -1
- package/dist/scheduler.d.ts +43 -4
- package/dist/scheduler.d.ts.map +1 -1
- package/dist/scheduler.js +530 -56
- package/dist/scheduler.js.map +1 -1
- package/dist/session-export.d.ts +26 -0
- package/dist/session-export.d.ts.map +1 -0
- package/dist/session-export.js +149 -0
- package/dist/session-export.js.map +1 -0
- package/dist/session-maintenance.d.ts.map +1 -1
- package/dist/session-maintenance.js +75 -13
- package/dist/session-maintenance.js.map +1 -1
- package/dist/session-transcripts.d.ts.map +1 -1
- package/dist/session-transcripts.js.map +1 -1
- package/dist/side-effects.d.ts.map +1 -1
- package/dist/side-effects.js +14 -2
- package/dist/side-effects.js.map +1 -1
- package/dist/skills-guard.d.ts.map +1 -1
- package/dist/skills-guard.js +893 -130
- package/dist/skills-guard.js.map +1 -1
- package/dist/skills.d.ts +5 -0
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +29 -15
- package/dist/skills.js.map +1 -1
- package/dist/token-efficiency.d.ts.map +1 -1
- package/dist/token-efficiency.js.map +1 -1
- package/dist/tui.js +92 -11
- package/dist/tui.js.map +1 -1
- package/dist/types.d.ts +150 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +24 -1
- package/dist/types.js.map +1 -1
- package/dist/update.d.ts.map +1 -1
- package/dist/update.js +42 -14
- package/dist/update.js.map +1 -1
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +49 -9
- package/dist/workspace.js.map +1 -1
- package/docs/chat.html +9 -3
- package/docs/index.html +72 -13
- package/package.json +8 -2
- package/src/agent.ts +31 -4
- package/src/audit-cli.ts +44 -16
- package/src/audit-events.ts +69 -5
- package/src/audit-trail.ts +41 -15
- package/src/channels/discord/attachments.ts +81 -27
- package/src/channels/discord/debounce.ts +25 -0
- package/src/channels/discord/delivery.ts +65 -14
- package/src/channels/discord/human-delay.ts +48 -0
- package/src/channels/discord/inbound.ts +190 -8
- package/src/channels/discord/mentions.ts +42 -18
- package/src/channels/discord/presence.ts +148 -0
- package/src/channels/discord/rate-limiter.ts +58 -0
- package/src/channels/discord/reactions.ts +211 -0
- package/src/channels/discord/runtime.ts +1161 -205
- package/src/channels/discord/stream.ts +85 -31
- package/src/channels/discord/tool-actions.ts +78 -37
- package/src/channels/discord/typing.ts +140 -0
- package/src/chunk.ts +12 -4
- package/src/cli.ts +174 -57
- package/src/config.ts +208 -36
- package/src/container-runner.ts +134 -42
- package/src/container-setup.ts +57 -22
- package/src/conversation.ts +9 -7
- package/src/db.ts +2217 -84
- package/src/delegation-manager.ts +6 -2
- package/src/gateway-client.ts +41 -17
- package/src/gateway-service.ts +996 -220
- package/src/gateway-types.ts +33 -0
- package/src/gateway.ts +487 -52
- package/src/health.ts +66 -26
- package/src/heartbeat.ts +99 -22
- package/src/hybridai-bots.ts +14 -5
- package/src/hybridai-models.ts +158 -0
- package/src/instruction-approval-audit.ts +4 -1
- package/src/instruction-integrity.ts +30 -9
- package/src/ipc.ts +23 -5
- package/src/logger.ts +4 -1
- package/src/memory-consolidation.ts +41 -0
- package/src/memory-service.ts +606 -0
- package/src/mount-security.ts +58 -13
- package/src/observability-ingest.ts +134 -35
- package/src/onboarding.ts +126 -35
- package/src/proactive-policy.ts +3 -1
- package/src/prompt-hooks.ts +42 -17
- package/src/runtime-config.ts +1112 -122
- package/src/scheduled-task-runner.ts +77 -11
- package/src/scheduler.ts +683 -60
- package/src/session-export.ts +196 -0
- package/src/session-maintenance.ts +125 -22
- package/src/session-transcripts.ts +12 -3
- package/src/side-effects.ts +28 -5
- package/src/skills-guard.ts +1067 -219
- package/src/skills.ts +163 -65
- package/src/token-efficiency.ts +31 -9
- package/src/tui.ts +166 -25
- package/src/types.ts +199 -2
- package/src/update.ts +79 -23
- package/src/workspace.ts +63 -11
- package/tests/approval-policy.test.ts +335 -0
- package/tests/discord.basic.test.ts +213 -4
- package/tests/discord.human-presence.test.ts +85 -0
- package/tests/gateway-service.media-routing.test.ts +8 -2
- package/tests/hybridai-client.test.ts +112 -0
- package/tests/hybridai-models.test.ts +46 -0
- package/tests/memory-service.test.ts +1114 -0
- package/tests/token-efficiency.basic.test.ts +8 -2
- package/tests/token-usage.cache.test.ts +128 -0
- package/vitest.e2e.config.ts +3 -1
- package/vitest.integration.config.ts +3 -1
- package/vitest.live.config.ts +3 -1
- package/vitest.unit.config.ts +9 -0
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
import { expect, test } from 'vitest';
|
|
2
2
|
|
|
3
3
|
import { buildResponseText } from '../src/channels/discord/delivery.js';
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
hasLooseBotMention,
|
|
6
|
+
isAddressedToChannel,
|
|
7
|
+
isAuthorizedCommandUser,
|
|
8
|
+
isTrigger,
|
|
9
|
+
parseCommand,
|
|
10
|
+
shouldReplyInFreeMode,
|
|
11
|
+
shouldSkipFreeReplyBecauseOtherUsersMentioned,
|
|
12
|
+
} from '../src/channels/discord/inbound.js';
|
|
13
|
+
import {
|
|
14
|
+
type MentionLookup,
|
|
15
|
+
rewriteUserMentions,
|
|
16
|
+
} from '../src/channels/discord/mentions.js';
|
|
6
17
|
|
|
7
18
|
function createLookup(entries: Record<string, string[]>): MentionLookup {
|
|
8
19
|
const byAlias = new Map<string, Set<string>>();
|
|
@@ -19,7 +30,9 @@ test('rewriteUserMentions rewrites a uniquely-resolved @alias', () => {
|
|
|
19
30
|
});
|
|
20
31
|
|
|
21
32
|
test('rewriteUserMentions does not rewrite ambiguous aliases', () => {
|
|
22
|
-
const lookup = createLookup({
|
|
33
|
+
const lookup = createLookup({
|
|
34
|
+
bob: ['111111111111111111', '222222222222222222'],
|
|
35
|
+
});
|
|
23
36
|
const output = rewriteUserMentions('hi @bob', lookup);
|
|
24
37
|
expect(output).toBe('hi @bob');
|
|
25
38
|
});
|
|
@@ -71,9 +84,23 @@ test('isTrigger still allows prefixed commands when channel mode is off', () =>
|
|
|
71
84
|
expect(shouldTrigger).toBe(true);
|
|
72
85
|
});
|
|
73
86
|
|
|
87
|
+
test('isTrigger allows slash-text commands when channel mode is off', () => {
|
|
88
|
+
const shouldTrigger = isTrigger({
|
|
89
|
+
content: '/status',
|
|
90
|
+
isDm: false,
|
|
91
|
+
commandsOnly: false,
|
|
92
|
+
respondToAllMessages: false,
|
|
93
|
+
guildMessageMode: 'off',
|
|
94
|
+
prefix: '!claw',
|
|
95
|
+
botMentionRegex: null,
|
|
96
|
+
hasBotMention: false,
|
|
97
|
+
});
|
|
98
|
+
expect(shouldTrigger).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
74
101
|
test('isTrigger allows free-response mode in guild channels', () => {
|
|
75
102
|
const shouldTrigger = isTrigger({
|
|
76
|
-
content: '
|
|
103
|
+
content: 'Can you review this patch?',
|
|
77
104
|
isDm: false,
|
|
78
105
|
commandsOnly: false,
|
|
79
106
|
respondToAllMessages: false,
|
|
@@ -85,6 +112,20 @@ test('isTrigger allows free-response mode in guild channels', () => {
|
|
|
85
112
|
expect(shouldTrigger).toBe(true);
|
|
86
113
|
});
|
|
87
114
|
|
|
115
|
+
test('isTrigger keeps mention mode even when respondToAllMessages is enabled', () => {
|
|
116
|
+
const shouldTrigger = isTrigger({
|
|
117
|
+
content: 'hello',
|
|
118
|
+
isDm: false,
|
|
119
|
+
commandsOnly: false,
|
|
120
|
+
respondToAllMessages: true,
|
|
121
|
+
guildMessageMode: 'mention',
|
|
122
|
+
prefix: '!claw',
|
|
123
|
+
botMentionRegex: null,
|
|
124
|
+
hasBotMention: false,
|
|
125
|
+
});
|
|
126
|
+
expect(shouldTrigger).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
88
129
|
test('parseCommand recognizes channel command namespace', () => {
|
|
89
130
|
const parsed = parseCommand('!claw channel mode free', null, '!claw');
|
|
90
131
|
expect(parsed).toEqual({
|
|
@@ -93,3 +134,171 @@ test('parseCommand recognizes channel command namespace', () => {
|
|
|
93
134
|
args: ['mode', 'free'],
|
|
94
135
|
});
|
|
95
136
|
});
|
|
137
|
+
|
|
138
|
+
test('parseCommand recognizes usage command namespace', () => {
|
|
139
|
+
const parsed = parseCommand('!claw usage monthly', null, '!claw');
|
|
140
|
+
expect(parsed).toEqual({
|
|
141
|
+
isCommand: true,
|
|
142
|
+
command: 'usage',
|
|
143
|
+
args: ['monthly'],
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('parseCommand recognizes status command namespace', () => {
|
|
148
|
+
const parsed = parseCommand('!claw status', null, '!claw');
|
|
149
|
+
expect(parsed).toEqual({
|
|
150
|
+
isCommand: true,
|
|
151
|
+
command: 'status',
|
|
152
|
+
args: [],
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('parseCommand recognizes slash-text status command namespace', () => {
|
|
157
|
+
const parsed = parseCommand('/status', null, '!claw');
|
|
158
|
+
expect(parsed).toEqual({
|
|
159
|
+
isCommand: true,
|
|
160
|
+
command: 'status',
|
|
161
|
+
args: [],
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('isTrigger commands-only allows slash-text commands', () => {
|
|
166
|
+
const shouldTrigger = isTrigger({
|
|
167
|
+
content: '/status',
|
|
168
|
+
isDm: false,
|
|
169
|
+
commandsOnly: true,
|
|
170
|
+
respondToAllMessages: false,
|
|
171
|
+
guildMessageMode: 'off',
|
|
172
|
+
prefix: '!claw',
|
|
173
|
+
botMentionRegex: null,
|
|
174
|
+
hasBotMention: false,
|
|
175
|
+
});
|
|
176
|
+
expect(shouldTrigger).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('free-mode skips non-bot user mentions', () => {
|
|
180
|
+
const shouldSkip = shouldSkipFreeReplyBecauseOtherUsersMentioned({
|
|
181
|
+
guildMessageMode: 'free',
|
|
182
|
+
hasBotMention: false,
|
|
183
|
+
hasPrefixInvocation: false,
|
|
184
|
+
botUserId: '111',
|
|
185
|
+
mentionedUserIds: ['222'],
|
|
186
|
+
});
|
|
187
|
+
expect(shouldSkip).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('free-mode does not skip when bot is mentioned', () => {
|
|
191
|
+
const shouldSkip = shouldSkipFreeReplyBecauseOtherUsersMentioned({
|
|
192
|
+
guildMessageMode: 'free',
|
|
193
|
+
hasBotMention: true,
|
|
194
|
+
hasPrefixInvocation: false,
|
|
195
|
+
botUserId: '111',
|
|
196
|
+
mentionedUserIds: ['111', '222'],
|
|
197
|
+
});
|
|
198
|
+
expect(shouldSkip).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('free-mode replies to actionable questions', () => {
|
|
202
|
+
const shouldReply = shouldReplyInFreeMode({
|
|
203
|
+
guildMessageMode: 'free',
|
|
204
|
+
content: 'Can you review this patch?',
|
|
205
|
+
hasBotMention: false,
|
|
206
|
+
hasPrefixInvocation: false,
|
|
207
|
+
isReplyToBot: false,
|
|
208
|
+
hasAttachments: false,
|
|
209
|
+
});
|
|
210
|
+
expect(shouldReply).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('free-mode replies to plain-text bot-name mentions', () => {
|
|
214
|
+
const hasLooseMention = hasLooseBotMention('hybridclaw, thoughts on this?', [
|
|
215
|
+
'HybridClaw',
|
|
216
|
+
'claw',
|
|
217
|
+
]);
|
|
218
|
+
expect(hasLooseMention).toBe(true);
|
|
219
|
+
|
|
220
|
+
const shouldReply = shouldReplyInFreeMode({
|
|
221
|
+
guildMessageMode: 'free',
|
|
222
|
+
content: 'hybridclaw please take a look',
|
|
223
|
+
hasBotMention: false,
|
|
224
|
+
hasLooseBotMention: true,
|
|
225
|
+
hasPrefixInvocation: false,
|
|
226
|
+
isReplyToBot: false,
|
|
227
|
+
hasAttachments: false,
|
|
228
|
+
});
|
|
229
|
+
expect(shouldReply).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('free-mode replies to channel-addressed messages', () => {
|
|
233
|
+
expect(isAddressedToChannel('Hey everyone, quick status update')).toBe(true);
|
|
234
|
+
|
|
235
|
+
const shouldReply = shouldReplyInFreeMode({
|
|
236
|
+
guildMessageMode: 'free',
|
|
237
|
+
content: 'Hey everyone, quick status update',
|
|
238
|
+
hasBotMention: false,
|
|
239
|
+
hasPrefixInvocation: false,
|
|
240
|
+
isAddressedToChannel: true,
|
|
241
|
+
isReplyToBot: false,
|
|
242
|
+
hasAttachments: false,
|
|
243
|
+
});
|
|
244
|
+
expect(shouldReply).toBe(true);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('free-mode ignores low-signal acknowledgements', () => {
|
|
248
|
+
const shouldReply = shouldReplyInFreeMode({
|
|
249
|
+
guildMessageMode: 'free',
|
|
250
|
+
content: 'ok',
|
|
251
|
+
hasBotMention: false,
|
|
252
|
+
hasPrefixInvocation: false,
|
|
253
|
+
isReplyToBot: false,
|
|
254
|
+
hasAttachments: false,
|
|
255
|
+
});
|
|
256
|
+
expect(shouldReply).toBe(false);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('free-mode allows reply-to-bot followups', () => {
|
|
260
|
+
const shouldReply = shouldReplyInFreeMode({
|
|
261
|
+
guildMessageMode: 'free',
|
|
262
|
+
content: 'sure',
|
|
263
|
+
hasBotMention: false,
|
|
264
|
+
hasPrefixInvocation: false,
|
|
265
|
+
isReplyToBot: true,
|
|
266
|
+
hasAttachments: false,
|
|
267
|
+
});
|
|
268
|
+
expect(shouldReply).toBe(true);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('command access allows all users in public mode', () => {
|
|
272
|
+
const authorized = isAuthorizedCommandUser({
|
|
273
|
+
mode: 'public',
|
|
274
|
+
userId: '222',
|
|
275
|
+
allowedUserIds: ['111'],
|
|
276
|
+
legacyCommandUserId: '333',
|
|
277
|
+
});
|
|
278
|
+
expect(authorized).toBe(true);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('command access enforces allowlist in restricted mode', () => {
|
|
282
|
+
const authorized = isAuthorizedCommandUser({
|
|
283
|
+
mode: 'restricted',
|
|
284
|
+
userId: '222',
|
|
285
|
+
allowedUserIds: ['111', '222'],
|
|
286
|
+
});
|
|
287
|
+
expect(authorized).toBe(true);
|
|
288
|
+
const denied = isAuthorizedCommandUser({
|
|
289
|
+
mode: 'restricted',
|
|
290
|
+
userId: '333',
|
|
291
|
+
allowedUserIds: ['111', '222'],
|
|
292
|
+
});
|
|
293
|
+
expect(denied).toBe(false);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('command access uses legacy commandUserId in restricted mode', () => {
|
|
297
|
+
const authorized = isAuthorizedCommandUser({
|
|
298
|
+
mode: 'restricted',
|
|
299
|
+
userId: '777',
|
|
300
|
+
allowedUserIds: [],
|
|
301
|
+
legacyCommandUserId: '777',
|
|
302
|
+
});
|
|
303
|
+
expect(authorized).toBe(true);
|
|
304
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { expect, test } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
resolveInboundDebounceMs,
|
|
5
|
+
shouldDebounceInbound,
|
|
6
|
+
} from '../src/channels/discord/debounce.js';
|
|
7
|
+
import {
|
|
8
|
+
getHumanDelayMs,
|
|
9
|
+
resolveHumanDelayConfig,
|
|
10
|
+
} from '../src/channels/discord/human-delay.js';
|
|
11
|
+
import { shouldSuppressAutoReply } from '../src/channels/discord/inbound.js';
|
|
12
|
+
import { SlidingWindowRateLimiter } from '../src/channels/discord/rate-limiter.js';
|
|
13
|
+
|
|
14
|
+
test('resolveHumanDelayConfig normalizes custom bounds', () => {
|
|
15
|
+
const resolved = resolveHumanDelayConfig({
|
|
16
|
+
mode: 'custom',
|
|
17
|
+
minMs: 2_500,
|
|
18
|
+
maxMs: 800,
|
|
19
|
+
});
|
|
20
|
+
expect(resolved).toEqual({
|
|
21
|
+
mode: 'custom',
|
|
22
|
+
minMs: 2_500,
|
|
23
|
+
maxMs: 2_500,
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('getHumanDelayMs returns 0 for off mode', () => {
|
|
28
|
+
expect(getHumanDelayMs({ mode: 'off' })).toBe(0);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('getHumanDelayMs respects configured range', () => {
|
|
32
|
+
for (let i = 0; i < 20; i += 1) {
|
|
33
|
+
const value = getHumanDelayMs({ mode: 'custom', minMs: 10, maxMs: 20 });
|
|
34
|
+
expect(value).toBeGreaterThanOrEqual(10);
|
|
35
|
+
expect(value).toBeLessThanOrEqual(20);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('shouldDebounceInbound skips media and control commands', () => {
|
|
40
|
+
expect(
|
|
41
|
+
shouldDebounceInbound({
|
|
42
|
+
content: 'hello',
|
|
43
|
+
hasAttachments: false,
|
|
44
|
+
isPrefixedCommand: false,
|
|
45
|
+
}),
|
|
46
|
+
).toBe(true);
|
|
47
|
+
expect(
|
|
48
|
+
shouldDebounceInbound({
|
|
49
|
+
content: 'hello',
|
|
50
|
+
hasAttachments: true,
|
|
51
|
+
isPrefixedCommand: false,
|
|
52
|
+
}),
|
|
53
|
+
).toBe(false);
|
|
54
|
+
expect(
|
|
55
|
+
shouldDebounceInbound({
|
|
56
|
+
content: '/stop now',
|
|
57
|
+
hasAttachments: false,
|
|
58
|
+
isPrefixedCommand: false,
|
|
59
|
+
}),
|
|
60
|
+
).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('resolveInboundDebounceMs uses channel override when provided', () => {
|
|
64
|
+
expect(resolveInboundDebounceMs(2500, undefined)).toBe(2500);
|
|
65
|
+
expect(resolveInboundDebounceMs(2500, 5000)).toBe(5000);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('shouldSuppressAutoReply matches patterns and greeting-only text', () => {
|
|
69
|
+
expect(shouldSuppressAutoReply('brb', ['/stop', 'brb'])).toBe(true);
|
|
70
|
+
expect(shouldSuppressAutoReply('hello', ['/stop'])).toBe(true);
|
|
71
|
+
expect(shouldSuppressAutoReply('please help me debug this', ['/stop'])).toBe(
|
|
72
|
+
false,
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('SlidingWindowRateLimiter enforces per-minute limits', () => {
|
|
77
|
+
const limiter = new SlidingWindowRateLimiter(60_000);
|
|
78
|
+
const key = 'channel:user';
|
|
79
|
+
expect(limiter.check(key, 2, 1_000).allowed).toBe(true);
|
|
80
|
+
expect(limiter.check(key, 2, 2_000).allowed).toBe(true);
|
|
81
|
+
const blocked = limiter.check(key, 2, 3_000);
|
|
82
|
+
expect(blocked.allowed).toBe(false);
|
|
83
|
+
expect(blocked.retryAfterMs).toBeGreaterThan(0);
|
|
84
|
+
expect(limiter.check(key, 2, 62_000).allowed).toBe(true);
|
|
85
|
+
});
|
|
@@ -15,13 +15,19 @@ const SAMPLE_MEDIA: MediaContextItem[] = [
|
|
|
15
15
|
];
|
|
16
16
|
|
|
17
17
|
test('Discord image question blocks browser_vision and prioritizes vision_analyze', () => {
|
|
18
|
-
const policy = resolveMediaToolPolicy(
|
|
18
|
+
const policy = resolveMediaToolPolicy(
|
|
19
|
+
'Was steht auf dem Bild?',
|
|
20
|
+
SAMPLE_MEDIA,
|
|
21
|
+
);
|
|
19
22
|
expect(policy.prioritizeVisionTool).toBe(true);
|
|
20
23
|
expect(policy.blockedTools).toEqual(['browser_vision']);
|
|
21
24
|
});
|
|
22
25
|
|
|
23
26
|
test('Explicit browser-tab question does not block browser_vision', () => {
|
|
24
|
-
const policy = resolveMediaToolPolicy(
|
|
27
|
+
const policy = resolveMediaToolPolicy(
|
|
28
|
+
'What is on the current browser tab?',
|
|
29
|
+
SAMPLE_MEDIA,
|
|
30
|
+
);
|
|
25
31
|
expect(policy.prioritizeVisionTool).toBe(false);
|
|
26
32
|
expect(policy.blockedTools).toBeUndefined();
|
|
27
33
|
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { afterEach, expect, test, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
callHybridAI,
|
|
5
|
+
callHybridAIStream,
|
|
6
|
+
} from '../container/src/hybridai-client.js';
|
|
7
|
+
|
|
8
|
+
const okResponse = {
|
|
9
|
+
id: 'resp_1',
|
|
10
|
+
model: 'gpt-5-nano',
|
|
11
|
+
choices: [
|
|
12
|
+
{
|
|
13
|
+
message: {
|
|
14
|
+
role: 'assistant',
|
|
15
|
+
content: 'ok',
|
|
16
|
+
},
|
|
17
|
+
finish_reason: 'stop',
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.restoreAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('callHybridAI forwards max_tokens when provided', async () => {
|
|
27
|
+
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
|
28
|
+
const body = JSON.parse(String(init?.body || '{}')) as Record<
|
|
29
|
+
string,
|
|
30
|
+
unknown
|
|
31
|
+
>;
|
|
32
|
+
expect(body.max_tokens).toBe(4096);
|
|
33
|
+
return new Response(JSON.stringify(okResponse), {
|
|
34
|
+
status: 200,
|
|
35
|
+
headers: { 'Content-Type': 'application/json' },
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
39
|
+
|
|
40
|
+
const result = await callHybridAI(
|
|
41
|
+
'https://hybridai.one',
|
|
42
|
+
'test-key',
|
|
43
|
+
'gpt-5-nano',
|
|
44
|
+
'bot_1',
|
|
45
|
+
true,
|
|
46
|
+
[{ role: 'user', content: 'hello' }],
|
|
47
|
+
[],
|
|
48
|
+
4096,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
expect(result.choices[0]?.message.content).toBe('ok');
|
|
52
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('callHybridAI omits max_tokens when not provided', async () => {
|
|
56
|
+
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
|
57
|
+
const body = JSON.parse(String(init?.body || '{}')) as Record<
|
|
58
|
+
string,
|
|
59
|
+
unknown
|
|
60
|
+
>;
|
|
61
|
+
expect(body.max_tokens).toBeUndefined();
|
|
62
|
+
return new Response(JSON.stringify(okResponse), {
|
|
63
|
+
status: 200,
|
|
64
|
+
headers: { 'Content-Type': 'application/json' },
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
68
|
+
|
|
69
|
+
await callHybridAI(
|
|
70
|
+
'https://hybridai.one',
|
|
71
|
+
'test-key',
|
|
72
|
+
'gpt-5-nano',
|
|
73
|
+
'bot_1',
|
|
74
|
+
true,
|
|
75
|
+
[{ role: 'user', content: 'hello' }],
|
|
76
|
+
[],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('callHybridAIStream forwards stream and max_tokens', async () => {
|
|
83
|
+
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
|
84
|
+
const body = JSON.parse(String(init?.body || '{}')) as Record<
|
|
85
|
+
string,
|
|
86
|
+
unknown
|
|
87
|
+
>;
|
|
88
|
+
expect(body.stream).toBe(true);
|
|
89
|
+
expect(body.stream_options).toEqual({ include_usage: true });
|
|
90
|
+
expect(body.max_tokens).toBe(1024);
|
|
91
|
+
return new Response(JSON.stringify(okResponse), {
|
|
92
|
+
status: 200,
|
|
93
|
+
headers: { 'Content-Type': 'application/json' },
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
97
|
+
|
|
98
|
+
const result = await callHybridAIStream(
|
|
99
|
+
'https://hybridai.one',
|
|
100
|
+
'test-key',
|
|
101
|
+
'gpt-5-nano',
|
|
102
|
+
'bot_1',
|
|
103
|
+
true,
|
|
104
|
+
[{ role: 'user', content: 'hello' }],
|
|
105
|
+
[],
|
|
106
|
+
() => {},
|
|
107
|
+
1024,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
expect(result.choices[0]?.message.content).toBe('ok');
|
|
111
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
112
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { expect, test } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
resolveModelContextWindowFallback,
|
|
5
|
+
resolveModelContextWindowFromList,
|
|
6
|
+
} from '../src/hybridai-models.js';
|
|
7
|
+
|
|
8
|
+
test('resolveModelContextWindowFromList matches exact model id', () => {
|
|
9
|
+
const models = [
|
|
10
|
+
{ id: 'gpt-5-mini', contextWindowTokens: 272_000 },
|
|
11
|
+
{ id: 'gpt-5-nano', contextWindowTokens: 128_000 },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
expect(resolveModelContextWindowFromList(models, 'gpt-5-mini')).toBe(272_000);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('resolveModelContextWindowFromList matches provider-prefixed tail', () => {
|
|
18
|
+
const models = [{ id: 'openai/gpt-5', contextWindowTokens: 400_000 }];
|
|
19
|
+
|
|
20
|
+
expect(resolveModelContextWindowFromList(models, 'gpt-5')).toBe(400_000);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('resolveModelContextWindowFromList returns null when unresolved', () => {
|
|
24
|
+
const models = [{ id: 'openai/gpt-5', contextWindowTokens: null }];
|
|
25
|
+
|
|
26
|
+
expect(resolveModelContextWindowFromList(models, 'gpt-5')).toBeNull();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('resolveModelContextWindowFallback resolves known defaults', () => {
|
|
30
|
+
expect(resolveModelContextWindowFallback('gpt-5-mini')).toBe(400_000);
|
|
31
|
+
expect(resolveModelContextWindowFallback('openai/gpt-5-nano')).toBe(400_000);
|
|
32
|
+
expect(resolveModelContextWindowFallback('gpt-5.1')).toBe(400_000);
|
|
33
|
+
expect(resolveModelContextWindowFallback('gpt-5.3')).toBe(400_000);
|
|
34
|
+
expect(resolveModelContextWindowFallback('anthropic/claude-opus-4-6')).toBe(
|
|
35
|
+
200_000,
|
|
36
|
+
);
|
|
37
|
+
expect(resolveModelContextWindowFallback('claude-sonnet-4.6')).toBe(200_000);
|
|
38
|
+
expect(resolveModelContextWindowFallback('google/gemini-3.1-pro')).toBe(
|
|
39
|
+
1_048_576,
|
|
40
|
+
);
|
|
41
|
+
expect(resolveModelContextWindowFallback('openai:gpt-5')).toBe(400_000);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('resolveModelContextWindowFallback returns null for unknown models', () => {
|
|
45
|
+
expect(resolveModelContextWindowFallback('unknown-model')).toBeNull();
|
|
46
|
+
});
|