@moxxy/core 0.5.0 → 0.5.2
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/events/log.d.ts.map +1 -1
- package/dist/events/log.js +38 -1
- package/dist/events/log.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/permissions/engine.d.ts.map +1 -1
- package/dist/permissions/engine.js +80 -17
- package/dist/permissions/engine.js.map +1 -1
- package/dist/plugins/discovery.d.ts.map +1 -1
- package/dist/plugins/discovery.js +48 -7
- package/dist/plugins/discovery.js.map +1 -1
- package/dist/plugins/host.d.ts.map +1 -1
- package/dist/plugins/host.js +54 -11
- package/dist/plugins/host.js.map +1 -1
- package/dist/plugins/lifecycle.d.ts.map +1 -1
- package/dist/plugins/lifecycle.js +7 -0
- package/dist/plugins/lifecycle.js.map +1 -1
- package/dist/plugins/loader.d.ts +9 -0
- package/dist/plugins/loader.d.ts.map +1 -1
- package/dist/plugins/loader.js +34 -8
- package/dist/plugins/loader.js.map +1 -1
- package/dist/plugins/registry-kinds.d.ts +21 -2
- package/dist/plugins/registry-kinds.d.ts.map +1 -1
- package/dist/plugins/registry-kinds.js +3 -1
- package/dist/plugins/registry-kinds.js.map +1 -1
- package/dist/plugins/toposort.d.ts.map +1 -1
- package/dist/plugins/toposort.js +43 -18
- package/dist/plugins/toposort.js.map +1 -1
- package/dist/preferences.d.ts.map +1 -1
- package/dist/preferences.js +5 -4
- package/dist/preferences.js.map +1 -1
- package/dist/registries/active-backend-registry.d.ts.map +1 -1
- package/dist/registries/active-backend-registry.js +10 -1
- package/dist/registries/active-backend-registry.js.map +1 -1
- package/dist/registries/channels.d.ts +2 -1
- package/dist/registries/channels.d.ts.map +1 -1
- package/dist/registries/channels.js +30 -20
- package/dist/registries/channels.js.map +1 -1
- package/dist/registries/commands.d.ts.map +1 -1
- package/dist/registries/commands.js +27 -8
- package/dist/registries/commands.js.map +1 -1
- package/dist/registries/isolators.d.ts +27 -1
- package/dist/registries/isolators.d.ts.map +1 -1
- package/dist/registries/isolators.js +35 -1
- package/dist/registries/isolators.js.map +1 -1
- package/dist/registries/modes.d.ts +4 -0
- package/dist/registries/modes.d.ts.map +1 -1
- package/dist/registries/modes.js +15 -4
- package/dist/registries/modes.js.map +1 -1
- package/dist/registries/providers.d.ts +8 -0
- package/dist/registries/providers.d.ts.map +1 -1
- package/dist/registries/providers.js +8 -0
- package/dist/registries/providers.js.map +1 -1
- package/dist/registries/skills.d.ts +9 -0
- package/dist/registries/skills.d.ts.map +1 -1
- package/dist/registries/skills.js +22 -0
- package/dist/registries/skills.js.map +1 -1
- package/dist/requirements.d.ts.map +1 -1
- package/dist/requirements.js +7 -1
- package/dist/requirements.js.map +1 -1
- package/dist/run-turn.d.ts.map +1 -1
- package/dist/run-turn.js +65 -17
- package/dist/run-turn.js.map +1 -1
- package/dist/session.d.ts +9 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +25 -3
- package/dist/session.js.map +1 -1
- package/dist/sessions/persistence.d.ts +15 -19
- package/dist/sessions/persistence.d.ts.map +1 -1
- package/dist/sessions/persistence.js +116 -49
- package/dist/sessions/persistence.js.map +1 -1
- package/dist/skills/loader.d.ts.map +1 -1
- package/dist/skills/loader.js +11 -2
- package/dist/skills/loader.js.map +1 -1
- package/dist/skills/synthesize.d.ts.map +1 -1
- package/dist/skills/synthesize.js +87 -36
- package/dist/skills/synthesize.js.map +1 -1
- package/dist/subagents/events.js +33 -2
- package/dist/subagents/events.js.map +1 -1
- package/dist/subagents/registry.d.ts +22 -1
- package/dist/subagents/registry.d.ts.map +1 -1
- package/dist/subagents/registry.js +72 -2
- package/dist/subagents/registry.js.map +1 -1
- package/dist/subagents/run-child.d.ts.map +1 -1
- package/dist/subagents/run-child.js +137 -46
- package/dist/subagents/run-child.js.map +1 -1
- package/dist/surfaces/host.d.ts +10 -0
- package/dist/surfaces/host.d.ts.map +1 -1
- package/dist/surfaces/host.js +41 -3
- package/dist/surfaces/host.js.map +1 -1
- package/dist/usage-stats.d.ts +7 -1
- package/dist/usage-stats.d.ts.map +1 -1
- package/dist/usage-stats.js +41 -15
- package/dist/usage-stats.js.map +1 -1
- package/dist/view/parse.d.ts.map +1 -1
- package/dist/view/parse.js +119 -19
- package/dist/view/parse.js.map +1 -1
- package/package.json +2 -2
- package/src/events/log.test.ts +45 -0
- package/src/events/log.ts +39 -1
- package/src/index.ts +0 -1
- package/src/permissions/engine.test.ts +60 -0
- package/src/permissions/engine.ts +81 -16
- package/src/plugins/discovery.test.ts +59 -1
- package/src/plugins/discovery.ts +57 -9
- package/src/plugins/host.test.ts +40 -0
- package/src/plugins/host.ts +48 -10
- package/src/plugins/lifecycle.test.ts +29 -0
- package/src/plugins/lifecycle.ts +7 -0
- package/src/plugins/loader.ts +45 -7
- package/src/plugins/registry-kinds.test.ts +53 -0
- package/src/plugins/registry-kinds.ts +25 -3
- package/src/plugins/toposort.ts +45 -17
- package/src/preferences.test.ts +52 -1
- package/src/preferences.ts +5 -4
- package/src/registries/active-backend-registry.ts +10 -1
- package/src/registries/channels.test.ts +79 -0
- package/src/registries/channels.ts +33 -18
- package/src/registries/commands.test.ts +65 -0
- package/src/registries/commands.ts +25 -8
- package/src/registries/isolators.test.ts +37 -0
- package/src/registries/isolators.ts +48 -1
- package/src/registries/modes.test.ts +21 -1
- package/src/registries/modes.ts +15 -2
- package/src/registries/providers.ts +8 -0
- package/src/registries/semantics.test.ts +33 -1
- package/src/registries/skills.ts +28 -0
- package/src/registries/transcribers.test.ts +13 -0
- package/src/requirements.test.ts +35 -0
- package/src/requirements.ts +7 -1
- package/src/run-turn.test.ts +219 -0
- package/src/run-turn.ts +68 -18
- package/src/session.test.ts +24 -0
- package/src/session.ts +24 -3
- package/src/sessions/page.test.ts +18 -39
- package/src/sessions/persistence.test.ts +93 -1
- package/src/sessions/persistence.ts +138 -68
- package/src/skills/loader.test.ts +18 -0
- package/src/skills/loader.ts +12 -1
- package/src/skills/synthesize.test.ts +59 -0
- package/src/skills/synthesize.ts +89 -36
- package/src/subagents/events.test.ts +56 -0
- package/src/subagents/events.ts +32 -2
- package/src/subagents/registry.test.ts +106 -0
- package/src/subagents/registry.ts +77 -3
- package/src/subagents/run-child.test.ts +163 -2
- package/src/subagents/run-child.ts +151 -47
- package/src/surfaces/host.test.ts +71 -1
- package/src/surfaces/host.ts +39 -2
- package/src/usage-stats.test.ts +99 -1
- package/src/usage-stats.ts +42 -17
- package/src/view/parse-extended.test.ts +80 -0
- package/src/view/parse.test.ts +14 -0
- package/src/view/parse.ts +113 -18
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { defineMode } from '@moxxy/sdk';
|
|
3
3
|
import { ModeRegistry } from './modes.js';
|
|
4
4
|
|
|
@@ -63,4 +63,24 @@ describe('ModeRegistry legacy-name migration', () => {
|
|
|
63
63
|
reg.replace(mode('goal')); // not the active mode
|
|
64
64
|
expect(fired).toBe(0);
|
|
65
65
|
});
|
|
66
|
+
|
|
67
|
+
it('isolates a throwing change listener so register/setActive never aborts', () => {
|
|
68
|
+
const reg = new ModeRegistry();
|
|
69
|
+
const after = vi.fn();
|
|
70
|
+
reg.onActiveChange(() => {
|
|
71
|
+
throw new Error('observer down (e.g. dead socket on InfoChanged)');
|
|
72
|
+
});
|
|
73
|
+
reg.onActiveChange(after);
|
|
74
|
+
// A throwing observer must NOT abort the remaining listeners nor unwind into
|
|
75
|
+
// the registration that triggered the notification.
|
|
76
|
+
expect(() => reg.register(mode('default'))).not.toThrow();
|
|
77
|
+
expect(after).toHaveBeenCalledTimes(1);
|
|
78
|
+
expect(reg.getActive().name).toBe('default');
|
|
79
|
+
|
|
80
|
+
// Same isolation on an explicit setActive (mode switch).
|
|
81
|
+
reg.register(mode('goal'));
|
|
82
|
+
after.mockClear();
|
|
83
|
+
expect(() => reg.setActive('goal')).not.toThrow();
|
|
84
|
+
expect(after).toHaveBeenCalledTimes(1);
|
|
85
|
+
});
|
|
66
86
|
});
|
package/src/registries/modes.ts
CHANGED
|
@@ -37,7 +37,7 @@ export class ModeRegistry {
|
|
|
37
37
|
// active behaviour changed under them, or remote clients keep driving the
|
|
38
38
|
// stale def. `activate()` would early-return on the name match, so notify
|
|
39
39
|
// directly.
|
|
40
|
-
|
|
40
|
+
this.notifyChange();
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
@@ -75,6 +75,19 @@ export class ModeRegistry {
|
|
|
75
75
|
private activate(mode: ModeDef): void {
|
|
76
76
|
if (this.active === mode.name) return;
|
|
77
77
|
this.active = mode.name;
|
|
78
|
-
|
|
78
|
+
this.notifyChange();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Fan out to change listeners, isolating each fault so a single throwing
|
|
82
|
+
* observer (e.g. the runner's InfoChanged broadcast hitting a dead socket)
|
|
83
|
+
* can't abort the rest or unwind into register()/replace()/setActive(). */
|
|
84
|
+
private notifyChange(): void {
|
|
85
|
+
for (const fn of this.changeListeners) {
|
|
86
|
+
try {
|
|
87
|
+
fn();
|
|
88
|
+
} catch {
|
|
89
|
+
// Swallow: an observer fault must not break mode activation.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
79
92
|
}
|
|
80
93
|
}
|
|
@@ -69,6 +69,14 @@ export class ProviderRegistry {
|
|
|
69
69
|
return !this.disabled.has(name);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Activate a provider, building its client on first activation. NOTE: once an
|
|
74
|
+
* instance is cached, a later `setActive(name, newConfig)` REUSES the cached
|
|
75
|
+
* instance and DROPS `newConfig` — re-config requires `replace()` first
|
|
76
|
+
* (which clears the cache), the flow the sole production caller follows.
|
|
77
|
+
* Reconfiguring by rebuilding here is intentionally avoided so callers don't
|
|
78
|
+
* silently lose instance identity / in-flight state on a redundant activate.
|
|
79
|
+
*/
|
|
72
80
|
setActive(name: string, config?: Record<string, unknown>): LLMProvider {
|
|
73
81
|
const def = this.defs.get(name);
|
|
74
82
|
if (!def) throw new Error(`Provider not registered: ${name}`);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import type { CompactorDef, ModeDef, ProviderDef } from '@moxxy/sdk';
|
|
3
|
+
import { silentLogger } from '../logger.js';
|
|
3
4
|
import { ProviderRegistry } from './providers.js';
|
|
4
5
|
import { ModeRegistry } from './modes.js';
|
|
5
6
|
import { CompactorRegistry } from './compactors.js';
|
|
@@ -114,4 +115,35 @@ describe('Registry consistency (PR3-1)', () => {
|
|
|
114
115
|
expect(r.byName('bar')?.body).toBe('two');
|
|
115
116
|
expect(r.list()).toHaveLength(1);
|
|
116
117
|
});
|
|
118
|
+
|
|
119
|
+
it('SkillRegistryImpl: warns (not silent) when two distinct skills share a frontmatter name', () => {
|
|
120
|
+
const warn = vi.fn();
|
|
121
|
+
const r = new SkillRegistryImpl({ logger: { ...silentLogger, warn } });
|
|
122
|
+
const a = {
|
|
123
|
+
id: 'project/a' as never,
|
|
124
|
+
path: '/a.md',
|
|
125
|
+
scope: 'project' as const,
|
|
126
|
+
frontmatter: { name: 'dupe', description: 'd' },
|
|
127
|
+
body: 'a',
|
|
128
|
+
};
|
|
129
|
+
const b = { ...a, id: 'project/b' as never, path: '/b.md', body: 'b' };
|
|
130
|
+
r.register(a);
|
|
131
|
+
r.register(b); // same frontmatter.name, different id → must warn
|
|
132
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
133
|
+
// Last-wins by name is preserved, but no longer silent.
|
|
134
|
+
expect(r.byName('dupe')?.id).toBe('project/b');
|
|
135
|
+
// Both are still reachable by id.
|
|
136
|
+
expect(r.list()).toHaveLength(2);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('SkillRegistryImpl: replaceAll warns on a frontmatter-name collision within the batch', () => {
|
|
140
|
+
const warn = vi.fn();
|
|
141
|
+
const r = new SkillRegistryImpl({ logger: { ...silentLogger, warn } });
|
|
142
|
+
r.replaceAll([
|
|
143
|
+
{ id: 'p/a' as never, path: '/a.md', scope: 'project' as const, frontmatter: { name: 'n', description: 'd' }, body: 'a' },
|
|
144
|
+
{ id: 'p/b' as never, path: '/b.md', scope: 'project' as const, frontmatter: { name: 'n', description: 'd' }, body: 'b' },
|
|
145
|
+
]);
|
|
146
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
147
|
+
expect(r.byName('n')?.id).toBe('p/b');
|
|
148
|
+
});
|
|
117
149
|
});
|
package/src/registries/skills.ts
CHANGED
|
@@ -1,8 +1,28 @@
|
|
|
1
1
|
import type { Skill, SkillRegistry } from '@moxxy/sdk';
|
|
2
|
+
import type { Logger } from '../logger.js';
|
|
2
3
|
|
|
3
4
|
export class SkillRegistryImpl implements SkillRegistry {
|
|
4
5
|
private readonly byId = new Map<string, Skill>();
|
|
5
6
|
private readonly byNameIdx = new Map<string, Skill>();
|
|
7
|
+
private readonly logger?: Logger;
|
|
8
|
+
|
|
9
|
+
constructor(opts: { logger?: Logger } = {}) {
|
|
10
|
+
this.logger = opts.logger;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Warn (don't silently last-wins) when two distinct skills fight over one
|
|
14
|
+
* frontmatter.name — otherwise byName() is non-deterministic by load order
|
|
15
|
+
* and the first skill becomes unreachable by name. */
|
|
16
|
+
private warnNameCollision(name: string, incomingId: string): void {
|
|
17
|
+
const existing = this.byNameIdx.get(name);
|
|
18
|
+
if (existing && existing.id !== incomingId) {
|
|
19
|
+
this.logger?.warn('SkillRegistry: two skills share a frontmatter name; last one wins by name', {
|
|
20
|
+
name,
|
|
21
|
+
existingId: existing.id,
|
|
22
|
+
incomingId,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}
|
|
6
26
|
|
|
7
27
|
list(): ReadonlyArray<Skill> {
|
|
8
28
|
return [...this.byId.values()];
|
|
@@ -34,6 +54,7 @@ export class SkillRegistryImpl implements SkillRegistry {
|
|
|
34
54
|
if (this.byId.has(skill.id)) {
|
|
35
55
|
throw new Error(`Skill already registered: ${skill.id}`);
|
|
36
56
|
}
|
|
57
|
+
this.warnNameCollision(skill.frontmatter.name, skill.id);
|
|
37
58
|
this.byId.set(skill.id, skill);
|
|
38
59
|
this.byNameIdx.set(skill.frontmatter.name, skill);
|
|
39
60
|
}
|
|
@@ -74,6 +95,13 @@ export class SkillRegistryImpl implements SkillRegistry {
|
|
|
74
95
|
const nextByName = new Map<string, Skill>();
|
|
75
96
|
for (const s of skills) {
|
|
76
97
|
nextById.set(s.id, s);
|
|
98
|
+
const priorForName = nextByName.get(s.frontmatter.name);
|
|
99
|
+
if (priorForName && priorForName.id !== s.id) {
|
|
100
|
+
this.logger?.warn(
|
|
101
|
+
'SkillRegistry: two skills share a frontmatter name; last one wins by name',
|
|
102
|
+
{ name: s.frontmatter.name, existingId: priorForName.id, incomingId: s.id },
|
|
103
|
+
);
|
|
104
|
+
}
|
|
77
105
|
nextByName.set(s.frontmatter.name, s);
|
|
78
106
|
}
|
|
79
107
|
// Synchronous swap: callers between these lines still see the OLD
|
|
@@ -67,4 +67,17 @@ describe('TranscriberRegistry', () => {
|
|
|
67
67
|
expect(r.text).toBe('new');
|
|
68
68
|
});
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
it('replacing the ACTIVE def eagerly rebuilds so getActive() never strands (non-buildOnRead)', async () => {
|
|
72
|
+
const reg = new TranscriberRegistry();
|
|
73
|
+
reg.register(fake('whisper', 'old'));
|
|
74
|
+
reg.setActive('whisper');
|
|
75
|
+
// A hot-reloaded plugin re-registers via replace() WITHOUT calling setActive
|
|
76
|
+
// again. For a non-buildOnRead registry, getActive() reads the cached
|
|
77
|
+
// instance directly; dropping it must not leave getActive() throwing.
|
|
78
|
+
reg.replace(fake('whisper', 'new'));
|
|
79
|
+
const active = reg.getActive(); // must NOT throw "has no instance"
|
|
80
|
+
const out = await active.transcribe(new Uint8Array());
|
|
81
|
+
expect(out.text).toBe('new'); // and it's the NEW def, not the stale one
|
|
82
|
+
});
|
|
70
83
|
});
|
package/src/requirements.test.ts
CHANGED
|
@@ -87,6 +87,41 @@ describe('RequirementRegistry', () => {
|
|
|
87
87
|
expect(check.issues[0]).toMatchObject({ code: 'not_ready' });
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
+
it('does not report version_mismatch for a non-plugin kind carrying a version', () => {
|
|
91
|
+
// `version` is only resolvable for the plugin kind. A version on any other
|
|
92
|
+
// kind used to compare against an always-undefined target.version and emit a
|
|
93
|
+
// permanent spurious version_mismatch ("(unknown)") even though the target
|
|
94
|
+
// was present and active.
|
|
95
|
+
const { requirements, providers } = makeRequirements();
|
|
96
|
+
providers.register(
|
|
97
|
+
defineProvider({
|
|
98
|
+
name: 'openai-codex',
|
|
99
|
+
models: [],
|
|
100
|
+
createClient: () => ({ name: 'openai-codex', models: [], stream: async function* () {}, countTokens: async () => 0 }),
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
providers.setActive('openai-codex');
|
|
104
|
+
|
|
105
|
+
const check = requirements.check([
|
|
106
|
+
{ kind: 'provider', name: 'openai-codex', state: 'active', version: '1.0.0' },
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
expect(check.ready).toBe(true);
|
|
110
|
+
expect(check.issues).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('still honors a version mismatch for the plugin kind', () => {
|
|
114
|
+
const { requirements } = makeRequirements();
|
|
115
|
+
requirements.registerPlugin('@moxxy/plugin-example', '2.0.0');
|
|
116
|
+
|
|
117
|
+
const check = requirements.check([
|
|
118
|
+
{ kind: 'plugin', name: '@moxxy/plugin-example', version: '1.0.0' },
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
expect(check.ready).toBe(false);
|
|
122
|
+
expect(check.issues[0]).toMatchObject({ code: 'version_mismatch' });
|
|
123
|
+
});
|
|
124
|
+
|
|
90
125
|
it('isReady checks a single named target without composing per-def requirements', () => {
|
|
91
126
|
const { requirements, transcribers } = makeRequirements();
|
|
92
127
|
transcribers.register(
|
package/src/requirements.ts
CHANGED
|
@@ -141,7 +141,13 @@ export class RequirementRegistry {
|
|
|
141
141
|
return issue(requirement, 'missing', `Required ${label(requirement.kind)} is not registered: ${requirement.name}`);
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
|
|
144
|
+
// `version` is only resolvable for the `plugin` kind (the sole kind whose
|
|
145
|
+
// `targetInfo` populates a version — see {@link targetInfo}). The SDK type
|
|
146
|
+
// permits `version` on every kind, so without this gate a `version` on any
|
|
147
|
+
// non-plugin kind would compare against an always-undefined `target.version`
|
|
148
|
+
// and report a permanent, spurious `version_mismatch` (target shown as
|
|
149
|
+
// `(unknown)`) even though the target is present and active.
|
|
150
|
+
if (requirement.kind === 'plugin' && requirement.version && target.version !== requirement.version) {
|
|
145
151
|
return issue(
|
|
146
152
|
requirement,
|
|
147
153
|
'version_mismatch',
|
package/src/run-turn.test.ts
CHANGED
|
@@ -176,3 +176,222 @@ describe('runTurn threads real cwd/env into onToolCall hooks', () => {
|
|
|
176
176
|
expect(captured.envFoo).toBe(process.env.PATH);
|
|
177
177
|
});
|
|
178
178
|
});
|
|
179
|
+
|
|
180
|
+
describe('runTurn worst-case hardening', () => {
|
|
181
|
+
it('aborting iteration early aborts the strategy promptly (no full background run)', async () => {
|
|
182
|
+
let observedAbort = false;
|
|
183
|
+
// A mode that loops indefinitely until its signal aborts. If the consumer's
|
|
184
|
+
// early `break` did NOT abort the turn, this would run forever and the
|
|
185
|
+
// generator's `finally` would hang awaiting it.
|
|
186
|
+
const foreverMode = defineMode({
|
|
187
|
+
name: 'forever',
|
|
188
|
+
run: async function* (ctx: ModeContext): AsyncIterable<MoxxyEvent> {
|
|
189
|
+
for (let i = 0; ; i++) {
|
|
190
|
+
if (ctx.signal.aborted) {
|
|
191
|
+
observedAbort = true;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
await ctx.emit({
|
|
195
|
+
type: 'assistant_message',
|
|
196
|
+
sessionId: ctx.sessionId,
|
|
197
|
+
turnId: ctx.turnId,
|
|
198
|
+
source: 'assistant',
|
|
199
|
+
text: `tick-${i}`,
|
|
200
|
+
});
|
|
201
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const session = new Session({ cwd: '/tmp', silent: true });
|
|
207
|
+
session.pluginHost.registerStatic(
|
|
208
|
+
definePlugin({
|
|
209
|
+
name: 'forever-plugin',
|
|
210
|
+
version: '0.0.0',
|
|
211
|
+
providers: [makeNoopProvider()],
|
|
212
|
+
modes: [foreverMode],
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
session.providers.setActive('noop');
|
|
216
|
+
session.modes.setActive('forever');
|
|
217
|
+
|
|
218
|
+
// Break out after the first event — the generator must abort the strategy
|
|
219
|
+
// in its finally and return promptly (this await would never resolve if the
|
|
220
|
+
// turn ran to its non-existent completion in the background).
|
|
221
|
+
for await (const event of runTurn(session, 'go')) {
|
|
222
|
+
if (event.type === 'assistant_message') break;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
expect(observedAbort).toBe(true);
|
|
226
|
+
expect(session.signal.aborted).toBe(false); // session-level signal untouched
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('an abandoned turn does NOT apply a mode switch it requested before being abandoned', async () => {
|
|
230
|
+
// A mode that immediately requests a hand-off, then loops until aborted and
|
|
231
|
+
// returns CLEANLY (no throw) on `signal.aborted` — so strategyError stays
|
|
232
|
+
// null. If the post-turn switch were gated only on `!strategyError`, an
|
|
233
|
+
// abandoned (consumer-broke-early) turn would silently flip the session into
|
|
234
|
+
// 'other' behind the user's back. It must stay on the original mode.
|
|
235
|
+
const requestThenLoop = defineMode({
|
|
236
|
+
name: 'requester',
|
|
237
|
+
run: async function* (ctx: ModeContext): AsyncIterable<MoxxyEvent> {
|
|
238
|
+
ctx.requestModeSwitch('other');
|
|
239
|
+
for (let i = 0; ; i++) {
|
|
240
|
+
if (ctx.signal.aborted) return; // clean return on abandonment
|
|
241
|
+
await ctx.emit({
|
|
242
|
+
type: 'assistant_message',
|
|
243
|
+
sessionId: ctx.sessionId,
|
|
244
|
+
turnId: ctx.turnId,
|
|
245
|
+
source: 'assistant',
|
|
246
|
+
text: `tick-${i}`,
|
|
247
|
+
});
|
|
248
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const session = new Session({ cwd: '/tmp', silent: true });
|
|
254
|
+
session.pluginHost.registerStatic(
|
|
255
|
+
definePlugin({
|
|
256
|
+
name: 'switch-on-abandon',
|
|
257
|
+
version: '0.0.0',
|
|
258
|
+
providers: [makeNoopProvider()],
|
|
259
|
+
modes: [requestThenLoop, defineMode({ name: 'other', run: async function* () {} })],
|
|
260
|
+
}),
|
|
261
|
+
);
|
|
262
|
+
session.providers.setActive('noop');
|
|
263
|
+
session.modes.setActive('requester');
|
|
264
|
+
|
|
265
|
+
// Abandon after the first event — the strategy aborts and returns cleanly.
|
|
266
|
+
for await (const event of runTurn(session, 'go')) {
|
|
267
|
+
if (event.type === 'assistant_message') break;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// The requested switch must be DROPPED because the turn was abandoned.
|
|
271
|
+
expect(session.modes.getActive().name).toBe('requester');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('a completed turn still applies a requested mode switch', async () => {
|
|
275
|
+
// The positive case: a mode that requests a switch and completes normally
|
|
276
|
+
// hands off as before — the abandonment gate must not regress this.
|
|
277
|
+
const requestThenDone = defineMode({
|
|
278
|
+
name: 'handoff',
|
|
279
|
+
run: async function* (ctx: ModeContext): AsyncIterable<MoxxyEvent> {
|
|
280
|
+
ctx.requestModeSwitch('target');
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
const session = new Session({ cwd: '/tmp', silent: true });
|
|
284
|
+
session.pluginHost.registerStatic(
|
|
285
|
+
definePlugin({
|
|
286
|
+
name: 'switch-on-complete',
|
|
287
|
+
version: '0.0.0',
|
|
288
|
+
providers: [makeNoopProvider()],
|
|
289
|
+
modes: [requestThenDone, defineMode({ name: 'target', run: async function* () {} })],
|
|
290
|
+
}),
|
|
291
|
+
);
|
|
292
|
+
session.providers.setActive('noop');
|
|
293
|
+
session.modes.setActive('handoff');
|
|
294
|
+
|
|
295
|
+
await collectTurn(session, 'go');
|
|
296
|
+
expect(session.modes.getActive().name).toBe('target');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('fires turnEnd even when the strategy throws (paired with turnStart)', async () => {
|
|
300
|
+
const calls: string[] = [];
|
|
301
|
+
const throwingMode = defineMode({
|
|
302
|
+
name: 'throws',
|
|
303
|
+
run: async function* (): AsyncIterable<MoxxyEvent> {
|
|
304
|
+
throw new Error('strategy boom');
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const session = new Session({ cwd: '/tmp', silent: true });
|
|
309
|
+
session.pluginHost.registerStatic(
|
|
310
|
+
definePlugin({
|
|
311
|
+
name: 'lifecycle-probe',
|
|
312
|
+
version: '0.0.0',
|
|
313
|
+
providers: [makeNoopProvider()],
|
|
314
|
+
modes: [throwingMode],
|
|
315
|
+
hooks: {
|
|
316
|
+
onTurnStart: () => {
|
|
317
|
+
calls.push('start');
|
|
318
|
+
},
|
|
319
|
+
onTurnEnd: () => {
|
|
320
|
+
calls.push('end');
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
}),
|
|
324
|
+
);
|
|
325
|
+
session.providers.setActive('noop');
|
|
326
|
+
session.modes.setActive('throws');
|
|
327
|
+
|
|
328
|
+
let threw = false;
|
|
329
|
+
try {
|
|
330
|
+
await collectTurn(session, 'go');
|
|
331
|
+
} catch {
|
|
332
|
+
threw = true;
|
|
333
|
+
}
|
|
334
|
+
expect(threw).toBe(true);
|
|
335
|
+
// turnEnd must pair with turnStart even on the error path — no leaked
|
|
336
|
+
// turn-scoped plugin state.
|
|
337
|
+
expect(calls).toEqual(['start', 'end']);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('a missing active provider records the prompt + a structured error, then rejects', async () => {
|
|
341
|
+
const session = new Session({ cwd: '/tmp', silent: true });
|
|
342
|
+
// No provider registered/active — getActive() throws.
|
|
343
|
+
session.pluginHost.registerStatic(
|
|
344
|
+
definePlugin({
|
|
345
|
+
name: 'mode-only',
|
|
346
|
+
version: '0.0.0',
|
|
347
|
+
modes: [makeMarkerLoop('marker', 1)],
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
350
|
+
session.modes.setActive('marker');
|
|
351
|
+
|
|
352
|
+
let threw = false;
|
|
353
|
+
try {
|
|
354
|
+
await collectTurn(session, 'remember me');
|
|
355
|
+
} catch {
|
|
356
|
+
threw = true;
|
|
357
|
+
}
|
|
358
|
+
expect(threw).toBe(true);
|
|
359
|
+
|
|
360
|
+
// The user prompt is preserved (not silently discarded) and a structured
|
|
361
|
+
// error event was logged so channels see a normal failed turn.
|
|
362
|
+
const events = session.log.slice();
|
|
363
|
+
const prompt = events.find((e) => e.type === 'user_prompt');
|
|
364
|
+
expect((prompt as { text?: string } | undefined)?.text).toBe('remember me');
|
|
365
|
+
const error = events.find((e) => e.type === 'error');
|
|
366
|
+
expect(error).toBeDefined();
|
|
367
|
+
expect((error as { kind?: string }).kind).toBe('fatal');
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('a provider advertising no models fails fast (no opaque "default" model id)', async () => {
|
|
371
|
+
const emptyModelsProvider = defineProvider({
|
|
372
|
+
name: 'empty',
|
|
373
|
+
models: [],
|
|
374
|
+
createClient: () => ({
|
|
375
|
+
name: 'empty',
|
|
376
|
+
models: [],
|
|
377
|
+
stream: async function* () {},
|
|
378
|
+
countTokens: async () => 0,
|
|
379
|
+
}),
|
|
380
|
+
});
|
|
381
|
+
const session = new Session({ cwd: '/tmp', silent: true });
|
|
382
|
+
session.pluginHost.registerStatic(
|
|
383
|
+
definePlugin({
|
|
384
|
+
name: 'empty-models',
|
|
385
|
+
version: '0.0.0',
|
|
386
|
+
providers: [emptyModelsProvider],
|
|
387
|
+
modes: [makeMarkerLoop('marker', 1)],
|
|
388
|
+
}),
|
|
389
|
+
);
|
|
390
|
+
session.providers.setActive('empty');
|
|
391
|
+
session.modes.setActive('marker');
|
|
392
|
+
|
|
393
|
+
await expect(collectTurn(session, 'go')).rejects.toThrow(/no models configured/i);
|
|
394
|
+
// lastResolvedModel never gets the bogus sentinel.
|
|
395
|
+
expect(session.lastResolvedModel).toBeNull();
|
|
396
|
+
});
|
|
397
|
+
});
|
package/src/run-turn.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EmittedEvent, ModeContext, MoxxyEvent, RunTurnOptions } from '@moxxy/sdk';
|
|
1
|
+
import type { EmittedEvent, LLMProvider, ModeContext, MoxxyEvent, RunTurnOptions } from '@moxxy/sdk';
|
|
2
2
|
import type { SessionRuntime } from './session-runtime.js';
|
|
3
3
|
import { createSubagentSpawner } from './subagents.js';
|
|
4
4
|
|
|
@@ -20,16 +20,11 @@ export async function* runTurn(
|
|
|
20
20
|
// Use a caller-supplied turnId when present (the runner mints it up front so
|
|
21
21
|
// it can return the id before the turn runs); otherwise mint one here.
|
|
22
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
23
|
|
|
30
24
|
const queue: MoxxyEvent[] = [];
|
|
31
25
|
const waiters: Array<() => void> = [];
|
|
32
26
|
let done = false;
|
|
27
|
+
let completed = false;
|
|
33
28
|
let strategyError: unknown = null;
|
|
34
29
|
// A mode can ask (via ctx.requestModeSwitch) to hand off to another mode
|
|
35
30
|
// once this turn finishes — applied after the strategy drains, below.
|
|
@@ -42,6 +37,12 @@ export async function* runTurn(
|
|
|
42
37
|
wake();
|
|
43
38
|
});
|
|
44
39
|
|
|
40
|
+
// Generator-scoped controller so an early consumer return/throw (HTTP client
|
|
41
|
+
// disconnect, channel teardown) can abort the in-flight strategy instead of
|
|
42
|
+
// leaving it to run the whole agentic loop to completion in the background
|
|
43
|
+
// (burning tokens, holding resources) while the abandoned `finally` blocks on
|
|
44
|
+
// `strategyPromise`.
|
|
45
|
+
const turnController = new AbortController();
|
|
45
46
|
let strategyPromise: Promise<void> | null = null;
|
|
46
47
|
|
|
47
48
|
try {
|
|
@@ -56,12 +57,46 @@ export async function* runTurn(
|
|
|
56
57
|
: {}),
|
|
57
58
|
});
|
|
58
59
|
|
|
60
|
+
// Resolve provider + model AFTER the prompt is recorded so a
|
|
61
|
+
// missing/misconfigured provider doesn't silently discard the user's
|
|
62
|
+
// prompt or orphan the turnId. On failure, append a structured error event
|
|
63
|
+
// (channels see a normal failed turn) and rethrow.
|
|
64
|
+
let provider: LLMProvider;
|
|
65
|
+
let model: string;
|
|
66
|
+
try {
|
|
67
|
+
provider = session.providers.getActive();
|
|
68
|
+
const resolvedModel = opts.model ?? provider.models[0]?.id;
|
|
69
|
+
if (!resolvedModel) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Active provider '${provider.name}' has no models configured`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
model = resolvedModel;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
77
|
+
await session.log.append({
|
|
78
|
+
type: 'error',
|
|
79
|
+
sessionId: session.id,
|
|
80
|
+
turnId,
|
|
81
|
+
source: 'system',
|
|
82
|
+
kind: 'fatal',
|
|
83
|
+
message,
|
|
84
|
+
});
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
// Record the resolution so out-of-band spawns (workflow triggers) can
|
|
88
|
+
// inherit the conversation's current model. Last-writer-wins when turns
|
|
89
|
+
// run concurrently — see the field's doc in SessionRuntime.
|
|
90
|
+
session.lastResolvedModel = model;
|
|
91
|
+
|
|
59
92
|
const strategy = session.modes.getActive();
|
|
60
|
-
// Combine the session's signal
|
|
61
|
-
// so
|
|
62
|
-
const effectiveSignal =
|
|
63
|
-
|
|
64
|
-
|
|
93
|
+
// Combine the session's signal, the per-turn one (if provided), and the
|
|
94
|
+
// generator-scoped abandonment signal so any of them firing cancels the turn.
|
|
95
|
+
const effectiveSignal = AbortSignal.any(
|
|
96
|
+
opts.signal
|
|
97
|
+
? [session.signal, opts.signal, turnController.signal]
|
|
98
|
+
: [session.signal, turnController.signal],
|
|
99
|
+
);
|
|
65
100
|
// The session's working dir + environment, mirrored onto the ModeContext
|
|
66
101
|
// so the shared tool dispatcher can hand onToolCall hooks the real cwd/env
|
|
67
102
|
// (path-based policy hooks gate on these) instead of empty placeholders.
|
|
@@ -105,16 +140,21 @@ export async function* runTurn(
|
|
|
105
140
|
const turnStartCtx = { ...appCtx, turnId, iteration: 0 };
|
|
106
141
|
|
|
107
142
|
strategyPromise = (async () => {
|
|
143
|
+
let started = false;
|
|
108
144
|
try {
|
|
109
145
|
await session.dispatcher.dispatchTurnStart(turnStartCtx);
|
|
146
|
+
started = true;
|
|
110
147
|
for await (const _ of strategy.run(ctx)) {
|
|
111
148
|
// Events are surfaced via the log subscription above.
|
|
112
149
|
void _;
|
|
113
150
|
}
|
|
114
|
-
await session.dispatcher.dispatchTurnEnd(turnStartCtx);
|
|
115
151
|
} catch (err) {
|
|
116
152
|
strategyError = err;
|
|
117
153
|
} finally {
|
|
154
|
+
// turnEnd must pair with turnStart even when the strategy throws/aborts,
|
|
155
|
+
// so plugins that allocate turn-scoped state in onTurnStart (spans,
|
|
156
|
+
// timers, token meters) always get the matching teardown.
|
|
157
|
+
if (started) await session.dispatcher.dispatchTurnEnd(turnStartCtx);
|
|
118
158
|
done = true;
|
|
119
159
|
wake();
|
|
120
160
|
}
|
|
@@ -125,15 +165,25 @@ export async function* runTurn(
|
|
|
125
165
|
if (done) break;
|
|
126
166
|
await new Promise<void>((resolve) => waiters.push(resolve));
|
|
127
167
|
}
|
|
168
|
+
completed = true;
|
|
128
169
|
} finally {
|
|
129
170
|
unsubscribe();
|
|
171
|
+
// If the consumer abandoned iteration early (broke out of the `for await`,
|
|
172
|
+
// an outer error), `completed` is still false: abort the strategy so it
|
|
173
|
+
// unwinds promptly instead of running the full loop in the background while
|
|
174
|
+
// this `finally` blocks on `strategyPromise`.
|
|
175
|
+
if (!completed) turnController.abort('runTurn iteration abandoned');
|
|
130
176
|
if (strategyPromise) await strategyPromise;
|
|
131
177
|
// Apply a mode hand-off the strategy requested, now that the turn has
|
|
132
|
-
// fully drained. Only on clean completion
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
178
|
+
// fully drained. Only on clean completion: a thrown turn (strategyError) OR
|
|
179
|
+
// an ABANDONED turn (consumer broke out early — `completed` is false, and a
|
|
180
|
+
// mode that returns cleanly on `signal.aborted` leaves strategyError null)
|
|
181
|
+
// keeps the current mode, so an unwatched/cancelled turn can't silently
|
|
182
|
+
// flip the session into a different mode behind the user's back. An unknown
|
|
183
|
+
// target is ignored so a bad name can't wedge the session. The registry's
|
|
184
|
+
// setActive triggers the runner's InfoChanged broadcast, so channels see the
|
|
185
|
+
// new mode.
|
|
186
|
+
if (requestedModeSwitch && completed && !strategyError) {
|
|
137
187
|
try {
|
|
138
188
|
session.modes.setActive(requestedModeSwitch);
|
|
139
189
|
} catch {
|
package/src/session.test.ts
CHANGED
|
@@ -72,6 +72,30 @@ describe('Session', () => {
|
|
|
72
72
|
expect(s.signal.aborted).toBe(true);
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
+
it('close() still runs plugin onShutdown when surface teardown rejects', 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
|
+
// A flaky native surface (PTY/browser) throwing during teardown must not
|
|
82
|
+
// pre-empt the plugin shutdown hooks — those are how plugins flush state.
|
|
83
|
+
vi.spyOn(s.surfaces, 'closeAll').mockRejectedValue(new Error('PTY teardown blew up'));
|
|
84
|
+
|
|
85
|
+
await expect(s.close()).resolves.toBeUndefined();
|
|
86
|
+
expect(onShutdown).toHaveBeenCalledTimes(1);
|
|
87
|
+
expect(s.signal.aborted).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('appContext env is a frozen, stable snapshot (reused across calls)', () => {
|
|
91
|
+
const s = new Session({ cwd: '/tmp', silent: true });
|
|
92
|
+
const a = s.appContext();
|
|
93
|
+
const b = s.appContext();
|
|
94
|
+
// Memoized: the same frozen object is handed out, not a fresh clone each call.
|
|
95
|
+
expect(a.env).toBe(b.env);
|
|
96
|
+
expect(Object.isFrozen(a.env)).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
75
99
|
it('close() is idempotent', async () => {
|
|
76
100
|
const s = new Session({ cwd: '/tmp', silent: true });
|
|
77
101
|
const onShutdown = vi.fn();
|