@moxxy/core 0.3.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/LICENSE +21 -0
- package/README.md +125 -0
- package/dist/events/factory.d.ts +6 -0
- package/dist/events/factory.d.ts.map +1 -0
- package/dist/events/factory.js +14 -0
- package/dist/events/factory.js.map +1 -0
- package/dist/events/log.d.ts +92 -0
- package/dist/events/log.d.ts.map +1 -0
- package/dist/events/log.js +244 -0
- package/dist/events/log.js.map +1 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +41 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +16 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +27 -0
- package/dist/logger.js.map +1 -0
- package/dist/permissions/engine.d.ts +94 -0
- package/dist/permissions/engine.d.ts.map +1 -0
- package/dist/permissions/engine.js +203 -0
- package/dist/permissions/engine.js.map +1 -0
- package/dist/permissions/resolvers.d.ts +8 -0
- package/dist/permissions/resolvers.d.ts.map +1 -0
- package/dist/permissions/resolvers.js +8 -0
- package/dist/permissions/resolvers.js.map +1 -0
- package/dist/plugins/discovery.d.ts +9 -0
- package/dist/plugins/discovery.d.ts.map +1 -0
- package/dist/plugins/discovery.js +112 -0
- package/dist/plugins/discovery.js.map +1 -0
- package/dist/plugins/host-options.d.ts +72 -0
- package/dist/plugins/host-options.d.ts.map +1 -0
- package/dist/plugins/host-options.js +2 -0
- package/dist/plugins/host-options.js.map +1 -0
- package/dist/plugins/host.d.ts +53 -0
- package/dist/plugins/host.d.ts.map +1 -0
- package/dist/plugins/host.js +269 -0
- package/dist/plugins/host.js.map +1 -0
- package/dist/plugins/lifecycle.d.ts +31 -0
- package/dist/plugins/lifecycle.d.ts.map +1 -0
- package/dist/plugins/lifecycle.js +100 -0
- package/dist/plugins/lifecycle.js.map +1 -0
- package/dist/plugins/loader.d.ts +7 -0
- package/dist/plugins/loader.d.ts.map +1 -0
- package/dist/plugins/loader.js +68 -0
- package/dist/plugins/loader.js.map +1 -0
- package/dist/plugins/package-requirements.d.ts +16 -0
- package/dist/plugins/package-requirements.d.ts.map +1 -0
- package/dist/plugins/package-requirements.js +52 -0
- package/dist/plugins/package-requirements.js.map +1 -0
- package/dist/plugins/registry-kinds.d.ts +58 -0
- package/dist/plugins/registry-kinds.d.ts.map +1 -0
- package/dist/plugins/registry-kinds.js +139 -0
- package/dist/plugins/registry-kinds.js.map +1 -0
- package/dist/plugins/toposort.d.ts +24 -0
- package/dist/plugins/toposort.d.ts.map +1 -0
- package/dist/plugins/toposort.js +67 -0
- package/dist/plugins/toposort.js.map +1 -0
- package/dist/preferences.d.ts +40 -0
- package/dist/preferences.d.ts.map +1 -0
- package/dist/preferences.js +58 -0
- package/dist/preferences.js.map +1 -0
- package/dist/registries/active-backend-registry.d.ts +65 -0
- package/dist/registries/active-backend-registry.d.ts.map +1 -0
- package/dist/registries/active-backend-registry.js +108 -0
- package/dist/registries/active-backend-registry.js.map +1 -0
- package/dist/registries/active-def-registry.d.ts +59 -0
- package/dist/registries/active-def-registry.d.ts.map +1 -0
- package/dist/registries/active-def-registry.js +63 -0
- package/dist/registries/active-def-registry.js.map +1 -0
- package/dist/registries/agents.d.ts +16 -0
- package/dist/registries/agents.d.ts.map +1 -0
- package/dist/registries/agents.js +17 -0
- package/dist/registries/agents.js.map +1 -0
- package/dist/registries/cache-strategies.d.ts +12 -0
- package/dist/registries/cache-strategies.d.ts.map +1 -0
- package/dist/registries/cache-strategies.js +13 -0
- package/dist/registries/cache-strategies.js.map +1 -0
- package/dist/registries/channels.d.ts +16 -0
- package/dist/registries/channels.d.ts.map +1 -0
- package/dist/registries/channels.js +35 -0
- package/dist/registries/channels.js.map +1 -0
- package/dist/registries/commands.d.ts +29 -0
- package/dist/registries/commands.d.ts.map +1 -0
- package/dist/registries/commands.js +72 -0
- package/dist/registries/commands.js.map +1 -0
- package/dist/registries/compactors.d.ts +12 -0
- package/dist/registries/compactors.d.ts.map +1 -0
- package/dist/registries/compactors.js +13 -0
- package/dist/registries/compactors.js.map +1 -0
- package/dist/registries/def-map-registry.d.ts +33 -0
- package/dist/registries/def-map-registry.d.ts.map +1 -0
- package/dist/registries/def-map-registry.js +36 -0
- package/dist/registries/def-map-registry.js.map +1 -0
- package/dist/registries/embedders.d.ts +16 -0
- package/dist/registries/embedders.d.ts.map +1 -0
- package/dist/registries/embedders.js +17 -0
- package/dist/registries/embedders.js.map +1 -0
- package/dist/registries/isolators.d.ts +21 -0
- package/dist/registries/isolators.d.ts.map +1 -0
- package/dist/registries/isolators.js +32 -0
- package/dist/registries/isolators.js.map +1 -0
- package/dist/registries/modes.d.ts +28 -0
- package/dist/registries/modes.d.ts.map +1 -0
- package/dist/registries/modes.js +81 -0
- package/dist/registries/modes.js.map +1 -0
- package/dist/registries/providers.d.ts +44 -0
- package/dist/registries/providers.d.ts.map +1 -0
- package/dist/registries/providers.js +95 -0
- package/dist/registries/providers.js.map +1 -0
- package/dist/registries/skills.d.ts +24 -0
- package/dist/registries/skills.d.ts.map +1 -0
- package/dist/registries/skills.js +81 -0
- package/dist/registries/skills.js.map +1 -0
- package/dist/registries/surfaces.d.ts +13 -0
- package/dist/registries/surfaces.d.ts.map +1 -0
- package/dist/registries/surfaces.js +14 -0
- package/dist/registries/surfaces.js.map +1 -0
- package/dist/registries/synthesizers.d.ts +26 -0
- package/dist/registries/synthesizers.d.ts.map +1 -0
- package/dist/registries/synthesizers.js +34 -0
- package/dist/registries/synthesizers.js.map +1 -0
- package/dist/registries/tools.d.ts +49 -0
- package/dist/registries/tools.d.ts.map +1 -0
- package/dist/registries/tools.js +98 -0
- package/dist/registries/tools.js.map +1 -0
- package/dist/registries/transcribers.d.ts +17 -0
- package/dist/registries/transcribers.d.ts.map +1 -0
- package/dist/registries/transcribers.js +18 -0
- package/dist/registries/transcribers.js.map +1 -0
- package/dist/registries/tunnel-providers.d.ts +12 -0
- package/dist/registries/tunnel-providers.d.ts.map +1 -0
- package/dist/registries/tunnel-providers.js +13 -0
- package/dist/registries/tunnel-providers.js.map +1 -0
- package/dist/registries/view-renderers.d.ts +13 -0
- package/dist/registries/view-renderers.d.ts.map +1 -0
- package/dist/registries/view-renderers.js +14 -0
- package/dist/registries/view-renderers.js.map +1 -0
- package/dist/registries/workflow-executors.d.ts +13 -0
- package/dist/registries/workflow-executors.d.ts.map +1 -0
- package/dist/registries/workflow-executors.js +14 -0
- package/dist/registries/workflow-executors.js.map +1 -0
- package/dist/requirements.d.ts +59 -0
- package/dist/requirements.d.ts.map +1 -0
- package/dist/requirements.js +149 -0
- package/dist/requirements.js.map +1 -0
- package/dist/run-turn.d.ts +6 -0
- package/dist/run-turn.d.ts.map +1 -0
- package/dist/run-turn.js +141 -0
- package/dist/run-turn.js.map +1 -0
- package/dist/session-runtime.d.ts +67 -0
- package/dist/session-runtime.d.ts.map +1 -0
- package/dist/session-runtime.js +2 -0
- package/dist/session-runtime.js.map +1 -0
- package/dist/session.d.ts +203 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +399 -0
- package/dist/session.js.map +1 -0
- package/dist/sessions/persistence.d.ts +184 -0
- package/dist/sessions/persistence.d.ts.map +1 -0
- package/dist/sessions/persistence.js +442 -0
- package/dist/sessions/persistence.js.map +1 -0
- package/dist/setup-agent.d.ts +91 -0
- package/dist/setup-agent.d.ts.map +1 -0
- package/dist/setup-agent.js +138 -0
- package/dist/setup-agent.js.map +1 -0
- package/dist/skills/index.d.ts +4 -0
- package/dist/skills/index.d.ts.map +1 -0
- package/dist/skills/index.js +4 -0
- package/dist/skills/index.js.map +1 -0
- package/dist/skills/loader.d.ts +16 -0
- package/dist/skills/loader.d.ts.map +1 -0
- package/dist/skills/loader.js +99 -0
- package/dist/skills/loader.js.map +1 -0
- package/dist/skills/parse.d.ts +5 -0
- package/dist/skills/parse.d.ts.map +1 -0
- package/dist/skills/parse.js +8 -0
- package/dist/skills/parse.js.map +1 -0
- package/dist/skills/synthesize-draft.d.ts +8 -0
- package/dist/skills/synthesize-draft.d.ts.map +1 -0
- package/dist/skills/synthesize-draft.js +32 -0
- package/dist/skills/synthesize-draft.js.map +1 -0
- package/dist/skills/synthesize.d.ts +33 -0
- package/dist/skills/synthesize.d.ts.map +1 -0
- package/dist/skills/synthesize.js +241 -0
- package/dist/skills/synthesize.js.map +1 -0
- package/dist/subagents/events.d.ts +20 -0
- package/dist/subagents/events.d.ts.map +1 -0
- package/dist/subagents/events.js +146 -0
- package/dist/subagents/events.js.map +1 -0
- package/dist/subagents/registry.d.ts +40 -0
- package/dist/subagents/registry.d.ts.map +1 -0
- package/dist/subagents/registry.js +42 -0
- package/dist/subagents/registry.js.map +1 -0
- package/dist/subagents/run-child.d.ts +43 -0
- package/dist/subagents/run-child.d.ts.map +1 -0
- package/dist/subagents/run-child.js +276 -0
- package/dist/subagents/run-child.js.map +1 -0
- package/dist/subagents/spawn.d.ts +8 -0
- package/dist/subagents/spawn.d.ts.map +1 -0
- package/dist/subagents/spawn.js +8 -0
- package/dist/subagents/spawn.js.map +1 -0
- package/dist/subagents/tools.d.ts +4 -0
- package/dist/subagents/tools.d.ts.map +1 -0
- package/dist/subagents/tools.js +13 -0
- package/dist/subagents/tools.js.map +1 -0
- package/dist/subagents.d.ts +3 -0
- package/dist/subagents.d.ts.map +1 -0
- package/dist/subagents.js +3 -0
- package/dist/subagents.js.map +1 -0
- package/dist/surfaces/host.d.ts +53 -0
- package/dist/surfaces/host.d.ts.map +1 -0
- package/dist/surfaces/host.js +177 -0
- package/dist/surfaces/host.js.map +1 -0
- package/dist/tunnel/localhost.d.ts +7 -0
- package/dist/tunnel/localhost.d.ts.map +1 -0
- package/dist/tunnel/localhost.js +21 -0
- package/dist/tunnel/localhost.js.map +1 -0
- package/dist/usage-stats.d.ts +42 -0
- package/dist/usage-stats.d.ts.map +1 -0
- package/dist/usage-stats.js +83 -0
- package/dist/usage-stats.js.map +1 -0
- package/dist/view/default-renderer.d.ts +7 -0
- package/dist/view/default-renderer.d.ts.map +1 -0
- package/dist/view/default-renderer.js +14 -0
- package/dist/view/default-renderer.js.map +1 -0
- package/dist/view/parse.d.ts +7 -0
- package/dist/view/parse.d.ts.map +1 -0
- package/dist/view/parse.js +479 -0
- package/dist/view/parse.js.map +1 -0
- package/package.json +61 -0
- package/src/events/factory.ts +27 -0
- package/src/events/log.test.ts +470 -0
- package/src/events/log.ts +256 -0
- package/src/index.ts +102 -0
- package/src/logger.ts +46 -0
- package/src/permissions/engine.test.ts +206 -0
- package/src/permissions/engine.ts +247 -0
- package/src/permissions/resolvers.test.ts +40 -0
- package/src/permissions/resolvers.ts +17 -0
- package/src/plugins/discovery.test.ts +115 -0
- package/src/plugins/discovery.ts +124 -0
- package/src/plugins/host-options.ts +73 -0
- package/src/plugins/host-view-tunnel.test.ts +86 -0
- package/src/plugins/host.test.ts +434 -0
- package/src/plugins/host.ts +327 -0
- package/src/plugins/lifecycle.test.ts +187 -0
- package/src/plugins/lifecycle.ts +136 -0
- package/src/plugins/loader.ts +79 -0
- package/src/plugins/package-requirements.ts +50 -0
- package/src/plugins/registry-kinds.test.ts +162 -0
- package/src/plugins/registry-kinds.ts +210 -0
- package/src/plugins/toposort.test.ts +66 -0
- package/src/plugins/toposort.ts +69 -0
- package/src/preferences.test.ts +105 -0
- package/src/preferences.ts +84 -0
- package/src/registries/active-backend-registry.ts +142 -0
- package/src/registries/active-def-registry.test.ts +103 -0
- package/src/registries/active-def-registry.ts +97 -0
- package/src/registries/agents.ts +18 -0
- package/src/registries/cache-strategies.ts +14 -0
- package/src/registries/channels.ts +42 -0
- package/src/registries/commands.test.ts +71 -0
- package/src/registries/commands.ts +78 -0
- package/src/registries/compactors.ts +14 -0
- package/src/registries/def-map-registry.test.ts +67 -0
- package/src/registries/def-map-registry.ts +59 -0
- package/src/registries/embedders.test.ts +54 -0
- package/src/registries/embedders.ts +18 -0
- package/src/registries/isolators.test.ts +43 -0
- package/src/registries/isolators.ts +38 -0
- package/src/registries/modes.test.ts +66 -0
- package/src/registries/modes.ts +80 -0
- package/src/registries/providers.test.ts +50 -0
- package/src/registries/providers.ts +97 -0
- package/src/registries/semantics.test.ts +117 -0
- package/src/registries/skills.ts +87 -0
- package/src/registries/surfaces.ts +18 -0
- package/src/registries/synthesizers.test.ts +63 -0
- package/src/registries/synthesizers.ts +36 -0
- package/src/registries/tools.test.ts +142 -0
- package/src/registries/tools.ts +145 -0
- package/src/registries/transcribers.test.ts +70 -0
- package/src/registries/transcribers.ts +19 -0
- package/src/registries/tunnel-providers.test.ts +30 -0
- package/src/registries/tunnel-providers.ts +14 -0
- package/src/registries/view-renderers.test.ts +68 -0
- package/src/registries/view-renderers.ts +15 -0
- package/src/registries/workflow-executors.test.ts +53 -0
- package/src/registries/workflow-executors.ts +15 -0
- package/src/requirements.targetinfo.test.ts +70 -0
- package/src/requirements.test.ts +111 -0
- package/src/requirements.ts +262 -0
- package/src/run-turn.test.ts +178 -0
- package/src/run-turn.ts +156 -0
- package/src/session-runtime.ts +75 -0
- package/src/session.test.ts +216 -0
- package/src/session.ts +491 -0
- package/src/sessions/persistence.flush.test.ts +100 -0
- package/src/sessions/persistence.ready.test.ts +91 -0
- package/src/sessions/persistence.test.ts +274 -0
- package/src/sessions/persistence.ts +498 -0
- package/src/setup-agent.test.ts +72 -0
- package/src/setup-agent.ts +189 -0
- package/src/skills/index.ts +14 -0
- package/src/skills/loader.test.ts +102 -0
- package/src/skills/loader.ts +114 -0
- package/src/skills/parse.test.ts +59 -0
- package/src/skills/parse.ts +11 -0
- package/src/skills/synthesize-draft.test.ts +79 -0
- package/src/skills/synthesize-draft.ts +46 -0
- package/src/skills/synthesize.test.ts +170 -0
- package/src/skills/synthesize.ts +304 -0
- package/src/subagents/events.test.ts +56 -0
- package/src/subagents/events.ts +194 -0
- package/src/subagents/registry.ts +62 -0
- package/src/subagents/run-child.test.ts +185 -0
- package/src/subagents/run-child.ts +425 -0
- package/src/subagents/spawn.ts +12 -0
- package/src/subagents/tools.ts +19 -0
- package/src/subagents.ts +2 -0
- package/src/surfaces/host.test.ts +330 -0
- package/src/surfaces/host.ts +192 -0
- package/src/tunnel/localhost.test.ts +22 -0
- package/src/tunnel/localhost.ts +21 -0
- package/src/usage-stats.test.ts +77 -0
- package/src/usage-stats.ts +119 -0
- package/src/view/default-renderer.test.ts +29 -0
- package/src/view/default-renderer.ts +14 -0
- package/src/view/parse-extended.test.ts +255 -0
- package/src/view/parse.test.ts +232 -0
- package/src/view/parse.ts +520 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { ModeContext, ModeDef, MoxxyEvent, ProviderDef, ToolCallContext } from '@moxxy/sdk';
|
|
3
|
+
import { defineMode, defineProvider, definePlugin, defineTool, executeToolUses, z } from '@moxxy/sdk';
|
|
4
|
+
import { Session } from './session.js';
|
|
5
|
+
import { runTurn, collectTurn } from './run-turn.js';
|
|
6
|
+
|
|
7
|
+
// A loop that emits N assistant_message events that include `turnId` in the
|
|
8
|
+
// text, then returns. It does NOT touch the provider, so concurrency is
|
|
9
|
+
// dominated by the awaits inside the loop body (deterministic interleave via
|
|
10
|
+
// the microtask queue).
|
|
11
|
+
function makeMarkerLoop(name: string, n: number): ModeDef {
|
|
12
|
+
return defineMode({
|
|
13
|
+
name,
|
|
14
|
+
run: async function* (ctx: ModeContext): AsyncIterable<MoxxyEvent> {
|
|
15
|
+
for (let i = 0; i < n; i++) {
|
|
16
|
+
// Yield to the microtask queue so two concurrent runs interleave.
|
|
17
|
+
await Promise.resolve();
|
|
18
|
+
await ctx.emit({
|
|
19
|
+
type: 'assistant_message',
|
|
20
|
+
sessionId: ctx.sessionId,
|
|
21
|
+
turnId: ctx.turnId,
|
|
22
|
+
source: 'assistant',
|
|
23
|
+
text: `${ctx.turnId}:${i}`,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makeNoopProvider(): ProviderDef {
|
|
31
|
+
const models = [{ id: 'noop-1' }];
|
|
32
|
+
return defineProvider({
|
|
33
|
+
name: 'noop',
|
|
34
|
+
models,
|
|
35
|
+
createClient: () => ({
|
|
36
|
+
name: 'noop',
|
|
37
|
+
models,
|
|
38
|
+
stream: async function* () {
|
|
39
|
+
// unused — the test loop doesn't call into the provider.
|
|
40
|
+
},
|
|
41
|
+
countTokens: async () => 0,
|
|
42
|
+
}),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function buildSession(): Session {
|
|
47
|
+
const session = new Session({ cwd: '/tmp', silent: true });
|
|
48
|
+
session.pluginHost.registerStatic(
|
|
49
|
+
definePlugin({
|
|
50
|
+
name: 'test-loop-and-provider',
|
|
51
|
+
version: '0.0.0',
|
|
52
|
+
providers: [makeNoopProvider()],
|
|
53
|
+
modes: [makeMarkerLoop('marker', 3)],
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
session.providers.setActive('noop');
|
|
57
|
+
session.modes.setActive('marker');
|
|
58
|
+
return session;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('runTurn turnId filtering', () => {
|
|
62
|
+
it('a single turn surfaces all of its own events', async () => {
|
|
63
|
+
const session = buildSession();
|
|
64
|
+
const events = await collectTurn(session, 'hi');
|
|
65
|
+
const turnIds = new Set(events.map((e) => e.turnId));
|
|
66
|
+
expect(turnIds.size).toBe(1);
|
|
67
|
+
expect(events.filter((e) => e.type === 'assistant_message')).toHaveLength(3);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('two concurrent turns do not cross-contaminate', async () => {
|
|
71
|
+
const session = buildSession();
|
|
72
|
+
const [eventsA, eventsB] = await Promise.all([
|
|
73
|
+
collectTurn(session, 'A'),
|
|
74
|
+
collectTurn(session, 'B'),
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
const turnIdA = eventsA[0]?.turnId;
|
|
78
|
+
const turnIdB = eventsB[0]?.turnId;
|
|
79
|
+
expect(turnIdA).toBeDefined();
|
|
80
|
+
expect(turnIdB).toBeDefined();
|
|
81
|
+
expect(turnIdA).not.toBe(turnIdB);
|
|
82
|
+
|
|
83
|
+
// Every event in each result must carry the same turnId as the first
|
|
84
|
+
// event of that result. Without the filter at run-turn.ts, A's events
|
|
85
|
+
// would include B's events and vice versa.
|
|
86
|
+
expect(eventsA.every((e) => e.turnId === turnIdA)).toBe(true);
|
|
87
|
+
expect(eventsB.every((e) => e.turnId === turnIdB)).toBe(true);
|
|
88
|
+
|
|
89
|
+
// Each turn yields user_prompt + 3 assistant_message events.
|
|
90
|
+
expect(eventsA.filter((e) => e.type === 'assistant_message')).toHaveLength(3);
|
|
91
|
+
expect(eventsB.filter((e) => e.type === 'assistant_message')).toHaveLength(3);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('does not leak a subscription when startTurn throws after subscribe', async () => {
|
|
95
|
+
const session = buildSession();
|
|
96
|
+
// Force startTurn to throw by removing the active loop strategy after
|
|
97
|
+
// creating the session — getActive() will throw before we touch the log.
|
|
98
|
+
session.modes.unregister('marker');
|
|
99
|
+
|
|
100
|
+
let listenerCountBefore = 0;
|
|
101
|
+
let listenerCountAfter = 0;
|
|
102
|
+
// Peek at the listener set size via subscribing/unsubscribing a no-op
|
|
103
|
+
// (subscribe returns identity-keyed unsubscribers; we infer the leak by
|
|
104
|
+
// running runTurn many times and checking the log still works normally).
|
|
105
|
+
const probe = session.log.subscribe(() => {});
|
|
106
|
+
listenerCountBefore = 1;
|
|
107
|
+
probe();
|
|
108
|
+
|
|
109
|
+
let threw = false;
|
|
110
|
+
try {
|
|
111
|
+
for await (const _ of runTurn(session, 'will fail')) {
|
|
112
|
+
void _;
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
threw = true;
|
|
116
|
+
}
|
|
117
|
+
expect(threw).toBe(true);
|
|
118
|
+
|
|
119
|
+
const probe2 = session.log.subscribe(() => {});
|
|
120
|
+
listenerCountAfter = 1;
|
|
121
|
+
probe2();
|
|
122
|
+
|
|
123
|
+
// If runTurn leaked its subscription, the EventLog's listener count would
|
|
124
|
+
// have grown; we can't observe that directly, but we can verify the next
|
|
125
|
+
// turn still receives a clean event stream (no spurious replays).
|
|
126
|
+
expect(listenerCountAfter).toBe(listenerCountBefore);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('runTurn threads real cwd/env into onToolCall hooks', () => {
|
|
131
|
+
it('the dispatchToolCall hook ctx carries the session cwd/env, not empty placeholders', async () => {
|
|
132
|
+
const captured: { cwd?: string; envFoo?: string } = {};
|
|
133
|
+
const SESSION_CWD = '/tmp/policy-cwd';
|
|
134
|
+
|
|
135
|
+
// A mode that dispatches a single tool use through the shared dispatcher
|
|
136
|
+
// (the same path default/goal use), so the onToolCall hook fires with the
|
|
137
|
+
// ModeContext-derived cwd/env.
|
|
138
|
+
const dispatchMode = defineMode({
|
|
139
|
+
name: 'dispatch-one',
|
|
140
|
+
run: async function* (ctx: ModeContext): AsyncIterable<MoxxyEvent> {
|
|
141
|
+
yield* executeToolUses(ctx, [{ id: 'c1', name: 'noop', input: {} }], 1);
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const session = new Session({ cwd: SESSION_CWD, silent: true });
|
|
146
|
+
session.pluginHost.registerStatic(
|
|
147
|
+
definePlugin({
|
|
148
|
+
name: 'cwd-env-probe',
|
|
149
|
+
version: '0.0.0',
|
|
150
|
+
providers: [makeNoopProvider()],
|
|
151
|
+
modes: [dispatchMode],
|
|
152
|
+
tools: [
|
|
153
|
+
defineTool({
|
|
154
|
+
name: 'noop',
|
|
155
|
+
description: '',
|
|
156
|
+
inputSchema: z.object({}),
|
|
157
|
+
handler: () => 'ok',
|
|
158
|
+
}),
|
|
159
|
+
],
|
|
160
|
+
hooks: {
|
|
161
|
+
onToolCall: (ctx: ToolCallContext) => {
|
|
162
|
+
captured.cwd = ctx.cwd;
|
|
163
|
+
captured.envFoo = ctx.env.PATH; // a real env var that always exists
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
session.providers.setActive('noop');
|
|
169
|
+
session.modes.setActive('dispatch-one');
|
|
170
|
+
|
|
171
|
+
await collectTurn(session, 'go');
|
|
172
|
+
|
|
173
|
+
// Previously hardcoded '' / {}, which silently defeated path-based policy
|
|
174
|
+
// hooks. Must now be the real session cwd + a populated env.
|
|
175
|
+
expect(captured.cwd).toBe(SESSION_CWD);
|
|
176
|
+
expect(captured.envFoo).toBe(process.env.PATH);
|
|
177
|
+
});
|
|
178
|
+
});
|
package/src/run-turn.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { EmittedEvent, ModeContext, MoxxyEvent, RunTurnOptions } from '@moxxy/sdk';
|
|
2
|
+
import type { SessionRuntime } from './session-runtime.js';
|
|
3
|
+
import { createSubagentSpawner } from './subagents.js';
|
|
4
|
+
|
|
5
|
+
// `RunTurnOptions` now lives in `@moxxy/sdk` so the runner client (which has
|
|
6
|
+
// no `Session`) can reference it. Re-exported here to keep the historical
|
|
7
|
+
// `@moxxy/core` import path working.
|
|
8
|
+
export type { RunTurnOptions } from '@moxxy/sdk';
|
|
9
|
+
|
|
10
|
+
export async function* runTurn(
|
|
11
|
+
session: SessionRuntime,
|
|
12
|
+
prompt: string,
|
|
13
|
+
opts: RunTurnOptions = {},
|
|
14
|
+
): AsyncIterable<MoxxyEvent> {
|
|
15
|
+
// Mint the turnId first so the subscriber below can filter by it. Without
|
|
16
|
+
// the filter, concurrent runTurn() calls on the same Session would each
|
|
17
|
+
// observe every event from every other turn (the EventLog has one global
|
|
18
|
+
// listener set), causing cross-talk for hosts like the HTTP channel that
|
|
19
|
+
// serve multiple turns in parallel.
|
|
20
|
+
// Use a caller-supplied turnId when present (the runner mints it up front so
|
|
21
|
+
// it can return the id before the turn runs); otherwise mint one here.
|
|
22
|
+
const turnId = opts.turnId ?? session.startTurn().turnId;
|
|
23
|
+
const provider = session.providers.getActive();
|
|
24
|
+
const model = opts.model ?? provider.models[0]?.id ?? 'default';
|
|
25
|
+
// Record the resolution so out-of-band spawns (workflow triggers) can
|
|
26
|
+
// inherit the conversation's current model. Last-writer-wins when turns
|
|
27
|
+
// run concurrently — see the field's doc in SessionRuntime.
|
|
28
|
+
session.lastResolvedModel = model;
|
|
29
|
+
|
|
30
|
+
const queue: MoxxyEvent[] = [];
|
|
31
|
+
const waiters: Array<() => void> = [];
|
|
32
|
+
let done = false;
|
|
33
|
+
let strategyError: unknown = null;
|
|
34
|
+
// A mode can ask (via ctx.requestModeSwitch) to hand off to another mode
|
|
35
|
+
// once this turn finishes — applied after the strategy drains, below.
|
|
36
|
+
let requestedModeSwitch: string | null = null;
|
|
37
|
+
|
|
38
|
+
const wake = (): void => waiters.shift()?.();
|
|
39
|
+
const unsubscribe = session.log.subscribe((event) => {
|
|
40
|
+
if (event.turnId !== turnId) return;
|
|
41
|
+
queue.push(event);
|
|
42
|
+
wake();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
let strategyPromise: Promise<void> | null = null;
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
await session.log.append({
|
|
49
|
+
type: 'user_prompt',
|
|
50
|
+
sessionId: session.id,
|
|
51
|
+
turnId,
|
|
52
|
+
source: 'user',
|
|
53
|
+
text: prompt,
|
|
54
|
+
...(opts.attachments && opts.attachments.length > 0
|
|
55
|
+
? { attachments: opts.attachments }
|
|
56
|
+
: {}),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const strategy = session.modes.getActive();
|
|
60
|
+
// Combine the session's signal with the per-turn one (if provided)
|
|
61
|
+
// so either firing cancels the turn.
|
|
62
|
+
const effectiveSignal = opts.signal
|
|
63
|
+
? AbortSignal.any([session.signal, opts.signal])
|
|
64
|
+
: session.signal;
|
|
65
|
+
// The session's working dir + environment, mirrored onto the ModeContext
|
|
66
|
+
// so the shared tool dispatcher can hand onToolCall hooks the real cwd/env
|
|
67
|
+
// (path-based policy hooks gate on these) instead of empty placeholders.
|
|
68
|
+
const appCtx = session.appContext();
|
|
69
|
+
const ctx: ModeContext = {
|
|
70
|
+
sessionId: session.id,
|
|
71
|
+
turnId,
|
|
72
|
+
cwd: appCtx.cwd,
|
|
73
|
+
env: appCtx.env,
|
|
74
|
+
model,
|
|
75
|
+
systemPrompt: opts.systemPrompt,
|
|
76
|
+
provider,
|
|
77
|
+
tools: session.tools,
|
|
78
|
+
skills: session.skills,
|
|
79
|
+
log: session.log,
|
|
80
|
+
compactor: session.compactors.getActive(),
|
|
81
|
+
cacheStrategy: session.cacheStrategies.getActive(),
|
|
82
|
+
...(session.elisionSettings ? { elision: session.elisionSettings } : {}),
|
|
83
|
+
...(session.lazyTools ? { lazyTools: true } : {}),
|
|
84
|
+
// Reasoning preference (effort) — honored only by providers/models that
|
|
85
|
+
// advertise `supportsReasoning` (gated in collectProviderStream).
|
|
86
|
+
...(session.reasoning ? { reasoning: session.reasoning } : {}),
|
|
87
|
+
permissions: session.resolver,
|
|
88
|
+
...(session.approvalResolver ? { approval: session.approvalResolver } : {}),
|
|
89
|
+
hooks: session.dispatcher,
|
|
90
|
+
pluginHost: session.pluginHost,
|
|
91
|
+
signal: effectiveSignal,
|
|
92
|
+
maxIterations: opts.maxIterations,
|
|
93
|
+
subagents: createSubagentSpawner({
|
|
94
|
+
parentSession: session,
|
|
95
|
+
parentTurnId: turnId,
|
|
96
|
+
parentSignal: effectiveSignal,
|
|
97
|
+
parentModel: model,
|
|
98
|
+
}),
|
|
99
|
+
requestModeSwitch: (modeName: string) => {
|
|
100
|
+
requestedModeSwitch = modeName;
|
|
101
|
+
},
|
|
102
|
+
emit: (event: EmittedEvent) => session.log.append(event),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const turnStartCtx = { ...appCtx, turnId, iteration: 0 };
|
|
106
|
+
|
|
107
|
+
strategyPromise = (async () => {
|
|
108
|
+
try {
|
|
109
|
+
await session.dispatcher.dispatchTurnStart(turnStartCtx);
|
|
110
|
+
for await (const _ of strategy.run(ctx)) {
|
|
111
|
+
// Events are surfaced via the log subscription above.
|
|
112
|
+
void _;
|
|
113
|
+
}
|
|
114
|
+
await session.dispatcher.dispatchTurnEnd(turnStartCtx);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
strategyError = err;
|
|
117
|
+
} finally {
|
|
118
|
+
done = true;
|
|
119
|
+
wake();
|
|
120
|
+
}
|
|
121
|
+
})();
|
|
122
|
+
|
|
123
|
+
while (true) {
|
|
124
|
+
while (queue.length > 0) yield queue.shift() as MoxxyEvent;
|
|
125
|
+
if (done) break;
|
|
126
|
+
await new Promise<void>((resolve) => waiters.push(resolve));
|
|
127
|
+
}
|
|
128
|
+
} finally {
|
|
129
|
+
unsubscribe();
|
|
130
|
+
if (strategyPromise) await strategyPromise;
|
|
131
|
+
// Apply a mode hand-off the strategy requested, now that the turn has
|
|
132
|
+
// fully drained. Only on clean completion (a thrown/aborted turn keeps
|
|
133
|
+
// the current mode); an unknown target is ignored so a bad name can't
|
|
134
|
+
// wedge the session. The registry's setActive triggers the runner's
|
|
135
|
+
// InfoChanged broadcast, so channels see the new mode.
|
|
136
|
+
if (requestedModeSwitch && !strategyError) {
|
|
137
|
+
try {
|
|
138
|
+
session.modes.setActive(requestedModeSwitch);
|
|
139
|
+
} catch {
|
|
140
|
+
/* unregistered target — leave the active mode unchanged */
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (strategyError) throw strategyError;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function collectTurn(
|
|
149
|
+
session: SessionRuntime,
|
|
150
|
+
prompt: string,
|
|
151
|
+
opts: RunTurnOptions = {},
|
|
152
|
+
): Promise<ReadonlyArray<MoxxyEvent>> {
|
|
153
|
+
const events: MoxxyEvent[] = [];
|
|
154
|
+
for await (const event of runTurn(session, prompt, opts)) events.push(event);
|
|
155
|
+
return events;
|
|
156
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AppContext,
|
|
3
|
+
ApprovalResolver,
|
|
4
|
+
CacheStrategyDef,
|
|
5
|
+
CompactorDef,
|
|
6
|
+
ElisionSettings,
|
|
7
|
+
HookDispatcher,
|
|
8
|
+
LLMProvider,
|
|
9
|
+
ModeDef,
|
|
10
|
+
PermissionResolver,
|
|
11
|
+
PluginHostHandle,
|
|
12
|
+
SessionId,
|
|
13
|
+
SkillRegistry,
|
|
14
|
+
TurnId,
|
|
15
|
+
} from '@moxxy/sdk';
|
|
16
|
+
|
|
17
|
+
import type { EventLog } from './events/log.js';
|
|
18
|
+
// The concrete `Session` carries core's own richer tool registry (extra
|
|
19
|
+
// methods the SDK surface doesn't model); use it here so consumers like
|
|
20
|
+
// `buildFilteredToolRegistry` keep type-checking.
|
|
21
|
+
import type { ToolRegistry } from './registries/tools.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The slice of the concrete `Session` that the turn driver (`run-turn.ts`)
|
|
25
|
+
* and the subagent runtime (`subagents/spawn.ts`, `subagents/events.ts`)
|
|
26
|
+
* actually consume. Those modules depend on *this* interface rather than
|
|
27
|
+
* importing the `Session` class directly.
|
|
28
|
+
*
|
|
29
|
+
* Why: `session.ts` value-imports `runTurn`, so if `run-turn.ts` (and the
|
|
30
|
+
* subagent modules it pulls in) imported the `Session` class back, the
|
|
31
|
+
* package would have an import cycle — flagged by `pnpm check:deps` because
|
|
32
|
+
* dependency-cruiser tracks type-only edges (`tsPreCompilationDeps`). Routing
|
|
33
|
+
* through this leaf contract keeps the value graph a DAG
|
|
34
|
+
* (`session → run-turn → subagents → spawn → events`) with no edges pointing
|
|
35
|
+
* back at `session.ts`.
|
|
36
|
+
*
|
|
37
|
+
* `Session implements SessionRuntime`, so the contract can't silently drift
|
|
38
|
+
* from the class it abstracts.
|
|
39
|
+
*/
|
|
40
|
+
export interface SessionRuntime {
|
|
41
|
+
readonly id: SessionId;
|
|
42
|
+
readonly log: EventLog;
|
|
43
|
+
readonly signal: AbortSignal;
|
|
44
|
+
readonly tools: ToolRegistry;
|
|
45
|
+
readonly skills: SkillRegistry;
|
|
46
|
+
readonly providers: { getActive(): LLMProvider };
|
|
47
|
+
readonly modes: {
|
|
48
|
+
getActive(): ModeDef;
|
|
49
|
+
list(): ReadonlyArray<ModeDef>;
|
|
50
|
+
/** Post-turn mode hand-off (one mode handing back to another). Throws on an
|
|
51
|
+
* unknown name; run-turn guards the call. */
|
|
52
|
+
setActive(name: string): void;
|
|
53
|
+
};
|
|
54
|
+
readonly compactors: { getActive(): CompactorDef | null };
|
|
55
|
+
readonly cacheStrategies: { getActive(): CacheStrategyDef | null };
|
|
56
|
+
readonly resolver: PermissionResolver;
|
|
57
|
+
readonly approvalResolver: ApprovalResolver | null;
|
|
58
|
+
readonly elisionSettings: ElisionSettings | null;
|
|
59
|
+
readonly lazyTools: boolean;
|
|
60
|
+
/** Reasoning/thinking preference (effort), forwarded to each turn's ModeContext. */
|
|
61
|
+
readonly reasoning?: { readonly effort?: 'low' | 'medium' | 'high' } | boolean | undefined;
|
|
62
|
+
readonly dispatcher: HookDispatcher;
|
|
63
|
+
readonly pluginHost: PluginHostHandle;
|
|
64
|
+
/**
|
|
65
|
+
* Model id resolved by the most recent `runTurn()` on this session.
|
|
66
|
+
* Out-of-band spawns (workflow triggers, schedulers) read this so their
|
|
67
|
+
* children inherit whatever the user is currently talking to instead of a
|
|
68
|
+
* stale hardcoded default. Last-writer-wins under concurrent turns — it is
|
|
69
|
+
* a "current conversation model" hint, not a per-turn record. Null until
|
|
70
|
+
* the first turn resolves a model.
|
|
71
|
+
*/
|
|
72
|
+
lastResolvedModel: string | null;
|
|
73
|
+
startTurn(): { turnId: TurnId };
|
|
74
|
+
appContext(): AppContext;
|
|
75
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { asSessionId, asToolCallId, defineMode, definePlugin } from '@moxxy/sdk';
|
|
3
|
+
import { Session } from './session.js';
|
|
4
|
+
import {
|
|
5
|
+
getRetainedChild,
|
|
6
|
+
registerRetainedChild,
|
|
7
|
+
releaseRetainedChild,
|
|
8
|
+
type RetainedChildSession,
|
|
9
|
+
} from './subagents/registry.js';
|
|
10
|
+
|
|
11
|
+
describe('Session', () => {
|
|
12
|
+
it('boots with sensible defaults', () => {
|
|
13
|
+
const s = new Session({ cwd: '/tmp', silent: true });
|
|
14
|
+
expect(s.id).toMatch(/^[0-9A-Z]+$/);
|
|
15
|
+
expect(s.cwd).toBe('/tmp');
|
|
16
|
+
expect(s.log.length).toBe(0);
|
|
17
|
+
expect(s.signal.aborted).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('abort flips signal', () => {
|
|
21
|
+
const s = new Session({ cwd: '/tmp', silent: true });
|
|
22
|
+
s.abort('test');
|
|
23
|
+
expect(s.signal.aborted).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('startTurn returns a fresh turn id', () => {
|
|
27
|
+
const s = new Session({ cwd: '/tmp', silent: true });
|
|
28
|
+
const t1 = s.startTurn().turnId;
|
|
29
|
+
const t2 = s.startTurn().turnId;
|
|
30
|
+
expect(t1).not.toBe(t2);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('exposes an immutable appContext snapshot', () => {
|
|
34
|
+
const s = new Session({ cwd: '/tmp', silent: true });
|
|
35
|
+
const ctx = s.appContext();
|
|
36
|
+
expect(ctx.sessionId).toBe(s.id);
|
|
37
|
+
expect(ctx.cwd).toBe('/tmp');
|
|
38
|
+
expect(ctx.log.length).toBe(0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('fans appended events out to plugin onEvent hooks', async () => {
|
|
42
|
+
const s = new Session({ cwd: '/tmp', silent: true });
|
|
43
|
+
const onEvent = vi.fn();
|
|
44
|
+
s.pluginHost.registerStatic(
|
|
45
|
+
definePlugin({
|
|
46
|
+
name: 'observer',
|
|
47
|
+
version: '0.0.0',
|
|
48
|
+
hooks: { onEvent },
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
await s.log.append({
|
|
52
|
+
type: 'user_prompt',
|
|
53
|
+
sessionId: s.id,
|
|
54
|
+
turnId: s.startTurn().turnId,
|
|
55
|
+
source: 'user',
|
|
56
|
+
text: 'hi',
|
|
57
|
+
});
|
|
58
|
+
expect(onEvent).toHaveBeenCalledTimes(1);
|
|
59
|
+
const arg = onEvent.mock.calls[0]![0] as { type: string; text?: string };
|
|
60
|
+
expect(arg.type).toBe('user_prompt');
|
|
61
|
+
expect(arg.text).toBe('hi');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('close() fires plugin onShutdown hooks and aborts the session', async () => {
|
|
65
|
+
const s = new Session({ cwd: '/tmp', silent: true });
|
|
66
|
+
const onShutdown = vi.fn();
|
|
67
|
+
s.pluginHost.registerStatic(
|
|
68
|
+
definePlugin({ name: 'p', version: '0.0.0', hooks: { onShutdown } }),
|
|
69
|
+
);
|
|
70
|
+
await s.close();
|
|
71
|
+
expect(onShutdown).toHaveBeenCalledTimes(1);
|
|
72
|
+
expect(s.signal.aborted).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('close() is idempotent', async () => {
|
|
76
|
+
const s = new Session({ cwd: '/tmp', silent: true });
|
|
77
|
+
const onShutdown = vi.fn();
|
|
78
|
+
s.pluginHost.registerStatic(
|
|
79
|
+
definePlugin({ name: 'p', version: '0.0.0', hooks: { onShutdown } }),
|
|
80
|
+
);
|
|
81
|
+
await s.close();
|
|
82
|
+
await s.close();
|
|
83
|
+
expect(onShutdown).toHaveBeenCalledTimes(1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('close() clears only THIS session\'s retained children, not another live session\'s', async () => {
|
|
87
|
+
const a = new Session({ cwd: '/tmp', silent: true });
|
|
88
|
+
const b = new Session({ cwd: '/tmp', silent: true });
|
|
89
|
+
const childId = asSessionId('retained-scope-test');
|
|
90
|
+
// A workflow awaitInput pause on session A registers a retained child whose
|
|
91
|
+
// parentSession is A. Closing a DIFFERENT session (B) must NOT wipe it —
|
|
92
|
+
// otherwise B's close breaks A's pending continue().
|
|
93
|
+
registerRetainedChild({
|
|
94
|
+
childSessionId: childId,
|
|
95
|
+
parentSession: a,
|
|
96
|
+
} as unknown as RetainedChildSession);
|
|
97
|
+
expect(getRetainedChild(childId)).toBeDefined();
|
|
98
|
+
try {
|
|
99
|
+
await b.close();
|
|
100
|
+
// Survives B's close — it belongs to A.
|
|
101
|
+
expect(getRetainedChild(childId)).toBeDefined();
|
|
102
|
+
// A's own close reclaims it.
|
|
103
|
+
await a.close();
|
|
104
|
+
expect(getRetainedChild(childId)).toBeUndefined();
|
|
105
|
+
} finally {
|
|
106
|
+
releaseRetainedChild(childId);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('getInfo returns a serializable snapshot of the registries', () => {
|
|
111
|
+
const s = new Session({ cwd: '/tmp', silent: true });
|
|
112
|
+
const info = s.getInfo();
|
|
113
|
+
expect(info.sessionId).toBe(s.id);
|
|
114
|
+
expect(info.cwd).toBe('/tmp');
|
|
115
|
+
// Bare session: nothing active yet, all lists empty, no transcriber.
|
|
116
|
+
expect(info.activeProvider).toBeNull();
|
|
117
|
+
expect(info.activeMode).toBeNull();
|
|
118
|
+
expect(info.activeModeBadge).toBeNull();
|
|
119
|
+
expect(info.providers).toEqual([]);
|
|
120
|
+
expect(info.modes).toEqual([]);
|
|
121
|
+
expect(info.tools).toEqual([]);
|
|
122
|
+
expect(info.commands).toEqual([]);
|
|
123
|
+
expect(info.readyProviders).toEqual([]);
|
|
124
|
+
expect(info.hasTranscriber).toBe(false);
|
|
125
|
+
// The snapshot must survive a JSON round-trip (it crosses the wire).
|
|
126
|
+
expect(JSON.parse(JSON.stringify(info))).toEqual(info);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('getInfo surfaces the active mode badge (and null for unbadged modes)', () => {
|
|
130
|
+
const s = new Session({ cwd: '/tmp', silent: true });
|
|
131
|
+
// First registration auto-activates — a badged mode lights up the snapshot
|
|
132
|
+
// so channels can render a persistent indicator (e.g. goal mode).
|
|
133
|
+
s.modes.register(
|
|
134
|
+
defineMode({
|
|
135
|
+
name: 'goal',
|
|
136
|
+
badge: { label: 'GOAL', tone: 'attention' },
|
|
137
|
+
run: async function* () {},
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
expect(s.getInfo().activeModeBadge).toEqual({ label: 'GOAL', tone: 'attention' });
|
|
141
|
+
|
|
142
|
+
// Switching to a mode with no badge clears it back to null.
|
|
143
|
+
s.modes.register(defineMode({ name: 'plain', run: async function* () {} }));
|
|
144
|
+
s.modes.setActive('plain');
|
|
145
|
+
expect(s.getInfo().activeMode).toBe('plain');
|
|
146
|
+
expect(s.getInfo().activeModeBadge).toBeNull();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('exposes runTurn as a method (SessionLike conformance)', () => {
|
|
150
|
+
const s = new Session({ cwd: '/tmp', silent: true });
|
|
151
|
+
expect(typeof s.runTurn).toBe('function');
|
|
152
|
+
expect(typeof s.getInfo).toBe('function');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('resolver.policyCheck probes policy without falling through to the prompt resolver', async () => {
|
|
156
|
+
const s = new Session({ cwd: '/tmp', silent: true });
|
|
157
|
+
let prompted = 0;
|
|
158
|
+
s.setPermissionResolver({
|
|
159
|
+
name: 'counting-prompt',
|
|
160
|
+
check: async () => {
|
|
161
|
+
prompted += 1;
|
|
162
|
+
return { mode: 'allow', reason: 'prompt said yes' };
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
await s.permissions.addDeny({ name: 'Bash', reason: 'no shell' });
|
|
166
|
+
|
|
167
|
+
const permCtx = { sessionId: String(s.id) };
|
|
168
|
+
// A matching deny rule decides — prompt-free.
|
|
169
|
+
const deny = await s.resolver.policyCheck?.(
|
|
170
|
+
{ callId: asToolCallId('c1'), name: 'Bash', input: {} },
|
|
171
|
+
permCtx,
|
|
172
|
+
);
|
|
173
|
+
expect(deny).toEqual({ mode: 'deny', reason: 'no shell' });
|
|
174
|
+
// No matching rule → null (the caller supplies its own fallback); the
|
|
175
|
+
// wrapped prompt resolver is NEVER consulted by policyCheck.
|
|
176
|
+
const undecided = await s.resolver.policyCheck?.(
|
|
177
|
+
{ callId: asToolCallId('c2'), name: 'Other', input: {} },
|
|
178
|
+
permCtx,
|
|
179
|
+
);
|
|
180
|
+
expect(undecided).toBeNull();
|
|
181
|
+
expect(prompted).toBe(0);
|
|
182
|
+
// check() keeps its existing behavior: policy first, then fall through.
|
|
183
|
+
const viaCheck = await s.resolver.check(
|
|
184
|
+
{ callId: asToolCallId('c3'), name: 'Other', input: {} },
|
|
185
|
+
permCtx,
|
|
186
|
+
);
|
|
187
|
+
expect(viaCheck.mode).toBe('allow');
|
|
188
|
+
expect(prompted).toBe(1);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('resolver passes non-check members (e.g. abortAll) through to the underlying resolver with correct `this`', () => {
|
|
192
|
+
const s = new Session({ cwd: '/tmp', silent: true });
|
|
193
|
+
const abortAll = vi.fn();
|
|
194
|
+
// A resolver carrying an extra member that reads private-ish state via
|
|
195
|
+
// `this` — the Proxy must forward both the call and the receiver binding.
|
|
196
|
+
const inner = {
|
|
197
|
+
name: 'channel-resolver',
|
|
198
|
+
_closedReason: 'shutting down',
|
|
199
|
+
check: async () => ({ mode: 'allow' as const }),
|
|
200
|
+
abortAll,
|
|
201
|
+
reasonViaThis(): string {
|
|
202
|
+
return this._closedReason;
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
s.setPermissionResolver(inner as never);
|
|
206
|
+
|
|
207
|
+
const wrapped = s.resolver as unknown as {
|
|
208
|
+
abortAll: (reason?: string) => void;
|
|
209
|
+
reasonViaThis: () => string;
|
|
210
|
+
};
|
|
211
|
+
wrapped.abortAll('bye');
|
|
212
|
+
expect(abortAll).toHaveBeenCalledWith('bye');
|
|
213
|
+
// `this` is bound through the proxy so a method reading sibling state works.
|
|
214
|
+
expect(wrapped.reasonViaThis()).toBe('shutting down');
|
|
215
|
+
});
|
|
216
|
+
});
|