@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.
Files changed (156) hide show
  1. package/dist/events/log.d.ts.map +1 -1
  2. package/dist/events/log.js +38 -1
  3. package/dist/events/log.js.map +1 -1
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +1 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/permissions/engine.d.ts.map +1 -1
  9. package/dist/permissions/engine.js +80 -17
  10. package/dist/permissions/engine.js.map +1 -1
  11. package/dist/plugins/discovery.d.ts.map +1 -1
  12. package/dist/plugins/discovery.js +48 -7
  13. package/dist/plugins/discovery.js.map +1 -1
  14. package/dist/plugins/host.d.ts.map +1 -1
  15. package/dist/plugins/host.js +54 -11
  16. package/dist/plugins/host.js.map +1 -1
  17. package/dist/plugins/lifecycle.d.ts.map +1 -1
  18. package/dist/plugins/lifecycle.js +7 -0
  19. package/dist/plugins/lifecycle.js.map +1 -1
  20. package/dist/plugins/loader.d.ts +9 -0
  21. package/dist/plugins/loader.d.ts.map +1 -1
  22. package/dist/plugins/loader.js +34 -8
  23. package/dist/plugins/loader.js.map +1 -1
  24. package/dist/plugins/registry-kinds.d.ts +21 -2
  25. package/dist/plugins/registry-kinds.d.ts.map +1 -1
  26. package/dist/plugins/registry-kinds.js +3 -1
  27. package/dist/plugins/registry-kinds.js.map +1 -1
  28. package/dist/plugins/toposort.d.ts.map +1 -1
  29. package/dist/plugins/toposort.js +43 -18
  30. package/dist/plugins/toposort.js.map +1 -1
  31. package/dist/preferences.d.ts.map +1 -1
  32. package/dist/preferences.js +5 -4
  33. package/dist/preferences.js.map +1 -1
  34. package/dist/registries/active-backend-registry.d.ts.map +1 -1
  35. package/dist/registries/active-backend-registry.js +10 -1
  36. package/dist/registries/active-backend-registry.js.map +1 -1
  37. package/dist/registries/channels.d.ts +2 -1
  38. package/dist/registries/channels.d.ts.map +1 -1
  39. package/dist/registries/channels.js +30 -20
  40. package/dist/registries/channels.js.map +1 -1
  41. package/dist/registries/commands.d.ts.map +1 -1
  42. package/dist/registries/commands.js +27 -8
  43. package/dist/registries/commands.js.map +1 -1
  44. package/dist/registries/isolators.d.ts +27 -1
  45. package/dist/registries/isolators.d.ts.map +1 -1
  46. package/dist/registries/isolators.js +35 -1
  47. package/dist/registries/isolators.js.map +1 -1
  48. package/dist/registries/modes.d.ts +4 -0
  49. package/dist/registries/modes.d.ts.map +1 -1
  50. package/dist/registries/modes.js +15 -4
  51. package/dist/registries/modes.js.map +1 -1
  52. package/dist/registries/providers.d.ts +8 -0
  53. package/dist/registries/providers.d.ts.map +1 -1
  54. package/dist/registries/providers.js +8 -0
  55. package/dist/registries/providers.js.map +1 -1
  56. package/dist/registries/skills.d.ts +9 -0
  57. package/dist/registries/skills.d.ts.map +1 -1
  58. package/dist/registries/skills.js +22 -0
  59. package/dist/registries/skills.js.map +1 -1
  60. package/dist/requirements.d.ts.map +1 -1
  61. package/dist/requirements.js +7 -1
  62. package/dist/requirements.js.map +1 -1
  63. package/dist/run-turn.d.ts.map +1 -1
  64. package/dist/run-turn.js +65 -17
  65. package/dist/run-turn.js.map +1 -1
  66. package/dist/session.d.ts +9 -0
  67. package/dist/session.d.ts.map +1 -1
  68. package/dist/session.js +25 -3
  69. package/dist/session.js.map +1 -1
  70. package/dist/sessions/persistence.d.ts +15 -19
  71. package/dist/sessions/persistence.d.ts.map +1 -1
  72. package/dist/sessions/persistence.js +116 -49
  73. package/dist/sessions/persistence.js.map +1 -1
  74. package/dist/skills/loader.d.ts.map +1 -1
  75. package/dist/skills/loader.js +11 -2
  76. package/dist/skills/loader.js.map +1 -1
  77. package/dist/skills/synthesize.d.ts.map +1 -1
  78. package/dist/skills/synthesize.js +87 -36
  79. package/dist/skills/synthesize.js.map +1 -1
  80. package/dist/subagents/events.js +33 -2
  81. package/dist/subagents/events.js.map +1 -1
  82. package/dist/subagents/registry.d.ts +22 -1
  83. package/dist/subagents/registry.d.ts.map +1 -1
  84. package/dist/subagents/registry.js +72 -2
  85. package/dist/subagents/registry.js.map +1 -1
  86. package/dist/subagents/run-child.d.ts.map +1 -1
  87. package/dist/subagents/run-child.js +137 -46
  88. package/dist/subagents/run-child.js.map +1 -1
  89. package/dist/surfaces/host.d.ts +10 -0
  90. package/dist/surfaces/host.d.ts.map +1 -1
  91. package/dist/surfaces/host.js +41 -3
  92. package/dist/surfaces/host.js.map +1 -1
  93. package/dist/usage-stats.d.ts +7 -1
  94. package/dist/usage-stats.d.ts.map +1 -1
  95. package/dist/usage-stats.js +41 -15
  96. package/dist/usage-stats.js.map +1 -1
  97. package/dist/view/parse.d.ts.map +1 -1
  98. package/dist/view/parse.js +119 -19
  99. package/dist/view/parse.js.map +1 -1
  100. package/package.json +2 -2
  101. package/src/events/log.test.ts +45 -0
  102. package/src/events/log.ts +39 -1
  103. package/src/index.ts +0 -1
  104. package/src/permissions/engine.test.ts +60 -0
  105. package/src/permissions/engine.ts +81 -16
  106. package/src/plugins/discovery.test.ts +59 -1
  107. package/src/plugins/discovery.ts +57 -9
  108. package/src/plugins/host.test.ts +40 -0
  109. package/src/plugins/host.ts +48 -10
  110. package/src/plugins/lifecycle.test.ts +29 -0
  111. package/src/plugins/lifecycle.ts +7 -0
  112. package/src/plugins/loader.ts +45 -7
  113. package/src/plugins/registry-kinds.test.ts +53 -0
  114. package/src/plugins/registry-kinds.ts +25 -3
  115. package/src/plugins/toposort.ts +45 -17
  116. package/src/preferences.test.ts +52 -1
  117. package/src/preferences.ts +5 -4
  118. package/src/registries/active-backend-registry.ts +10 -1
  119. package/src/registries/channels.test.ts +79 -0
  120. package/src/registries/channels.ts +33 -18
  121. package/src/registries/commands.test.ts +65 -0
  122. package/src/registries/commands.ts +25 -8
  123. package/src/registries/isolators.test.ts +37 -0
  124. package/src/registries/isolators.ts +48 -1
  125. package/src/registries/modes.test.ts +21 -1
  126. package/src/registries/modes.ts +15 -2
  127. package/src/registries/providers.ts +8 -0
  128. package/src/registries/semantics.test.ts +33 -1
  129. package/src/registries/skills.ts +28 -0
  130. package/src/registries/transcribers.test.ts +13 -0
  131. package/src/requirements.test.ts +35 -0
  132. package/src/requirements.ts +7 -1
  133. package/src/run-turn.test.ts +219 -0
  134. package/src/run-turn.ts +68 -18
  135. package/src/session.test.ts +24 -0
  136. package/src/session.ts +24 -3
  137. package/src/sessions/page.test.ts +18 -39
  138. package/src/sessions/persistence.test.ts +93 -1
  139. package/src/sessions/persistence.ts +138 -68
  140. package/src/skills/loader.test.ts +18 -0
  141. package/src/skills/loader.ts +12 -1
  142. package/src/skills/synthesize.test.ts +59 -0
  143. package/src/skills/synthesize.ts +89 -36
  144. package/src/subagents/events.test.ts +56 -0
  145. package/src/subagents/events.ts +32 -2
  146. package/src/subagents/registry.test.ts +106 -0
  147. package/src/subagents/registry.ts +77 -3
  148. package/src/subagents/run-child.test.ts +163 -2
  149. package/src/subagents/run-child.ts +151 -47
  150. package/src/surfaces/host.test.ts +71 -1
  151. package/src/surfaces/host.ts +39 -2
  152. package/src/usage-stats.test.ts +99 -1
  153. package/src/usage-stats.ts +42 -17
  154. package/src/view/parse-extended.test.ts +80 -0
  155. package/src/view/parse.test.ts +14 -0
  156. 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
  });
@@ -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
- for (const fn of this.changeListeners) fn();
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
- for (const fn of this.changeListeners) fn();
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
  });
@@ -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
  });
@@ -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(
@@ -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
- if (requirement.version && target.version !== requirement.version) {
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',
@@ -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 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;
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 (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) {
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 {
@@ -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();