@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,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session persistence — appends each event in `session.log` to a
|
|
3
|
+
* per-session JSONL file under `~/.moxxy/sessions/`, and maintains an
|
|
4
|
+
* `index.json` of session metadata so `moxxy resume` can list and
|
|
5
|
+
* pick a prior session without scanning every event file.
|
|
6
|
+
*
|
|
7
|
+
* Layout:
|
|
8
|
+
* ~/.moxxy/sessions/
|
|
9
|
+
* <sessionId>.meta.json per-session metadata sidecar (one per session)
|
|
10
|
+
* <sessionId>.jsonl one MoxxyEvent per line
|
|
11
|
+
*
|
|
12
|
+
* Each session writes only its OWN `.meta.json` sidecar (write-temp-rename), so
|
|
13
|
+
* two concurrent moxxy processes can't drop each other's row the way a shared
|
|
14
|
+
* `index.json` read-modify-write would. `readIndex` assembles the sidecars (and
|
|
15
|
+
* a legacy `index.json`, if present, for back-compat). JSONL appends are
|
|
16
|
+
* best-effort (lose at most the last in-flight event on a crash).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { promises as fs } from 'node:fs';
|
|
20
|
+
import * as os from 'node:os';
|
|
21
|
+
import * as path from 'node:path';
|
|
22
|
+
import { createMutex, type Mutex, type MoxxyEvent, type SessionId } from '@moxxy/sdk';
|
|
23
|
+
import { writeFileAtomic } from '@moxxy/sdk/server';
|
|
24
|
+
import type { EventLog } from '../events/log.js';
|
|
25
|
+
import { createLogger, type Logger } from '../logger.js';
|
|
26
|
+
|
|
27
|
+
export interface SessionMeta {
|
|
28
|
+
readonly id: string;
|
|
29
|
+
readonly cwd: string;
|
|
30
|
+
readonly startedAt: string;
|
|
31
|
+
readonly lastActivity: string;
|
|
32
|
+
readonly eventCount: number;
|
|
33
|
+
/** First 80 chars of the first user_prompt. Used as the picker label. */
|
|
34
|
+
readonly firstPrompt: string | null;
|
|
35
|
+
readonly provider: string | null;
|
|
36
|
+
readonly model: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SessionPersistenceOpts {
|
|
40
|
+
readonly sessionId: SessionId;
|
|
41
|
+
readonly cwd: string;
|
|
42
|
+
/** Override the storage root. Defaults to `~/.moxxy/sessions`. */
|
|
43
|
+
readonly dir?: string;
|
|
44
|
+
/** Currently-active provider name — captured into the index for the picker. */
|
|
45
|
+
readonly providerName?: string;
|
|
46
|
+
/** Currently-active model id — captured into the index for the picker. */
|
|
47
|
+
readonly modelId?: string;
|
|
48
|
+
/**
|
|
49
|
+
* Structured logger for persistence-degradation warnings. Defaults to a
|
|
50
|
+
* stderr JSON logger — event-log write failures are the session's source
|
|
51
|
+
* of truth going dark, so they must be loud even when nothing injects one.
|
|
52
|
+
*/
|
|
53
|
+
readonly logger?: Logger;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function defaultSessionsDir(): string {
|
|
57
|
+
return path.join(os.homedir(), '.moxxy', 'sessions');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Attaches a listener that streams every appended event to disk and
|
|
62
|
+
* keeps the index in sync. Returns an `unsubscribe` callback the
|
|
63
|
+
* caller should run on shutdown.
|
|
64
|
+
*/
|
|
65
|
+
export class SessionPersistence {
|
|
66
|
+
private readonly dir: string;
|
|
67
|
+
private readonly id: string;
|
|
68
|
+
private readonly logPath: string;
|
|
69
|
+
private meta: SessionMeta;
|
|
70
|
+
private indexUpdateScheduled = false;
|
|
71
|
+
/** Handle for the in-flight debounce timer, so `flush()` can cancel it. */
|
|
72
|
+
private indexTimer: ReturnType<typeof setTimeout> | null = null;
|
|
73
|
+
/**
|
|
74
|
+
* In-flight writes are serialized through this mutex so the file
|
|
75
|
+
* stays append-ordered even when events arrive faster than the disk
|
|
76
|
+
* can flush.
|
|
77
|
+
*/
|
|
78
|
+
private writeQueue: Mutex = createMutex();
|
|
79
|
+
/**
|
|
80
|
+
* Memoized one-time setup: `mkdir -p` the sessions dir and create the empty
|
|
81
|
+
* `.jsonl` (so resume lists the session before any event). Awaited by both
|
|
82
|
+
* `attach` and `writeIndex` so an early debounced index flush can't race the
|
|
83
|
+
* initial creation — but it runs the open+close syscalls ONCE, not on every
|
|
84
|
+
* 250ms flush as before.
|
|
85
|
+
*/
|
|
86
|
+
private ready: Promise<void> | null = null;
|
|
87
|
+
private closed = false;
|
|
88
|
+
private readonly logger: Logger;
|
|
89
|
+
/**
|
|
90
|
+
* Latched while event-log writes are failing. Doubles as the warn-once
|
|
91
|
+
* gate: only the first failure of a streak logs; a subsequent successful
|
|
92
|
+
* write clears the latch (and re-arms the warning).
|
|
93
|
+
*/
|
|
94
|
+
private writeDegraded = false;
|
|
95
|
+
|
|
96
|
+
constructor(opts: SessionPersistenceOpts) {
|
|
97
|
+
this.dir = opts.dir ?? defaultSessionsDir();
|
|
98
|
+
this.id = String(opts.sessionId);
|
|
99
|
+
this.logger = opts.logger ?? createLogger();
|
|
100
|
+
this.logPath = path.join(this.dir, `${this.id}.jsonl`);
|
|
101
|
+
const now = new Date().toISOString();
|
|
102
|
+
this.meta = {
|
|
103
|
+
id: this.id,
|
|
104
|
+
cwd: opts.cwd,
|
|
105
|
+
startedAt: now,
|
|
106
|
+
lastActivity: now,
|
|
107
|
+
eventCount: 0,
|
|
108
|
+
firstPrompt: null,
|
|
109
|
+
provider: opts.providerName ?? null,
|
|
110
|
+
model: opts.modelId ?? null,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Subscribe to the log; returns the unsubscribe callback. The first
|
|
116
|
+
* call also writes the initial index row so `moxxy resume` lists
|
|
117
|
+
* the session before any events arrive.
|
|
118
|
+
*
|
|
119
|
+
* Also subscribes to the log's `clear()`, truncating the JSONL in
|
|
120
|
+
* lockstep so a `/new` wipe can't resurrect on `--resume`. The
|
|
121
|
+
* session keeps its id and file: post-reset events restart at seq 0
|
|
122
|
+
* in the same (now empty) JSONL, matching how the in-memory log
|
|
123
|
+
* reuses the same Session object.
|
|
124
|
+
*/
|
|
125
|
+
attach(log: EventLog): () => void {
|
|
126
|
+
void this.ensureReady()
|
|
127
|
+
.then(() => this.scheduleIndexWrite())
|
|
128
|
+
.catch(() => undefined);
|
|
129
|
+
const unsub = log.subscribe((event) => {
|
|
130
|
+
if (this.closed) return;
|
|
131
|
+
this.enqueueAppend(event);
|
|
132
|
+
});
|
|
133
|
+
const unsubClear = log.onClear(() => {
|
|
134
|
+
if (this.closed) return;
|
|
135
|
+
this.enqueueTruncate();
|
|
136
|
+
});
|
|
137
|
+
return () => {
|
|
138
|
+
this.closed = true;
|
|
139
|
+
unsub();
|
|
140
|
+
unsubClear();
|
|
141
|
+
// Schedule a final index write so lastActivity reflects the close time
|
|
142
|
+
// even if no events arrived in the last debounce window. This is the
|
|
143
|
+
// best-effort, fire-and-forget path; the debounce timer is `unref`'d, so
|
|
144
|
+
// a process that exits immediately after detach would drop it. Shutdown
|
|
145
|
+
// paths that MUST not lose the final row should `await persistence.flush()`
|
|
146
|
+
// instead — it bypasses the debounce and resolves once the row is on disk.
|
|
147
|
+
this.scheduleIndexWrite();
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Force the pending (debounced) index write to happen now and resolve once
|
|
153
|
+
* the meta sidecar is on disk. Cancels the in-flight debounce timer so the
|
|
154
|
+
* write isn't also re-run on its original schedule. Intended for awaitable
|
|
155
|
+
* shutdown: `detach()` only *schedules* the final write (an `unref`'d timer
|
|
156
|
+
* an immediate `process.exit` can drop), whereas `await flush()` guarantees
|
|
157
|
+
* it completed. The steady-state debounce in `scheduleIndexWrite` is
|
|
158
|
+
* untouched, so calling this mid-session just collapses the next write early.
|
|
159
|
+
*/
|
|
160
|
+
async flush(): Promise<void> {
|
|
161
|
+
if (this.indexTimer) {
|
|
162
|
+
clearTimeout(this.indexTimer);
|
|
163
|
+
this.indexTimer = null;
|
|
164
|
+
}
|
|
165
|
+
this.indexUpdateScheduled = false;
|
|
166
|
+
await this.writeIndex();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Resolve once every event-log write queued so far has settled. Appends are
|
|
171
|
+
* enqueued fire-and-forget (`enqueueAppend`), so callers that need to observe
|
|
172
|
+
* the on-disk result of prior appends — graceful shutdown, or a test that
|
|
173
|
+
* mutates the filesystem between writes — await this to drain the queue
|
|
174
|
+
* rather than guessing at timing. Enqueues a no-op at the tail of the same
|
|
175
|
+
* mutex, so it can only resolve after all earlier appends/truncates have run.
|
|
176
|
+
*/
|
|
177
|
+
async settleWrites(): Promise<void> {
|
|
178
|
+
await this.writeQueue.run(() => undefined);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Manually update header fields (provider/model) when the user
|
|
183
|
+
* switches mid-session. The /model picker calls this so the index
|
|
184
|
+
* reflects the active model when the session is resumed.
|
|
185
|
+
*/
|
|
186
|
+
updateHeader(patch: { providerName?: string; modelId?: string }): void {
|
|
187
|
+
this.meta = {
|
|
188
|
+
...this.meta,
|
|
189
|
+
provider: patch.providerName ?? this.meta.provider,
|
|
190
|
+
model: patch.modelId ?? this.meta.model,
|
|
191
|
+
};
|
|
192
|
+
this.scheduleIndexWrite();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private enqueueAppend(event: MoxxyEvent): void {
|
|
196
|
+
// Update in-memory meta synchronously so multiple events in the
|
|
197
|
+
// same tick share one debounced index write.
|
|
198
|
+
this.meta = {
|
|
199
|
+
...this.meta,
|
|
200
|
+
eventCount: this.meta.eventCount + 1,
|
|
201
|
+
lastActivity: new Date().toISOString(),
|
|
202
|
+
firstPrompt:
|
|
203
|
+
this.meta.firstPrompt ??
|
|
204
|
+
(event.type === 'user_prompt' ? event.text.slice(0, 80) : null),
|
|
205
|
+
};
|
|
206
|
+
this.scheduleIndexWrite();
|
|
207
|
+
const line = JSON.stringify(event) + '\n';
|
|
208
|
+
// Never propagate a write error into the listener chain — but never
|
|
209
|
+
// swallow it silently either: the JSONL is the session's source of
|
|
210
|
+
// truth, so a failing disk must at least be loud.
|
|
211
|
+
//
|
|
212
|
+
// Serialize the append behind `ensureReady()` (memoized mkdir -p + open):
|
|
213
|
+
// `fs.appendFile` with flag 'a' creates the FILE but not its parent dir, so
|
|
214
|
+
// a first event that arrives before `attach()`'s detached `ensureReady()`
|
|
215
|
+
// resolves on a machine without `~/.moxxy/sessions` would ENOENT, latch the
|
|
216
|
+
// misleading "persistence degraded" warning, and lose that event. Because
|
|
217
|
+
// `ready` is memoized, every later append pays nothing.
|
|
218
|
+
void this.writeQueue
|
|
219
|
+
.run(() => this.ensureReady().then(() => fs.appendFile(this.logPath, line, 'utf8')))
|
|
220
|
+
.then(() => this.noteWriteOk())
|
|
221
|
+
.catch((err: unknown) => this.noteWriteFailure('append', err));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* True while event-log writes are failing (history is no longer being
|
|
226
|
+
* persisted). Cleared automatically by the next successful write.
|
|
227
|
+
*/
|
|
228
|
+
get degraded(): boolean {
|
|
229
|
+
return this.writeDegraded;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private noteWriteOk(): void {
|
|
233
|
+
if (!this.writeDegraded) return;
|
|
234
|
+
this.writeDegraded = false;
|
|
235
|
+
this.logger.info('session event-log writes recovered', { path: this.logPath });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private noteWriteFailure(op: 'append' | 'truncate', err: unknown): void {
|
|
239
|
+
const alreadyDegraded = this.writeDegraded;
|
|
240
|
+
this.writeDegraded = true;
|
|
241
|
+
if (alreadyDegraded) return; // warn once per failure streak, not per event
|
|
242
|
+
this.logger.warn(
|
|
243
|
+
'session event-log write failed — history persistence degraded (resume will miss these events)',
|
|
244
|
+
{
|
|
245
|
+
op,
|
|
246
|
+
path: this.logPath,
|
|
247
|
+
sessionId: this.id,
|
|
248
|
+
error: err instanceof Error ? err.message : String(err),
|
|
249
|
+
},
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Mirror a `log.clear()` on disk: truncate the JSONL and reset the
|
|
255
|
+
* sidecar's counters. Rides the same write queue as appends, so
|
|
256
|
+
* pre-clear appends flush first and post-clear appends land in the
|
|
257
|
+
* fresh (empty) file — ordering matches the in-memory log exactly.
|
|
258
|
+
*/
|
|
259
|
+
private enqueueTruncate(): void {
|
|
260
|
+
this.meta = {
|
|
261
|
+
...this.meta,
|
|
262
|
+
eventCount: 0,
|
|
263
|
+
firstPrompt: null,
|
|
264
|
+
lastActivity: new Date().toISOString(),
|
|
265
|
+
};
|
|
266
|
+
this.scheduleIndexWrite();
|
|
267
|
+
void this.writeQueue
|
|
268
|
+
.run(() => this.ensureReady().then(() => fs.writeFile(this.logPath, '', 'utf8')))
|
|
269
|
+
.then(() => this.noteWriteOk())
|
|
270
|
+
.catch((err: unknown) => this.noteWriteFailure('truncate', err));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private scheduleIndexWrite(): void {
|
|
274
|
+
if (this.indexUpdateScheduled) return;
|
|
275
|
+
this.indexUpdateScheduled = true;
|
|
276
|
+
// 250ms debounce — fast enough that the picker stays current,
|
|
277
|
+
// slow enough that a chatty turn doesn't rewrite the index per
|
|
278
|
+
// assistant_chunk.
|
|
279
|
+
const timer = setTimeout(() => {
|
|
280
|
+
this.indexUpdateScheduled = false;
|
|
281
|
+
this.indexTimer = null;
|
|
282
|
+
void this.writeIndex();
|
|
283
|
+
}, 250);
|
|
284
|
+
timer.unref?.();
|
|
285
|
+
this.indexTimer = timer;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private async writeIndex(): Promise<void> {
|
|
289
|
+
try {
|
|
290
|
+
// Once closed (the final detach/flush write), don't re-create the dir or
|
|
291
|
+
// log file: setup already made them, and resurrecting a directory a
|
|
292
|
+
// concurrent `deleteSession`/teardown is removing would leave a stray
|
|
293
|
+
// sidecar behind. A live session awaits the (memoized) one-time setup so
|
|
294
|
+
// an early write can't race the initial creation — but this no longer
|
|
295
|
+
// re-runs the mkdir/open+close syscalls on every 250ms flush.
|
|
296
|
+
if (!this.closed) {
|
|
297
|
+
await this.ensureReady();
|
|
298
|
+
}
|
|
299
|
+
// Write ONLY this session's sidecar (`<id>.meta.json`), never a
|
|
300
|
+
// read-modify-write of a shared index.json — that loses rows when two
|
|
301
|
+
// moxxy processes update "their" row concurrently (each reads the index,
|
|
302
|
+
// re-adds only itself, and the last writer drops the other's row).
|
|
303
|
+
// `writeJsonAtomic` already `mkdir -p`s the sidecar's dir, so this owns no
|
|
304
|
+
// ensureLogFile — the `.jsonl` is the append path's responsibility.
|
|
305
|
+
await writeJsonAtomic(metaPath(this.dir, this.meta.id), this.meta);
|
|
306
|
+
} catch {
|
|
307
|
+
// Index write failures shouldn't bring down a session; the
|
|
308
|
+
// user can always re-resume by id from the filename.
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** One-time, memoized: `mkdir -p` the dir + create the empty `.jsonl`. On
|
|
313
|
+
* failure the latch is cleared so a later flush retries (matching the old
|
|
314
|
+
* per-write ensureDir/ensureLogFile recoverability). */
|
|
315
|
+
private ensureReady(): Promise<void> {
|
|
316
|
+
if (!this.ready) {
|
|
317
|
+
this.ready = (async () => {
|
|
318
|
+
await fs.mkdir(this.dir, { recursive: true });
|
|
319
|
+
const handle = await fs.open(this.logPath, 'a');
|
|
320
|
+
await handle.close();
|
|
321
|
+
})().catch((err) => {
|
|
322
|
+
this.ready = null;
|
|
323
|
+
throw err;
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
return this.ready;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Read the session index by assembling per-session sidecars (`<id>.meta.json`)
|
|
332
|
+
* plus a legacy `index.json` if one exists (sidecars win by id). Sessions whose
|
|
333
|
+
* `.jsonl` is missing are dropped. Sorted most-recent-activity first.
|
|
334
|
+
*/
|
|
335
|
+
export async function readIndex(dir = defaultSessionsDir()): Promise<SessionMeta[]> {
|
|
336
|
+
const byId = new Map<string, SessionMeta>();
|
|
337
|
+
|
|
338
|
+
// Legacy single-file index (pre-sidecar layout). Sidecars override it below.
|
|
339
|
+
try {
|
|
340
|
+
const raw = await fs.readFile(path.join(dir, 'index.json'), 'utf8');
|
|
341
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
342
|
+
if (Array.isArray(parsed)) {
|
|
343
|
+
for (const m of parsed.filter(isSessionMeta)) byId.set(m.id, m);
|
|
344
|
+
}
|
|
345
|
+
} catch {
|
|
346
|
+
// no legacy index — fine
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
let dirents: import('node:fs').Dirent[];
|
|
350
|
+
try {
|
|
351
|
+
dirents = await fs.readdir(dir, { withFileTypes: true });
|
|
352
|
+
} catch {
|
|
353
|
+
dirents = [];
|
|
354
|
+
}
|
|
355
|
+
await Promise.all(
|
|
356
|
+
dirents
|
|
357
|
+
.filter((d) => d.isFile() && d.name.endsWith('.meta.json'))
|
|
358
|
+
.map(async (d) => {
|
|
359
|
+
try {
|
|
360
|
+
const raw = await fs.readFile(path.join(dir, d.name), 'utf8');
|
|
361
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
362
|
+
if (isSessionMeta(parsed)) byId.set(parsed.id, parsed);
|
|
363
|
+
} catch {
|
|
364
|
+
// skip a malformed/half-written sidecar
|
|
365
|
+
}
|
|
366
|
+
}),
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
const metas = [...byId.values()];
|
|
370
|
+
const checks = await Promise.all(
|
|
371
|
+
metas.map(async (meta) => {
|
|
372
|
+
try {
|
|
373
|
+
await fs.access(path.join(dir, `${meta.id}.jsonl`));
|
|
374
|
+
return true;
|
|
375
|
+
} catch {
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
}),
|
|
379
|
+
);
|
|
380
|
+
return metas
|
|
381
|
+
.filter((_, index) => checks[index])
|
|
382
|
+
.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Restore a previously-persisted session's events. Returns the full
|
|
387
|
+
* event array suitable for passing into `new EventLog(events)`.
|
|
388
|
+
*
|
|
389
|
+
* Skips malformed lines (a single corrupted append shouldn't make the
|
|
390
|
+
* rest of the conversation unreadable) and then RE-SEQUENCES the
|
|
391
|
+
* survivors to contiguous `seq` 0..n-1, preserving order and ids. This
|
|
392
|
+
* matters twice over:
|
|
393
|
+
*
|
|
394
|
+
* - Mirror replay: `EventLog.ingest` accepts only `seq === length`, so
|
|
395
|
+
* a gap left by one corrupt middle line would silently truncate every
|
|
396
|
+
* attached client's history at that point.
|
|
397
|
+
* - Future appends: `EventLog.append` mints `seq = events.length`, so a
|
|
398
|
+
* gapped seed would mint colliding/out-of-order seqs.
|
|
399
|
+
*
|
|
400
|
+
* When anything was skipped or re-sequenced, the file is atomically
|
|
401
|
+
* rewritten with the repaired events so the NEXT resume starts clean —
|
|
402
|
+
* otherwise post-resume appends (seq = n-gap..) would interleave with the
|
|
403
|
+
* stale higher on-disk seqs forever. Safe ordering-wise: restore runs
|
|
404
|
+
* before `SessionPersistence.attach`, so no append queue is live yet.
|
|
405
|
+
* Note: compaction/elision events referencing seqs after a gap shift by
|
|
406
|
+
* the gap size — an accepted, logged trade-off versus losing all
|
|
407
|
+
* post-gap history.
|
|
408
|
+
*/
|
|
409
|
+
export async function restoreEvents(
|
|
410
|
+
sessionId: string,
|
|
411
|
+
dir = defaultSessionsDir(),
|
|
412
|
+
logger: Logger = createLogger(),
|
|
413
|
+
): Promise<MoxxyEvent[]> {
|
|
414
|
+
const logPath = path.join(dir, `${sessionId}.jsonl`);
|
|
415
|
+
let raw: string;
|
|
416
|
+
try {
|
|
417
|
+
raw = await fs.readFile(logPath, 'utf8');
|
|
418
|
+
} catch {
|
|
419
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
420
|
+
}
|
|
421
|
+
const events: MoxxyEvent[] = [];
|
|
422
|
+
let corruptLines = 0;
|
|
423
|
+
for (const line of raw.split('\n')) {
|
|
424
|
+
if (!line.trim()) continue;
|
|
425
|
+
try {
|
|
426
|
+
events.push(JSON.parse(line) as MoxxyEvent);
|
|
427
|
+
} catch {
|
|
428
|
+
corruptLines += 1;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Re-sequence to contiguous 0..n-1 (order + ids preserved). A clean log is
|
|
433
|
+
// already contiguous, so this is a no-op in the common case.
|
|
434
|
+
let resequenced = 0;
|
|
435
|
+
for (let i = 0; i < events.length; i += 1) {
|
|
436
|
+
if (events[i]!.seq !== i) {
|
|
437
|
+
events[i] = { ...events[i]!, seq: i } as MoxxyEvent;
|
|
438
|
+
resequenced += 1;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (corruptLines > 0 || resequenced > 0) {
|
|
443
|
+
logger.warn('session log restored with gaps — re-sequenced to keep full history replayable', {
|
|
444
|
+
sessionId,
|
|
445
|
+
path: logPath,
|
|
446
|
+
corruptLines,
|
|
447
|
+
resequencedEvents: resequenced,
|
|
448
|
+
restoredEvents: events.length,
|
|
449
|
+
});
|
|
450
|
+
try {
|
|
451
|
+
const repaired = events.map((e) => JSON.stringify(e) + '\n').join('');
|
|
452
|
+
await writeFileAtomic(logPath, repaired);
|
|
453
|
+
} catch (err) {
|
|
454
|
+
// Restore still succeeds — the in-memory log is repaired; only the
|
|
455
|
+
// next resume would re-run this same repair.
|
|
456
|
+
logger.warn('failed to rewrite repaired session log on disk', {
|
|
457
|
+
sessionId,
|
|
458
|
+
path: logPath,
|
|
459
|
+
error: err instanceof Error ? err.message : String(err),
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return events;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Remove a session's log file and its sidecar. A leftover legacy `index.json`
|
|
468
|
+
* row, if any, is harmless — `readIndex` filters out sessions whose `.jsonl` is
|
|
469
|
+
* gone, so the deleted session won't reappear.
|
|
470
|
+
*/
|
|
471
|
+
export async function deleteSession(
|
|
472
|
+
sessionId: string,
|
|
473
|
+
dir = defaultSessionsDir(),
|
|
474
|
+
): Promise<void> {
|
|
475
|
+
await fs.rm(path.join(dir, `${sessionId}.jsonl`), { force: true });
|
|
476
|
+
await fs.rm(metaPath(dir, sessionId), { force: true });
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Per-session metadata sidecar path. */
|
|
480
|
+
function metaPath(dir: string, id: string): string {
|
|
481
|
+
return path.join(dir, `${id}.meta.json`);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async function writeJsonAtomic(target: string, value: unknown): Promise<void> {
|
|
485
|
+
await writeFileAtomic(target, JSON.stringify(value, null, 2) + '\n');
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function isSessionMeta(v: unknown): v is SessionMeta {
|
|
489
|
+
if (!v || typeof v !== 'object') return false;
|
|
490
|
+
const m = v as Record<string, unknown>;
|
|
491
|
+
return (
|
|
492
|
+
typeof m.id === 'string' &&
|
|
493
|
+
typeof m.cwd === 'string' &&
|
|
494
|
+
typeof m.startedAt === 'string' &&
|
|
495
|
+
typeof m.lastActivity === 'string' &&
|
|
496
|
+
typeof m.eventCount === 'number'
|
|
497
|
+
);
|
|
498
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { definePlugin, defineProvider, defineTool, type LLMProvider } from '@moxxy/sdk';
|
|
4
|
+
|
|
5
|
+
import { setupAgent } from './setup-agent.js';
|
|
6
|
+
|
|
7
|
+
const tool = (name: string) =>
|
|
8
|
+
defineTool({ name, description: name, inputSchema: z.object({}), handler: () => 'ok' });
|
|
9
|
+
|
|
10
|
+
const modePlugin = definePlugin({ name: 'mode-x', version: '0' });
|
|
11
|
+
|
|
12
|
+
const providerPlugin = (provName: string) =>
|
|
13
|
+
definePlugin({
|
|
14
|
+
name: `prov-${provName}`,
|
|
15
|
+
version: '0',
|
|
16
|
+
providers: [
|
|
17
|
+
defineProvider({
|
|
18
|
+
name: provName,
|
|
19
|
+
models: [],
|
|
20
|
+
createClient: () => ({}) as unknown as LLMProvider,
|
|
21
|
+
validateKey: async () => ({ ok: true }),
|
|
22
|
+
}),
|
|
23
|
+
],
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('setupAgent', () => {
|
|
27
|
+
it('returns a destructurable Agent with the expected methods', () => {
|
|
28
|
+
const { session, ask, stream, collect, discover, use, addTool, removeTool, setProvider, setMode } =
|
|
29
|
+
setupAgent();
|
|
30
|
+
for (const fn of [ask, stream, collect, discover, use, addTool, removeTool, setProvider, setMode]) {
|
|
31
|
+
expect(typeof fn).toBe('function');
|
|
32
|
+
}
|
|
33
|
+
expect(session.cwd).toBeTruthy();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('registers tools from options and supports hot add/remove', () => {
|
|
37
|
+
const agent = setupAgent({ tools: [tool('a')] });
|
|
38
|
+
expect(agent.session.tools.has('a')).toBe(true);
|
|
39
|
+
agent.addTool(tool('b'));
|
|
40
|
+
expect(agent.session.tools.has('b')).toBe(true);
|
|
41
|
+
agent.removeTool('a');
|
|
42
|
+
expect(agent.session.tools.has('a')).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('merges an array of presets, de-duping a shared plugin (no double-register throw)', () => {
|
|
46
|
+
// Both presets bring the same mode plugin → it must register once, not throw.
|
|
47
|
+
expect(() =>
|
|
48
|
+
setupAgent([{ plugins: [modePlugin] }, { plugins: [modePlugin], tools: [tool('t')] }]),
|
|
49
|
+
).not.toThrow();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('activates the FIRST preset provider; the others stay registered to swap to', () => {
|
|
53
|
+
const agent = setupAgent([
|
|
54
|
+
{ plugins: [providerPlugin('openai')], provider: { name: 'openai' } },
|
|
55
|
+
{ plugins: [providerPlugin('anthropic')], provider: { name: 'anthropic' } },
|
|
56
|
+
]);
|
|
57
|
+
expect(agent.session.providers.getActiveName()).toBe('openai');
|
|
58
|
+
expect(
|
|
59
|
+
agent.session.providers
|
|
60
|
+
.list()
|
|
61
|
+
.map((d) => d.name)
|
|
62
|
+
.sort(),
|
|
63
|
+
).toEqual(['anthropic', 'openai']);
|
|
64
|
+
agent.setProvider('anthropic');
|
|
65
|
+
expect(agent.session.providers.getActiveName()).toBe('anthropic');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('hot-change methods are chainable (return the same agent)', () => {
|
|
69
|
+
const agent = setupAgent();
|
|
70
|
+
expect(agent.addTool(tool('x'))).toBe(agent);
|
|
71
|
+
});
|
|
72
|
+
});
|