mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Merge pull request #1 from boudra/refactor/agent-snapshot-removal
fix: remove stale raw field expectation from test
The 'raw' field was removed from timeline entries in commit e80989e
(Nov 28) to reduce payload sizes by 64-85%. This test was never updated
and has been broken since then. Remove the stale expectation to align
with the current implementation.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,17 @@ This is an npm workspace monorepo:
|
||||
- **packages/server**: Backend server (Express + WebSocket API)
|
||||
- **packages/app**: Cross-platform app (Expo - iOS, Android, Web)
|
||||
|
||||
## Environment overrides
|
||||
|
||||
- `PASEO_HOME` – path for runtime state such as `agents.json`. Defaults to `~/.paseo`; set this to a unique directory (e.g., `~/.paseo-blue`) when running a secondary server instance.
|
||||
- `PASEO_PORT` – preferred voice server + MCP port. Overrides `PORT` and defaults to `6767`. Use distinct ports (e.g., `7777`) for blue/green testing.
|
||||
|
||||
Example blue/green launch:
|
||||
|
||||
```
|
||||
PASEO_HOME=~/.paseo-blue PASEO_PORT=7777 npm run dev
|
||||
```
|
||||
|
||||
## Running and checking logs
|
||||
|
||||
We run the mobile app and server in the tmux session `voice-dev`:
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -43,6 +43,3 @@ coverage/
|
||||
# Misc
|
||||
*.pem
|
||||
.vercel
|
||||
|
||||
# Runtime agent data
|
||||
packages/server/agents.json
|
||||
|
||||
67
mcp-test-server.mjs
Normal file
67
mcp-test-server.mjs
Normal file
@@ -0,0 +1,67 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
const mcpServer = new McpServer({ name: 'test', version: '0.0.1' });
|
||||
|
||||
mcpServer.registerTool(
|
||||
'fast_tool',
|
||||
{
|
||||
title: 'Fast tool',
|
||||
description: 'Returns immediately',
|
||||
inputSchema: {},
|
||||
outputSchema: {
|
||||
echo: z.string()
|
||||
}
|
||||
},
|
||||
async () => {
|
||||
console.log('[server] fast_tool handler invoked');
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: { echo: 'hello' }
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
mcpServer.registerTool(
|
||||
'slow_tool',
|
||||
{
|
||||
title: 'Slow tool',
|
||||
description: 'Waits before responding',
|
||||
inputSchema: {
|
||||
waitMs: z.number()
|
||||
},
|
||||
outputSchema: {
|
||||
ok: z.boolean()
|
||||
}
|
||||
},
|
||||
async ({ waitMs }) => {
|
||||
console.log('[server] slow_tool handler invoked');
|
||||
await new Promise(resolve => setTimeout(resolve, waitMs));
|
||||
console.log('[server] slow_tool resolving');
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: { ok: true }
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => 'test-session' });
|
||||
await mcpServer.connect(transport);
|
||||
|
||||
const HOST = '127.0.0.1';
|
||||
const PORT = 6768;
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
if (req.url !== '/mcp') {
|
||||
res.statusCode = 404;
|
||||
res.end('not found');
|
||||
return;
|
||||
}
|
||||
await transport.handleRequest(req, res);
|
||||
});
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`Test MCP server listening on http://${HOST}:${PORT}`);
|
||||
});
|
||||
28
packages/app/review.md
Normal file
28
packages/app/review.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Android Voice Recording Fix – Code Review
|
||||
|
||||
## Correctness
|
||||
- Using Expo's recorder instance as the single source of truth is the right direction, but the new guard in `stop()` (`src/hooks/use-audio-recorder.native.ts:247-254`) now throws as soon as Expo's `isRecording` flag is `false`, even if **we** initiated a session (`recordingStartTime` is still set) and the OS stopped it for us (focus loss, phone call, permission revocation, etc.). In that situation we still have to call `stop()` to fetch the URI/work around the SDK bug, but the hook now exits early with “Not recording”, so the audio file is lost and UI state machines (e.g. dictation) never advance. Previously we relied on our own `isRecording` state, so callers could still finalize the recording even if Expo toggled its flag behind our back.
|
||||
- The dictation modal relies on the recorder object changing identity to re-run its cleanup effects. After memoizing the hook's return value, `dictationRecorder` no longer changes when recording starts or stops. When the user closes the modal while `dictationRecorder.start()` is still awaiting (permission prompt, slow prepare), the effect at `src/components/create-agent-modal.tsx:1164-1170` runs **once**, sees `isRecording?.()` as `false`, and returns. When the native recorder finally transitions to recording there is no dependency change, so we never auto-stop; the microphone keeps running in the background until unmount. The previous implementation re-rendered when `isRecording` state flipped, so the cleanup path executed. This is a regression introduced by the memoization/state removal.
|
||||
|
||||
## Edge Cases
|
||||
- Similar races exist anywhere we call `dictationRecorder.stop()` conditionally (`src/components/create-agent-modal.tsx:923-925`, `1172-1178`). Those guards now consult the asynchronous native flag; if the OS toggles it before we read it, we skip cleanup entirely (and `stop()` would throw even if we tried). We need a fallback based on our own intent (e.g., `recordingStartTime !== null`) to ensure we always clean up sessions we started.
|
||||
|
||||
## Performance
|
||||
- The metering effect (`src/hooks/use-audio-recorder.native.ts:175-193`) now depends on `recorderState.metering` *and* spins up a `setInterval`. `useAudioRecorderState` already polls every 100 ms and triggers a render. Because `recorderState.metering` is a dependency, this effect is torn down and recreated at that same cadence, so the interval almost never fires and we constantly allocate/clear timers. At best this starves the audio-level callback, at worst it's extra work on every render. We should either remove the interval and invoke the callback directly when `recorderState.metering` changes, or keep the interval but depend only on `recorderState.isRecording`/`configRef`.
|
||||
|
||||
## Code Quality
|
||||
- `recordingOptions` still depends on `config?.onAudioLevel` by reference (`src/hooks/use-audio-recorder.native.ts:141-164`). That means any caller that passes an inline callback will recreate the entire recorder on every render, reintroducing the race this fix aims to solve. The hook already captures the callback in a ref; the dependency only needs the boolean `!!config?.onAudioLevel` to flip metering on/off. Requiring every consumer to remember to wrap their callback in `useCallback` makes this API fragile and hard to use correctly.
|
||||
|
||||
## Completeness
|
||||
- Only two call sites were updated to stabilize the callback, but `useAudioRecorder` is exported for general use (`src/app/audio-test.tsx`, future flows). Unless we address the dependency noted above, any new screen that passes `onAudioLevel={level => …}` will regress immediately. Consider hardening the hook so callers cannot accidentally reintroduce the bug.
|
||||
|
||||
## Testing Recommendations
|
||||
- Start dictation, dismiss the modal (or navigate away) while the permission prompt is still showing, and verify that recording stops automatically once the modal is gone.
|
||||
- Force Android to interrupt recording (receive a phone call / unplug the mic) and ensure `stop()` still returns a blob instead of throwing “Not recording”.
|
||||
- Verify the audio level meter updates smoothly for a prolonged recording session (the current interval churn may cause it to freeze).
|
||||
- Confirm the new error-handling path actually resets `isDictating`, `isDictationProcessing`, and UI affordances after simulated failures (permission denied, `sendAgentAudio` rejection).
|
||||
|
||||
## Potential Improvements
|
||||
- Treat `!!config?.onAudioLevel` as the dependency in `recordingOptions` (and/or derive metering enablement internally) so callers no longer need to memoize callbacks.
|
||||
- Rework metering to emit directly from `useAudioRecorderState` updates (or keep a ref to `recorderState`), avoiding the redundant timer and ensuring the callback fires consistently.
|
||||
- Augment `stop()` to fall back to `recordingStartTime`/`recorder.uri` even if Expo's `isRecording` flag is already false so we can always deliver/clean up recordings we initiated.
|
||||
@@ -8,5 +8,12 @@ TTS_MODEL=tts-1
|
||||
# Available models: tts-1 (faster), tts-1-hd (higher quality)
|
||||
|
||||
# Server Configuration
|
||||
# Location for runtime state (agents.json, etc.). Defaults to ~/.paseo
|
||||
PASEO_HOME=~/.paseo
|
||||
|
||||
# Voice server port (overrides PORT). Defaults to 6767
|
||||
PASEO_PORT=6767
|
||||
|
||||
# Legacy web server port used by the legacy Express app
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
154
packages/server/AGENT_REFACTOR_PROPOSAL.md
Normal file
154
packages/server/AGENT_REFACTOR_PROPOSAL.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Agent Architecture Refactor Proposal
|
||||
|
||||
## Status (November 30, 2025)
|
||||
|
||||
- `ManagedAgent` is the single source of truth; the legacy `AgentSnapshot` type has been fully removed and no runtime APIs expose it.
|
||||
- The UI, MCP server, and persistence flows now project from `ManagedAgent` via `toAgentPayload`/`serializeAgentSnapshot` and `toStoredAgentRecord`.
|
||||
- `AgentSnapshotPayload` remains as the wire schema for clients, but it is now exclusively derived from `ManagedAgent` projections and never mutated manually.
|
||||
- Tasks 1–6 of the refactor are complete; the notes below are kept for historical context and future audits.
|
||||
|
||||
> The remaining sections describe the legacy state and the design rationale that guided the refactor. They are preserved so future contributors understand why the cleanup happened.
|
||||
|
||||
## Legacy State Analysis (Pre-Refactor)
|
||||
|
||||
### AgentSnapshot Usage
|
||||
|
||||
| Location | Purpose |
|
||||
| --- | --- |
|
||||
| `src/server/agent/agent-manager.ts:231-247` | `getAgent` / `listAgents` expose `AgentSnapshot` copies generated via `toSnapshot`. |
|
||||
| `src/server/session.ts:534-577, 1130-1145` | Session broadcasts snapshots to clients (`forwardAgentState`, `buildAgentPayload`). |
|
||||
| `src/server/agent/agent-registry.ts:138-185` | `applySnapshot` is driven by snapshots (via `attachAgentRegistryPersistence`). |
|
||||
| `src/server/persistence-hooks.ts:23-35` | Persistence hook subscribes to `agent_state` and forwards snapshots. |
|
||||
| `src/server/agent/mcp-server.ts:12-452` | MCP endpoints serialize snapshots for diagnostics/listing. |
|
||||
| `src/server/messages.ts:19-338` | `AgentSnapshotPayload` and `serializeAgentSnapshot` expect snapshots. |
|
||||
| Tests (`src/server/persistence-hooks.test.ts`, `src/server/agent/agent-registry.test.ts`) | Fabricate snapshots as fixtures. |
|
||||
|
||||
### Impacted Areas
|
||||
|
||||
- `AgentManager` snapshot creation & event emission.
|
||||
- Session serialization (`buildAgentPayload`, `forwardAgentState`).
|
||||
- Registry persistence (`applySnapshot`, tests).
|
||||
- MCP server responses.
|
||||
- Messaging schema & helper utilities.
|
||||
|
||||
### Why AgentSnapshot Existed
|
||||
|
||||
It attempted to provide a JSON-friendly view stripped of internal handles (e.g., `AgentSession`, `pendingRun`). In practice it duplicates the data model, omits config (requiring `recordConfig`), and forces redundant copies. All downstream consumers just serialize the snapshot immediately, so it adds complexity without real protection.
|
||||
|
||||
## Implemented Design
|
||||
|
||||
### Single Source of Truth
|
||||
|
||||
Introduce a discriminated union for `ManagedAgent` that encodes lifecycle-specific invariants:
|
||||
|
||||
```ts
|
||||
type ManagedAgent =
|
||||
| { lifecycle: "initializing"; pendingRun: null; /* ... */ }
|
||||
| { lifecycle: "running"; pendingRun: AsyncGenerator<AgentStreamEvent>; /* ... */ }
|
||||
| { lifecycle: "idle"; pendingRun: null; /* ... */ }
|
||||
| { lifecycle: "error"; pendingRun: null; lastError: string; /* ... */ }
|
||||
| { lifecycle: "closed"; session: null; pendingRun: null; /* ... */ };
|
||||
```
|
||||
|
||||
Fields include the live `AgentSession`, normalized config (`AgentSessionConfig`), timelines, permissions map, timestamps, persistence handles, etc. Impossible states (e.g., `pendingRun` non-null while lifecycle `"idle"`) become unrepresentable.
|
||||
|
||||
### Pure Transformation Functions
|
||||
|
||||
1. **Persistence Projection**
|
||||
```ts
|
||||
function toStoredAgentRecord(agent: ManagedAgent, options?: { title?: string | null }): StoredAgentRecord
|
||||
```
|
||||
Copies provider, cwd, ISO timestamps, lifecycle status, `lastModeId`, complete config (`modeId`, `model`, `extra`), persistence handle, title metadata.
|
||||
|
||||
2. **Client Payload Projection**
|
||||
```ts
|
||||
function toAgentPayload(agent: ManagedAgent, options?: { title?: string | null }): AgentSnapshotPayload
|
||||
```
|
||||
Converts dates to ISO strings, normalizes pending permissions map to an array, includes safe config/model fields for UI, hides `AgentSession` reference.
|
||||
|
||||
3. **Additional helpers**
|
||||
Reuse the same projections for MCP responses, diagnostics, etc. The functions are deterministic and easy to test.
|
||||
|
||||
### Unified Persistence Flow
|
||||
|
||||
- Remove `AgentSnapshot` and `recordConfig`.
|
||||
- `AgentManager.subscribe` emits `ManagedAgent` references (or read-only copies) on `agent_state`.
|
||||
- `attachAgentRegistryPersistence` now calls `toStoredAgentRecord` and writes the result. This single path handles both lifecycle and config data atomically.
|
||||
- Tests rely on the pure projection functions instead of crafting ad-hoc snapshots.
|
||||
|
||||
### Lazy Initialization Strategy
|
||||
|
||||
- `restorePersistedAgents` still reads `StoredAgentRecord` entries with full config populated by the projection.
|
||||
- Lazy `ensureAgentLoaded` uses the stored record to resume the agent on demand; no special-case status bootstrapping required.
|
||||
- When an agent is resumed/created, `AgentManager` emits `agent_state` events with the live `ManagedAgent`, ensuring persistence is updated immediately before any other code touches the registry.
|
||||
|
||||
### Client Communication
|
||||
|
||||
- Session and MCP server call `toAgentPayload` before emitting events.
|
||||
- `AgentSnapshotPayload` remains the wire-format schema, but it’s derived directly from `ManagedAgent`.
|
||||
- Clients continue to receive the same data shape (with potential additions, e.g., config info) without intermediate snapshot objects.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Mitigation |
|
||||
| --- | --- |
|
||||
| All consumer APIs expect `AgentSnapshot`. | Update type signatures and provide pure projection helpers; TypeScript will flag missing updates. |
|
||||
| Accidentally exposing mutable internal state. | Projections must deep-clone arrays/maps; optionally expose read-only `ManagedAgentView` wrappers. |
|
||||
| Persistence logic mistakes. | Add dedicated unit tests for `toStoredAgentRecord`; compare outputs with existing fixtures. |
|
||||
| Client payload regressions. | Snapshot serialization tests (`serializeAgentSnapshot`, session tests) must be updated to use the new helper. |
|
||||
| Integration behavior (lazy init/status updates). | Manual QA and e2e tests verifying “open agent after restart” scenarios. |
|
||||
|
||||
### Test Coverage
|
||||
|
||||
- Existing registry + persistence-hook tests already exercise serialization; they must be ported to the new helpers.
|
||||
- Need new tests for `toStoredAgentRecord` and `toAgentPayload` to validate field-level correctness.
|
||||
- Re-run session/MCP/e2e tests to ensure agent cards, timelines, and lazy loading still function.
|
||||
|
||||
### Migration Strategy
|
||||
|
||||
- Breaking change is acceptable (server + client change together). `agents.json` schema stays identical; only the producer path changes.
|
||||
- Local migrations not required; new code overwrites entries with full config automatically.
|
||||
|
||||
## Implementation Debrief
|
||||
|
||||
The checklist below mirrors the steps that were executed during the refactor and remains here for traceability.
|
||||
|
||||
1. **Introduce transformation helpers & tests**
|
||||
- Implement `toStoredAgentRecord` and `toAgentPayload`.
|
||||
- Add unit tests covering all lifecycle variants and edge cases (pending permissions map, optional fields).
|
||||
|
||||
2. **Refactor AgentManager emissions**
|
||||
- Remove `AgentSnapshot` type and `toSnapshot`.
|
||||
- Update `subscribe`, `getAgent`, `listAgents`, and event dispatchers to pass `ManagedAgent`.
|
||||
- Ensure consumers cannot mutate returned references (clone or freeze if necessary).
|
||||
|
||||
3. **Update consumers**
|
||||
- **Registry:** `applySnapshot` now accepts `ManagedAgent` and uses `toStoredAgentRecord`. Delete `recordConfig`.
|
||||
- **Session/MCP:** call `toAgentPayload` when broadcasting to clients; update serialized types.
|
||||
- **Messages schema:** keep `AgentSnapshotPayload` but note it’s derived from `ManagedAgent`.
|
||||
|
||||
4. **Cleanup**
|
||||
- Remove `AgentSnapshot` definitions, serialization helpers, and obsolete code paths/tests.
|
||||
- Ensure `AgentRegistry` no longer imports `AgentSnapshot`.
|
||||
|
||||
5. **Validation**
|
||||
- Run `npm run test agent-registry`, persistence hook tests, session tests.
|
||||
- Manual QA: create agent → verify `agents.json` includes status & config; restart server → open agent → ensure UI shows “idle” immediately.
|
||||
|
||||
## Validation Plan
|
||||
|
||||
- **Unit Tests**
|
||||
- `toStoredAgentRecord` and `toAgentPayload` coverage (all lifecycle states, optional fields, config propagation).
|
||||
- Update registry/persistence tests to use `ManagedAgent` fixtures.
|
||||
|
||||
- **Integration Tests**
|
||||
- Session → client agent list (`SessionContext` expectations) to ensure payload format.
|
||||
- Lazy init/resume scenario (open agent after restart without manual refresh).
|
||||
- MCP “list agents” command returning full info.
|
||||
|
||||
- **Manual Verification**
|
||||
- Create agents with various configs/modes; inspect `agents.json`.
|
||||
- Restart server; verify UI cards show accurate status and accept prompts immediately.
|
||||
- High-load scenario: multiple agents running concurrently to ensure no races in persistence.
|
||||
|
||||
This refactor removes redundant abstractions, consolidates persistence into deterministic pure functions, and keeps `ManagedAgent` as the authoritative state—from which every other representation (disk, UI, MCP) is derived.***
|
||||
@@ -75,10 +75,18 @@ DEEPGRAM_API_KEY=... # Streaming STT
|
||||
STT_MODEL=whisper-1 # Optional: override to gpt-4o-transcribe, etc.
|
||||
STT_CONFIDENCE_THRESHOLD=-3.0 # Optional: reject low-confidence clips
|
||||
STT_DEBUG_AUDIO_DIR=.stt-debug # Optional: persist raw dictation audio for debugging
|
||||
PASEO_HOME=~/.paseo # Runtime state directory (agents.json, etc.)
|
||||
PASEO_PORT=6767 # Voice server + MCP port (falls back to 6767)
|
||||
PORT=3000 # Server port
|
||||
NODE_ENV=development # Environment
|
||||
```
|
||||
|
||||
`PASEO_HOME` defaults to `~/.paseo` and isolates runtime artifacts like `agents.json`. `PASEO_PORT` takes precedence over `PORT` and controls both the HTTP server and MCP endpoint. For blue/green testing you can run a parallel server without touching production state:
|
||||
|
||||
```bash
|
||||
PASEO_HOME=~/.paseo-blue PASEO_PORT=7777 npm run dev
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Server**: Express, TypeScript, ws (WebSocket)
|
||||
|
||||
812
packages/server/agent-mcp-review.md
Normal file
812
packages/server/agent-mcp-review.md
Normal file
@@ -0,0 +1,812 @@
|
||||
# Agent Control MCP Implementation Review
|
||||
|
||||
## Executive Summary
|
||||
|
||||
After thorough analysis of the agent control MCP implementation, I've identified several critical issues and race conditions that could cause hangs. The proposed changes are sound in principle but need careful implementation to avoid introducing new bugs. This review covers:
|
||||
|
||||
1. Current `waitForAgentEvent` implementation analysis
|
||||
2. Signal/abort handling race conditions
|
||||
3. Timeline/activity structure and message extraction
|
||||
4. Recommendations for implementing the proposed changes
|
||||
5. Identified bugs in current wait logic
|
||||
|
||||
---
|
||||
|
||||
## 1. Current `waitForAgentEvent` Implementation Analysis
|
||||
|
||||
### Location
|
||||
`/home/moboudra/dev/voice-dev/packages/server/src/server/agent/agent-manager.ts:494-591`
|
||||
|
||||
### Current Behavior
|
||||
|
||||
The implementation is **fundamentally correct** but has subtle race conditions:
|
||||
|
||||
```typescript
|
||||
async waitForAgentEvent(agentId: string, options?: WaitForAgentOptions): Promise<WaitForAgentResult> {
|
||||
const snapshot = this.getAgent(agentId);
|
||||
if (!snapshot) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
// Early return for pending permission
|
||||
const immediatePermission = snapshot.pendingPermissions[0] ?? null;
|
||||
if (immediatePermission) {
|
||||
return { status: snapshot.status, permission: immediatePermission };
|
||||
}
|
||||
|
||||
// Early return if not busy
|
||||
if (!isAgentBusy(snapshot.status)) {
|
||||
return { status: snapshot.status, permission: null };
|
||||
}
|
||||
|
||||
// ... wait logic
|
||||
}
|
||||
```
|
||||
|
||||
### Critical Race Condition #1: Time-of-Check-Time-of-Use (TOCTOU)
|
||||
|
||||
**Problem:** Between checking `isAgentBusy(snapshot.status)` and setting up the subscription, the agent could transition to idle. This creates a window where:
|
||||
|
||||
1. Line 508: Check shows `status === "running"`
|
||||
2. Agent completes before line 536
|
||||
3. Line 536-576: Subscribe with `replayState: true`
|
||||
4. Subscription receives stale "running" state
|
||||
5. Wait never resolves because completion event already fired
|
||||
|
||||
**Evidence:** The `replayState: true` parameter (line 575) partially mitigates this but doesn't eliminate the race:
|
||||
- If completion happens BEFORE subscription, the replayed state will be "idle" and we return immediately ✅
|
||||
- If completion happens DURING subscription setup, we might miss the state change ❌
|
||||
|
||||
### Critical Race Condition #2: Event Order Dependencies
|
||||
|
||||
The implementation relies on event ordering:
|
||||
|
||||
```typescript
|
||||
switch (event.event.type) {
|
||||
case "permission_requested": {
|
||||
currentStatus = "running";
|
||||
finish(event.request);
|
||||
break;
|
||||
}
|
||||
case "turn_completed": {
|
||||
currentStatus = "idle";
|
||||
finish(null);
|
||||
break;
|
||||
}
|
||||
case "turn_failed": {
|
||||
currentStatus = "error";
|
||||
finish(null);
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Problem:** The code handles both `agent_state` events (lines 538-549) and `agent_stream` events (lines 551-573). When an agent completes:
|
||||
1. Stream events fire: `turn_completed`
|
||||
2. State events fire: `agent_state` with `status: "idle"`
|
||||
|
||||
Depending on timing, we might receive them in either order or have duplicate processing.
|
||||
|
||||
---
|
||||
|
||||
## 2. Signal/Abort Handling Analysis
|
||||
|
||||
### Implementation (lines 579-589)
|
||||
|
||||
```typescript
|
||||
if (options?.signal) {
|
||||
const abortHandler = () => {
|
||||
cleanup();
|
||||
reject(createAbortError(options.signal, "wait_for_agent aborted"));
|
||||
};
|
||||
|
||||
options.signal.addEventListener("abort", abortHandler, { once: true });
|
||||
cleanupFns.push(() =>
|
||||
options.signal?.removeEventListener("abort", abortHandler)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Issue #1: Missing Initial Abort Check
|
||||
|
||||
**Bug:** The code doesn't check if `signal.aborted` is already true before adding the listener.
|
||||
|
||||
**Fix needed:**
|
||||
```typescript
|
||||
if (options?.signal) {
|
||||
if (options.signal.aborted) {
|
||||
throw createAbortError(options.signal, "wait_for_agent aborted");
|
||||
}
|
||||
// ... add listener
|
||||
}
|
||||
```
|
||||
|
||||
Wait, actually there IS a check on line 512-514:
|
||||
```typescript
|
||||
if (options?.signal?.aborted) {
|
||||
throw createAbortError(options.signal, "wait_for_agent aborted");
|
||||
}
|
||||
```
|
||||
|
||||
**But this is BEFORE the Promise, so there's still a race!** If the signal aborts between line 514 and line 579, the listener is never added and the promise hangs forever.
|
||||
|
||||
### Issue #2: MCP Server Signal Forwarding Complexity
|
||||
|
||||
In `mcp-server.ts:251-310`, the `wait_for_agent` handler creates its own AbortController and forwards signals:
|
||||
|
||||
```typescript
|
||||
const abortController = new AbortController();
|
||||
|
||||
const forwardExternalAbort = () => {
|
||||
if (!abortController.signal.aborted) {
|
||||
const reason = signal?.reason ?? new Error("wait_for_agent aborted");
|
||||
abortController.abort(reason);
|
||||
}
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
forwardExternalAbort();
|
||||
} else {
|
||||
signal.addEventListener("abort", forwardExternalAbort, { once: true });
|
||||
cleanupFns.push(() =>
|
||||
signal.removeEventListener("abort", forwardExternalAbort)
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Analysis:** This is actually well-designed! The MCP layer:
|
||||
1. Creates its own AbortController
|
||||
2. Forwards external abort to internal signal
|
||||
3. Also registers with waitTracker for explicit cancellation
|
||||
4. Properly cleans up all listeners
|
||||
|
||||
The issue is that `AgentManager.waitForAgentEvent` doesn't do the same pre-check inside the Promise constructor.
|
||||
|
||||
---
|
||||
|
||||
## 3. Timeline/Activity Structure Review
|
||||
|
||||
### Timeline Item Types
|
||||
|
||||
From `agent-sdk-types.ts:51-68`:
|
||||
```typescript
|
||||
export type AgentTimelineItem =
|
||||
| { type: "user_message"; text: string; messageId?: string }
|
||||
| { type: "assistant_message"; text: string }
|
||||
| { type: "reasoning"; text: string }
|
||||
| { type: "tool_call"; /* ... */ }
|
||||
| { type: "todo"; items: { text: string; completed: boolean }[] }
|
||||
| { type: "error"; message: string };
|
||||
```
|
||||
|
||||
### Extracting Last Assistant Message
|
||||
|
||||
The `runAgent` method (lines 321-351) already implements this correctly:
|
||||
|
||||
```typescript
|
||||
for await (const event of events) {
|
||||
if (event.type === "timeline") {
|
||||
timeline.push(event.item);
|
||||
if (event.item.type === "assistant_message") {
|
||||
finalText = event.item.text; // ← Last message wins
|
||||
}
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Key insight:** Multiple `assistant_message` items can exist in a single turn (streaming reassembly). The last one contains the complete message.
|
||||
|
||||
### Current Activity Structure
|
||||
|
||||
The `buildActivityPayload` function (lines 99-118) returns:
|
||||
```typescript
|
||||
{
|
||||
format: "curated",
|
||||
updateCount: number,
|
||||
currentModeId: string | null,
|
||||
content: string // ← Curated summary via curateAgentActivity()
|
||||
}
|
||||
```
|
||||
|
||||
**Problem:** The curated content mixes user messages, assistant messages, tool calls, reasoning, and todos. Extracting just the last assistant message from this string is error-prone.
|
||||
|
||||
---
|
||||
|
||||
## 4. Proposed Changes Evaluation
|
||||
|
||||
### Proposal Summary
|
||||
|
||||
1. Make `waitForAgentEvent` return last assistant message text (not full activity)
|
||||
2. Add `background` flag (default `false`) to `create_coding_agent` and `send_agent_prompt`
|
||||
3. When `background=false`, wait for completion and return last message
|
||||
4. All three tools share same wait code path
|
||||
|
||||
### Evaluation
|
||||
|
||||
#### ✅ **Proposal 1: Return Last Assistant Message**
|
||||
|
||||
**Good:** Simpler, more focused API. Clients can always call `get_agent_activity` if they need full context.
|
||||
|
||||
**Implementation approach:**
|
||||
```typescript
|
||||
// In waitForAgentEvent
|
||||
async waitForAgentEvent(
|
||||
agentId: string,
|
||||
options?: WaitForAgentOptions
|
||||
): Promise<WaitForAgentResult & { lastMessage: string | null }> {
|
||||
// ... existing wait logic ...
|
||||
|
||||
// After waiting completes, extract last message
|
||||
const timeline = this.getTimeline(agentId);
|
||||
let lastMessage: string | null = null;
|
||||
|
||||
// Iterate backward to find most recent assistant_message
|
||||
for (let i = timeline.length - 1; i >= 0; i--) {
|
||||
const item = timeline[i];
|
||||
if (item.type === "assistant_message") {
|
||||
lastMessage = item.text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: currentStatus,
|
||||
permission: permissionOrNull,
|
||||
lastMessage,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Concern:** If the turn was interrupted or failed, there might be no assistant message. Callers need to handle `null`.
|
||||
|
||||
#### ⚠️ **Proposal 2-3: Add `background` Flag**
|
||||
|
||||
**Concerns:**
|
||||
|
||||
1. **API Confusion:** Having two modes (blocking vs non-blocking) in the same tool is confusing. MCP best practices suggest separate tools for different behaviors.
|
||||
|
||||
2. **Timeout Handling:** What if the agent runs for 10 minutes? The MCP call will timeout. Need explicit timeout parameter.
|
||||
|
||||
3. **Breaking Change:** Default `false` means all existing callers will suddenly block instead of returning immediately.
|
||||
|
||||
**Alternative Design:**
|
||||
|
||||
Keep existing tools as non-blocking (current behavior), add new tools for blocking:
|
||||
|
||||
- `create_coding_agent` → remains non-blocking
|
||||
- `create_coding_agent_and_wait` → new blocking variant
|
||||
- `send_agent_prompt` → remains non-blocking
|
||||
- `send_agent_prompt_and_wait` → new blocking variant
|
||||
|
||||
This is more explicit and backward-compatible.
|
||||
|
||||
#### ✅ **Proposal 4: Shared Wait Code Path**
|
||||
|
||||
**Good:** DRY principle, easier to maintain.
|
||||
|
||||
**Implementation:**
|
||||
```typescript
|
||||
// Extract common logic
|
||||
private async waitForCompletion(
|
||||
agentId: string,
|
||||
options?: { signal?: AbortSignal; timeout?: number }
|
||||
): Promise<{ status: AgentLifecycleStatus; lastMessage: string | null }> {
|
||||
const result = await this.waitForAgentEvent(agentId, {
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
if (result.permission) {
|
||||
throw new Error(
|
||||
`Agent ${agentId} is blocked on permission request: ${result.permission.title ?? result.permission.name}`
|
||||
);
|
||||
}
|
||||
|
||||
// Extract last message
|
||||
const timeline = this.getTimeline(agentId);
|
||||
let lastMessage: string | null = null;
|
||||
for (let i = timeline.length - 1; i >= 0; i--) {
|
||||
if (timeline[i].type === "assistant_message") {
|
||||
lastMessage = (timeline[i] as { text: string }).text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { status: result.status, lastMessage };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Identified Bugs in Current Wait Logic
|
||||
|
||||
### Bug #1: Missing Abort Check Inside Promise
|
||||
|
||||
**File:** `agent-manager.ts:516`
|
||||
|
||||
**Problem:**
|
||||
```typescript
|
||||
return await new Promise<WaitForAgentResult>((resolve, reject) => {
|
||||
// Signal could abort here ← RACE!
|
||||
let currentStatus: AgentLifecycleStatus = snapshot.status;
|
||||
// ...
|
||||
|
||||
// Listener added much later (line 579)
|
||||
if (options?.signal) {
|
||||
options.signal.addEventListener("abort", abortHandler, { once: true });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Fix:**
|
||||
```typescript
|
||||
return await new Promise<WaitForAgentResult>((resolve, reject) => {
|
||||
// Check immediately inside Promise constructor
|
||||
if (options?.signal?.aborted) {
|
||||
reject(createAbortError(options.signal, "wait_for_agent aborted"));
|
||||
return;
|
||||
}
|
||||
|
||||
// ... rest of logic
|
||||
});
|
||||
```
|
||||
|
||||
### Bug #2: Duplicate Event Processing
|
||||
|
||||
**File:** `agent-manager.ts:536-573`
|
||||
|
||||
**Problem:** When a turn completes, both `agent_state` and `agent_stream` events fire. The code processes both:
|
||||
|
||||
```typescript
|
||||
this.subscribe((event) => {
|
||||
if (event.type === "agent_state") {
|
||||
currentStatus = event.agent.status;
|
||||
const pending = event.agent.pendingPermissions[0] ?? null;
|
||||
if (pending) {
|
||||
finish(pending); // ← Can finish here
|
||||
return;
|
||||
}
|
||||
if (!isAgentBusy(event.agent.status)) {
|
||||
finish(null); // ← Or here
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type !== "agent_stream") {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.event.type) {
|
||||
case "turn_completed": {
|
||||
currentStatus = "idle";
|
||||
finish(null); // ← Or here (duplicate!)
|
||||
break;
|
||||
}
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Result:** `finish()` gets called multiple times, but because it sets `finalized = true`, only the first call matters. Subsequent calls are no-ops.
|
||||
|
||||
**Assessment:** Not actually a bug due to the guard, but inefficient and confusing. Should skip one of the code paths.
|
||||
|
||||
**Fix:** Remove redundant stream event handling:
|
||||
```typescript
|
||||
this.subscribe((event) => {
|
||||
if (event.type === "agent_state") {
|
||||
currentStatus = event.agent.status;
|
||||
const pending = event.agent.pendingPermissions[0] ?? null;
|
||||
if (pending) {
|
||||
finish(pending);
|
||||
return;
|
||||
}
|
||||
if (!isAgentBusy(event.agent.status)) {
|
||||
finish(null);
|
||||
}
|
||||
}
|
||||
// Remove agent_stream handling - agent_state is sufficient
|
||||
}, { agentId, replayState: true });
|
||||
```
|
||||
|
||||
### Bug #3: Cleanup Race on Fast Completion
|
||||
|
||||
**File:** `agent-manager.ts:520-534`
|
||||
|
||||
**Problem:** If the agent completes instantly (e.g., already idle when we subscribe), the `finish()` call happens synchronously inside the `subscribe()` callback, but cleanup functions haven't been registered yet!
|
||||
|
||||
```typescript
|
||||
const unsubscribe = this.subscribe((event) => {
|
||||
// Agent is already idle, event fires IMMEDIATELY
|
||||
if (!isAgentBusy(event.agent.status)) {
|
||||
finish(null); // ← Calls cleanup() but cleanupFns is empty!
|
||||
}
|
||||
}, { agentId, replayState: true });
|
||||
cleanupFns.push(unsubscribe); // ← Added AFTER callback fires
|
||||
```
|
||||
|
||||
**Result:** Subscription is never cleaned up, causing memory leak.
|
||||
|
||||
**Fix:** Add cleanup function before subscribing:
|
||||
```typescript
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
const cleanup = () => {
|
||||
while (cleanupFns.length) {
|
||||
const fn = cleanupFns.pop();
|
||||
try { fn?.(); } catch {}
|
||||
}
|
||||
if (unsubscribe) {
|
||||
try { unsubscribe(); } catch {}
|
||||
}
|
||||
};
|
||||
|
||||
unsubscribe = this.subscribe((event) => {
|
||||
// ... handler
|
||||
}, { agentId, replayState: true });
|
||||
|
||||
cleanupFns.push(unsubscribe);
|
||||
```
|
||||
|
||||
Actually, looking more carefully, the code already handles this correctly with the `while` loop. Let me re-read...
|
||||
|
||||
Actually no - the issue is that `cleanupFns.push(unsubscribe)` happens on line 577, but if the subscription callback fires synchronously during line 536, it calls `finish()` on line 542/546, which calls `cleanup()` on line 532, which pops from `cleanupFns` on line 521-527... but `unsubscribe` was never pushed!
|
||||
|
||||
This is a **real bug**.
|
||||
|
||||
### Bug #4: WaitTracker Cleanup on Agent Close
|
||||
|
||||
**File:** `agent-manager.ts:306-312`
|
||||
|
||||
**Problem:** When an agent is closed via `closeAgent()`, the `waitTracker` is not notified.
|
||||
|
||||
```typescript
|
||||
async closeAgent(agentId: string): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
this.agents.delete(agentId);
|
||||
agent.status = "closed";
|
||||
await agent.session.close();
|
||||
this.emitState(agent);
|
||||
// Missing: waitTracker.cancel(agentId)
|
||||
}
|
||||
```
|
||||
|
||||
**Result:** Any active `wait_for_agent` calls will hang until timeout.
|
||||
|
||||
**Fix:** This was already fixed in commit `cfa3fa8` in `mcp-server.ts`, but only at the MCP layer. The `AgentManager` should also handle this internally for non-MCP callers:
|
||||
|
||||
```typescript
|
||||
async closeAgent(agentId: string): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
this.agents.delete(agentId);
|
||||
agent.status = "closed";
|
||||
|
||||
// Cancel any pending runs first
|
||||
await this.cancelAgentRun(agentId).catch(() => {});
|
||||
|
||||
await agent.session.close();
|
||||
this.emitState(agent);
|
||||
}
|
||||
```
|
||||
|
||||
Actually, the `waitTracker` lives in `mcp-server.ts`, not `agent-manager.ts`, so this is architecture-dependent. The MCP layer correctly handles it; the AgentManager doesn't need to know about waiters.
|
||||
|
||||
---
|
||||
|
||||
## 6. Recommendations for Implementation
|
||||
|
||||
### Recommended Approach
|
||||
|
||||
**Phase 1: Fix Existing Bugs**
|
||||
1. Fix Bug #3 (cleanup race) - highest priority
|
||||
2. Fix Bug #1 (missing abort check in Promise)
|
||||
3. Optimize Bug #2 (duplicate event processing) - low priority
|
||||
|
||||
**Phase 2: Extend waitForAgentEvent**
|
||||
1. Add `lastMessage` to return type
|
||||
2. Extract last assistant message from timeline
|
||||
3. Update MCP server to include in response
|
||||
|
||||
**Phase 3: Add Blocking Variants (Optional)**
|
||||
1. Create new MCP tools: `create_coding_agent_and_wait`, `send_agent_prompt_and_wait`
|
||||
2. Implement shared wait helper in AgentManager
|
||||
3. Add timeout parameter (default 5 minutes)
|
||||
4. Handle permission requests gracefully (throw or return them)
|
||||
|
||||
### Implementation Code
|
||||
|
||||
#### Fix Bug #1 & #3: Cleanup Race
|
||||
|
||||
```typescript
|
||||
async waitForAgentEvent(
|
||||
agentId: string,
|
||||
options?: WaitForAgentOptions
|
||||
): Promise<WaitForAgentResult> {
|
||||
const snapshot = this.getAgent(agentId);
|
||||
if (!snapshot) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
const immediatePermission = snapshot.pendingPermissions[0] ?? null;
|
||||
if (immediatePermission) {
|
||||
return { status: snapshot.status, permission: immediatePermission };
|
||||
}
|
||||
|
||||
if (!isAgentBusy(snapshot.status)) {
|
||||
return { status: snapshot.status, permission: null };
|
||||
}
|
||||
|
||||
if (options?.signal?.aborted) {
|
||||
throw createAbortError(options.signal, "wait_for_agent aborted");
|
||||
}
|
||||
|
||||
return await new Promise<WaitForAgentResult>((resolve, reject) => {
|
||||
// Check AGAIN inside Promise constructor (fix Bug #1)
|
||||
if (options?.signal?.aborted) {
|
||||
reject(createAbortError(options.signal, "wait_for_agent aborted"));
|
||||
return;
|
||||
}
|
||||
|
||||
let currentStatus: AgentLifecycleStatus = snapshot.status;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let abortListener: (() => void) | null = null;
|
||||
|
||||
const cleanup = () => {
|
||||
if (unsubscribe) {
|
||||
try { unsubscribe(); } catch {}
|
||||
unsubscribe = null;
|
||||
}
|
||||
if (abortListener && options?.signal) {
|
||||
try {
|
||||
options.signal.removeEventListener("abort", abortListener);
|
||||
} catch {}
|
||||
abortListener = null;
|
||||
}
|
||||
};
|
||||
|
||||
const finish = (permission: AgentPermissionRequest | null) => {
|
||||
cleanup();
|
||||
resolve({ status: currentStatus, permission });
|
||||
};
|
||||
|
||||
// Set up subscription BEFORE registering cleanup (fix Bug #3)
|
||||
unsubscribe = this.subscribe(
|
||||
(event) => {
|
||||
if (event.type === "agent_state") {
|
||||
currentStatus = event.agent.status;
|
||||
const pending = event.agent.pendingPermissions[0] ?? null;
|
||||
if (pending) {
|
||||
finish(pending);
|
||||
return;
|
||||
}
|
||||
if (!isAgentBusy(event.agent.status)) {
|
||||
finish(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ agentId, replayState: true }
|
||||
);
|
||||
|
||||
// Set up abort handler
|
||||
if (options?.signal) {
|
||||
abortListener = () => {
|
||||
cleanup();
|
||||
reject(createAbortError(options.signal, "wait_for_agent aborted"));
|
||||
};
|
||||
options.signal.addEventListener("abort", abortListener, { once: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### Add Last Message Extraction
|
||||
|
||||
```typescript
|
||||
export type WaitForAgentResult = {
|
||||
status: AgentLifecycleStatus;
|
||||
permission: AgentPermissionRequest | null;
|
||||
lastMessage: string | null;
|
||||
};
|
||||
|
||||
async waitForAgentEvent(
|
||||
agentId: string,
|
||||
options?: WaitForAgentOptions
|
||||
): Promise<WaitForAgentResult> {
|
||||
// ... existing wait logic ...
|
||||
|
||||
// After promise resolves, extract last message
|
||||
const baseResult = await waitPromise; // existing Promise code
|
||||
const timeline = this.getTimeline(agentId);
|
||||
let lastMessage: string | null = null;
|
||||
|
||||
for (let i = timeline.length - 1; i >= 0; i--) {
|
||||
const item = timeline[i];
|
||||
if (item.type === "assistant_message") {
|
||||
lastMessage = item.text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...baseResult,
|
||||
lastMessage,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### Update MCP Server
|
||||
|
||||
```typescript
|
||||
server.registerTool(
|
||||
"wait_for_agent",
|
||||
{
|
||||
// ... existing schema ...
|
||||
outputSchema: {
|
||||
agentId: z.string(),
|
||||
status: AgentStatusEnum,
|
||||
permission: AgentPermissionRequestPayloadSchema.nullable(),
|
||||
lastMessage: z.string().nullable(),
|
||||
activity: z.object({
|
||||
format: z.literal("curated"),
|
||||
updateCount: z.number(),
|
||||
currentModeId: z.string().nullable(),
|
||||
content: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async ({ agentId }, { signal }) => {
|
||||
// ... existing signal setup ...
|
||||
|
||||
const result = await agentManager.waitForAgentEvent(agentId, {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
const activity = buildActivityPayload(agentManager, agentId);
|
||||
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({
|
||||
agentId,
|
||||
status: result.status,
|
||||
permission: result.permission,
|
||||
lastMessage: result.lastMessage,
|
||||
activity,
|
||||
}),
|
||||
};
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Alternative: Keep It Simple
|
||||
|
||||
The proposed changes add complexity. Consider if they're actually needed:
|
||||
|
||||
### Current Pattern (Works Well)
|
||||
```typescript
|
||||
// Claude uses these MCP tools:
|
||||
const { agentId } = await create_coding_agent({ cwd, initialPrompt });
|
||||
const result = await wait_for_agent({ agentId });
|
||||
// result.activity.content has curated summary
|
||||
```
|
||||
|
||||
### Proposed Pattern
|
||||
```typescript
|
||||
// With background flag:
|
||||
const result = await create_coding_agent({
|
||||
cwd,
|
||||
initialPrompt,
|
||||
background: false // wait for completion
|
||||
});
|
||||
// result.lastMessage has final response
|
||||
```
|
||||
|
||||
### Assessment
|
||||
|
||||
The current pattern is more flexible:
|
||||
- Caller controls when to wait
|
||||
- Can poll status without waiting
|
||||
- Can cancel agent independently
|
||||
- Activity summary is actually more useful than raw last message
|
||||
|
||||
The proposed pattern is simpler:
|
||||
- One call instead of two
|
||||
- More similar to standard LLM APIs
|
||||
- Last message is what most callers want
|
||||
|
||||
**Recommendation:** Implement both patterns:
|
||||
1. Keep existing tools unchanged (backward compatibility)
|
||||
2. Add `*_and_wait` variants for convenience
|
||||
3. Let usage patterns determine which is preferred
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing Recommendations
|
||||
|
||||
### Unit Tests Needed
|
||||
|
||||
1. **Abort Signal Race Test**
|
||||
```typescript
|
||||
test("waitForAgentEvent handles signal aborted before Promise", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
await expect(
|
||||
agentManager.waitForAgentEvent(agentId, { signal: controller.signal })
|
||||
).rejects.toThrow("aborted");
|
||||
});
|
||||
```
|
||||
|
||||
2. **Fast Completion Test**
|
||||
```typescript
|
||||
test("waitForAgentEvent cleans up when agent already idle", async () => {
|
||||
// Create idle agent
|
||||
const snapshot = await agentManager.createAgent(config);
|
||||
|
||||
// Should return immediately without hanging
|
||||
const result = await agentManager.waitForAgentEvent(snapshot.id);
|
||||
expect(result.status).toBe("idle");
|
||||
|
||||
// Verify no memory leaks (subscription cleaned up)
|
||||
});
|
||||
```
|
||||
|
||||
3. **Last Message Extraction Test**
|
||||
```typescript
|
||||
test("waitForAgentEvent returns last assistant message", async () => {
|
||||
const snapshot = await agentManager.createAgent(config);
|
||||
const runPromise = agentManager.runAgent(snapshot.id, "Hello");
|
||||
|
||||
const result = await agentManager.waitForAgentEvent(snapshot.id);
|
||||
expect(result.lastMessage).toBeTruthy();
|
||||
expect(result.lastMessage).toContain("Hello");
|
||||
});
|
||||
```
|
||||
|
||||
### Integration Tests Needed
|
||||
|
||||
1. Test MCP tools with actual Claude agent
|
||||
2. Test interruption during long-running command
|
||||
3. Test multiple concurrent wait_for_agent calls
|
||||
4. Test wait_for_agent during permission request
|
||||
|
||||
---
|
||||
|
||||
## 9. Summary
|
||||
|
||||
### Critical Issues Found
|
||||
|
||||
1. ⚠️ **Cleanup race condition** (Bug #3) - Can cause memory leaks
|
||||
2. ⚠️ **Missing abort check in Promise** (Bug #1) - Can cause hangs
|
||||
3. ⚠️ **TOCTOU race on agent status** - Can cause missed completions
|
||||
|
||||
### Proposed Changes Assessment
|
||||
|
||||
| Proposal | Verdict | Notes |
|
||||
|----------|---------|-------|
|
||||
| Return last message in waitForAgentEvent | ✅ Good | Simple, useful, backward compatible |
|
||||
| Add `background` flag to existing tools | ⚠️ Risky | Breaking change, confusing API |
|
||||
| Add separate `*_and_wait` tools | ✅ Better | Explicit, backward compatible |
|
||||
| Shared wait code path | ✅ Good | DRY, easier to maintain |
|
||||
|
||||
### Recommended Action Plan
|
||||
|
||||
**Immediate (Fix Production Bugs):**
|
||||
1. Fix Bug #3: Cleanup race condition
|
||||
2. Fix Bug #1: Abort signal check in Promise constructor
|
||||
|
||||
**Short Term (Enhance API):**
|
||||
1. Add `lastMessage` to `WaitForAgentResult`
|
||||
2. Update `wait_for_agent` MCP tool to return it
|
||||
3. Add integration tests
|
||||
|
||||
**Long Term (New Features):**
|
||||
1. Add `create_coding_agent_and_wait` MCP tool
|
||||
2. Add `send_agent_prompt_and_wait` MCP tool
|
||||
3. Add timeout parameter support
|
||||
4. Monitor usage to see which pattern users prefer
|
||||
|
||||
### Code Quality
|
||||
The codebase is generally well-structured with good separation of concerns. The recent fix (commit `cfa3fa8`) correctly addressed the waitTracker cancellation issue at the MCP layer. The remaining bugs are subtle race conditions that are easy to miss in async code.
|
||||
@@ -22,34 +22,19 @@ import type {
|
||||
} from "./agent-sdk-types.js";
|
||||
import { resolveAgentModel } from "./model-resolver.js";
|
||||
|
||||
export type AgentLifecycleStatus =
|
||||
| "initializing"
|
||||
| "idle"
|
||||
| "running"
|
||||
| "error"
|
||||
| "closed";
|
||||
export const AGENT_LIFECYCLE_STATUSES = [
|
||||
"initializing",
|
||||
"idle",
|
||||
"running",
|
||||
"error",
|
||||
"closed",
|
||||
] as const;
|
||||
|
||||
export type AgentSnapshot = {
|
||||
id: string;
|
||||
provider: AgentProvider;
|
||||
cwd: string;
|
||||
model: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
lastUserMessageAt: Date | null;
|
||||
status: AgentLifecycleStatus;
|
||||
sessionId: string | null;
|
||||
capabilities: AgentCapabilityFlags;
|
||||
currentModeId: string | null;
|
||||
availableModes: AgentMode[];
|
||||
pendingPermissions: AgentPermissionRequest[];
|
||||
persistence: AgentPersistenceHandle | null;
|
||||
lastUsage?: AgentUsage;
|
||||
lastError?: string;
|
||||
};
|
||||
export type AgentLifecycleStatus =
|
||||
(typeof AGENT_LIFECYCLE_STATUSES)[number];
|
||||
|
||||
export type AgentManagerEvent =
|
||||
| { type: "agent_state"; agent: AgentSnapshot }
|
||||
| { type: "agent_state"; agent: ManagedAgent }
|
||||
| { type: "agent_stream"; agentId: string; event: AgentStreamEvent };
|
||||
|
||||
export type AgentSubscriber = (event: AgentManagerEvent) => void;
|
||||
@@ -76,31 +61,73 @@ export type WaitForAgentOptions = {
|
||||
export type WaitForAgentResult = {
|
||||
status: AgentLifecycleStatus;
|
||||
permission: AgentPermissionRequest | null;
|
||||
lastMessage: string | null;
|
||||
};
|
||||
|
||||
type ManagedAgent = {
|
||||
type ManagedAgentBase = {
|
||||
id: string;
|
||||
provider: AgentProvider;
|
||||
cwd: string;
|
||||
session: AgentSession;
|
||||
sessionId: string | null;
|
||||
capabilities: AgentCapabilityFlags;
|
||||
config: AgentSessionConfig;
|
||||
status: AgentLifecycleStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
availableModes: AgentMode[];
|
||||
currentModeId: string | null;
|
||||
pendingPermissions: Map<string, AgentPermissionRequest>;
|
||||
pendingRun: AsyncGenerator<AgentStreamEvent> | null;
|
||||
timeline: AgentTimelineItem[];
|
||||
persistence: AgentPersistenceHandle | null;
|
||||
lastUsage?: AgentUsage;
|
||||
lastError?: string;
|
||||
historyPrimed: boolean;
|
||||
lastUserMessageAt: Date | null;
|
||||
sessionId: string | null;
|
||||
lastUsage?: AgentUsage;
|
||||
lastError?: string;
|
||||
};
|
||||
|
||||
type ManagedAgentWithSession = ManagedAgentBase & {
|
||||
session: AgentSession;
|
||||
};
|
||||
|
||||
type ManagedAgentInitializing = ManagedAgentWithSession & {
|
||||
lifecycle: "initializing";
|
||||
pendingRun: null;
|
||||
};
|
||||
|
||||
type ManagedAgentIdle = ManagedAgentWithSession & {
|
||||
lifecycle: "idle";
|
||||
pendingRun: null;
|
||||
};
|
||||
|
||||
type ManagedAgentRunning = ManagedAgentWithSession & {
|
||||
lifecycle: "running";
|
||||
pendingRun: AsyncGenerator<AgentStreamEvent>;
|
||||
};
|
||||
|
||||
type ManagedAgentError = ManagedAgentWithSession & {
|
||||
lifecycle: "error";
|
||||
pendingRun: null;
|
||||
lastError: string;
|
||||
};
|
||||
|
||||
type ManagedAgentClosed = ManagedAgentBase & {
|
||||
lifecycle: "closed";
|
||||
session: null;
|
||||
pendingRun: null;
|
||||
};
|
||||
|
||||
export type ManagedAgent =
|
||||
| ManagedAgentInitializing
|
||||
| ManagedAgentIdle
|
||||
| ManagedAgentRunning
|
||||
| ManagedAgentError
|
||||
| ManagedAgentClosed;
|
||||
|
||||
type ActiveManagedAgent =
|
||||
| ManagedAgentInitializing
|
||||
| ManagedAgentIdle
|
||||
| ManagedAgentRunning
|
||||
| ManagedAgentError;
|
||||
|
||||
type SubscriptionRecord = {
|
||||
callback: AgentSubscriber;
|
||||
agentId: string | null;
|
||||
@@ -112,6 +139,23 @@ const BUSY_STATUSES: AgentLifecycleStatus[] = [
|
||||
"running",
|
||||
];
|
||||
|
||||
const READ_ONLY_AGENT_ERROR_MESSAGE =
|
||||
"ManagedAgent views returned by AgentManager are read-only";
|
||||
|
||||
class ReadonlyPendingPermissionsMap<K, V> extends Map<K, V> {
|
||||
override set(): this {
|
||||
throw new Error(READ_ONLY_AGENT_ERROR_MESSAGE);
|
||||
}
|
||||
|
||||
override delete(): boolean {
|
||||
throw new Error(READ_ONLY_AGENT_ERROR_MESSAGE);
|
||||
}
|
||||
|
||||
override clear(): void {
|
||||
throw new Error(READ_ONLY_AGENT_ERROR_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
function isAgentBusy(status: AgentLifecycleStatus): boolean {
|
||||
return BUSY_STATUSES.includes(status);
|
||||
}
|
||||
@@ -132,7 +176,7 @@ function createAbortError(
|
||||
|
||||
export class AgentManager {
|
||||
private readonly clients = new Map<AgentProvider, AgentClient>();
|
||||
private readonly agents = new Map<string, ManagedAgent>();
|
||||
private readonly agents = new Map<string, ActiveManagedAgent>();
|
||||
private readonly subscribers = new Set<SubscriptionRecord>();
|
||||
private readonly maxTimelineItems: number;
|
||||
private readonly idFactory: () => string;
|
||||
@@ -165,11 +209,17 @@ export class AgentManager {
|
||||
if (record.agentId) {
|
||||
const agent = this.agents.get(record.agentId);
|
||||
if (agent) {
|
||||
callback({ type: "agent_state", agent: this.toSnapshot(agent) });
|
||||
callback({
|
||||
type: "agent_state",
|
||||
agent: this.createAgentView(agent),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (const agent of this.agents.values()) {
|
||||
callback({ type: "agent_state", agent: this.toSnapshot(agent) });
|
||||
callback({
|
||||
type: "agent_state",
|
||||
agent: this.createAgentView(agent),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,9 +229,9 @@ export class AgentManager {
|
||||
};
|
||||
}
|
||||
|
||||
listAgents(): AgentSnapshot[] {
|
||||
listAgents(): ManagedAgent[] {
|
||||
return Array.from(this.agents.values()).map((agent) =>
|
||||
this.toSnapshot(agent)
|
||||
this.createAgentView(agent)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -223,9 +273,9 @@ export class AgentManager {
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
getAgent(id: string): AgentSnapshot | null {
|
||||
getAgent(id: string): ManagedAgent | null {
|
||||
const agent = this.agents.get(id);
|
||||
return agent ? this.toSnapshot(agent) : null;
|
||||
return agent ? this.createAgentView(agent) : null;
|
||||
}
|
||||
|
||||
getTimeline(id: string): AgentTimelineItem[] {
|
||||
@@ -236,7 +286,7 @@ export class AgentManager {
|
||||
async createAgent(
|
||||
config: AgentSessionConfig,
|
||||
agentId?: string
|
||||
): Promise<AgentSnapshot> {
|
||||
): Promise<ManagedAgent> {
|
||||
const normalizedConfig = await this.normalizeConfig(config);
|
||||
const client = this.requireClient(normalizedConfig.provider);
|
||||
const session = await client.createSession(normalizedConfig);
|
||||
@@ -251,7 +301,7 @@ export class AgentManager {
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
agentId?: string
|
||||
): Promise<AgentSnapshot> {
|
||||
): Promise<ManagedAgent> {
|
||||
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
||||
const mergedConfig = {
|
||||
...metadata,
|
||||
@@ -272,7 +322,7 @@ export class AgentManager {
|
||||
);
|
||||
}
|
||||
|
||||
async refreshAgentFromPersistence(agentId: string): Promise<AgentSnapshot> {
|
||||
async refreshAgentFromPersistence(agentId: string): Promise<ManagedAgent> {
|
||||
const existing = this.requireAgent(agentId);
|
||||
const handle = existing.persistence;
|
||||
if (!handle) {
|
||||
@@ -306,9 +356,16 @@ export class AgentManager {
|
||||
async closeAgent(agentId: string): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
this.agents.delete(agentId);
|
||||
agent.status = "closed";
|
||||
await agent.session.close();
|
||||
this.emitState(agent);
|
||||
const session = agent.session;
|
||||
const closedAgent: ManagedAgent = {
|
||||
...agent,
|
||||
lifecycle: "closed",
|
||||
session: null,
|
||||
pendingRun: null,
|
||||
sessionId: agent.sessionId,
|
||||
};
|
||||
await session.close();
|
||||
this.emitState(closedAgent);
|
||||
}
|
||||
|
||||
async setAgentMode(agentId: string, modeId: string): Promise<void> {
|
||||
@@ -377,16 +434,14 @@ export class AgentManager {
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions
|
||||
): AsyncGenerator<AgentStreamEvent> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
if (agent.status === "closed") {
|
||||
throw new Error(`Agent ${agentId} is closed`);
|
||||
}
|
||||
if (agent.pendingRun) {
|
||||
const existingAgent = this.requireAgent(agentId);
|
||||
if (existingAgent.pendingRun) {
|
||||
throw new Error(`Agent ${agentId} already has an active run`);
|
||||
}
|
||||
|
||||
const agent = existingAgent as ActiveManagedAgent;
|
||||
const iterator = agent.session.stream(prompt, options);
|
||||
agent.status = "running";
|
||||
agent.lifecycle = "running";
|
||||
agent.pendingRun = iterator;
|
||||
agent.lastError = undefined;
|
||||
this.emitState(agent);
|
||||
@@ -405,11 +460,12 @@ export class AgentManager {
|
||||
return;
|
||||
}
|
||||
|
||||
agent.pendingRun = null;
|
||||
agent.status = error ? "error" : "idle";
|
||||
agent.lastError = error;
|
||||
agent.persistence = agent.session.describePersistence();
|
||||
this.emitState(agent);
|
||||
const mutableAgent = agent as ActiveManagedAgent;
|
||||
mutableAgent.pendingRun = null;
|
||||
mutableAgent.lifecycle = error ? "error" : "idle";
|
||||
mutableAgent.lastError = error;
|
||||
mutableAgent.persistence = mutableAgent.session.describePersistence();
|
||||
this.emitState(mutableAgent);
|
||||
};
|
||||
|
||||
const self = this;
|
||||
@@ -486,11 +542,42 @@ export class AgentManager {
|
||||
return Array.from(agent.pendingPermissions.values());
|
||||
}
|
||||
|
||||
private peekPendingPermission(agent: ManagedAgent): AgentPermissionRequest | null {
|
||||
const iterator = agent.pendingPermissions.values().next();
|
||||
return iterator.done ? null : iterator.value;
|
||||
}
|
||||
|
||||
async primeAgentHistory(agentId: string): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
await this.primeHistory(agent);
|
||||
}
|
||||
|
||||
private getLastAssistantMessage(agentId: string): string | null {
|
||||
const agent = this.agents.get(agentId);
|
||||
if (!agent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Collect the last contiguous assistant messages (Claude streams chunks)
|
||||
const chunks: string[] = [];
|
||||
for (let i = agent.timeline.length - 1; i >= 0; i--) {
|
||||
const item = agent.timeline[i];
|
||||
if (item.type !== "assistant_message") {
|
||||
if (chunks.length) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
chunks.push(item.text);
|
||||
}
|
||||
|
||||
if (!chunks.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return chunks.reverse().join("");
|
||||
}
|
||||
|
||||
async waitForAgentEvent(
|
||||
agentId: string,
|
||||
options?: WaitForAgentOptions
|
||||
@@ -500,93 +587,103 @@ export class AgentManager {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
const immediatePermission = snapshot.pendingPermissions[0] ?? null;
|
||||
|
||||
const immediatePermission = this.peekPendingPermission(snapshot);
|
||||
if (immediatePermission) {
|
||||
return { status: snapshot.status, permission: immediatePermission };
|
||||
return {
|
||||
status: snapshot.lifecycle,
|
||||
permission: immediatePermission,
|
||||
lastMessage: this.getLastAssistantMessage(agentId)
|
||||
};
|
||||
}
|
||||
|
||||
if (!isAgentBusy(snapshot.status)) {
|
||||
return { status: snapshot.status, permission: null };
|
||||
if (!isAgentBusy(snapshot.lifecycle)) {
|
||||
return {
|
||||
status: snapshot.lifecycle,
|
||||
permission: null,
|
||||
lastMessage: this.getLastAssistantMessage(agentId)
|
||||
};
|
||||
}
|
||||
|
||||
if (options?.signal?.aborted) {
|
||||
throw createAbortError(options.signal, "wait_for_agent aborted");
|
||||
}
|
||||
|
||||
|
||||
return await new Promise<WaitForAgentResult>((resolve, reject) => {
|
||||
let currentStatus: AgentLifecycleStatus = snapshot.status;
|
||||
const cleanupFns: Array<() => void> = [];
|
||||
// Bug #1 Fix: Check abort signal AGAIN inside Promise constructor
|
||||
// to avoid race condition between pre-Promise check and abort listener registration
|
||||
if (options?.signal?.aborted) {
|
||||
reject(createAbortError(options.signal, "wait_for_agent aborted"));
|
||||
return;
|
||||
}
|
||||
|
||||
let currentStatus: AgentLifecycleStatus = snapshot.lifecycle;
|
||||
|
||||
// Bug #3 Fix: Declare unsubscribe and abortHandler upfront so cleanup can reference them
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let abortHandler: (() => void) | null = null;
|
||||
|
||||
const cleanup = () => {
|
||||
while (cleanupFns.length) {
|
||||
const fn = cleanupFns.pop();
|
||||
// Clean up subscription
|
||||
if (unsubscribe) {
|
||||
try {
|
||||
fn?.();
|
||||
unsubscribe();
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
unsubscribe = null;
|
||||
}
|
||||
|
||||
// Clean up abort listener
|
||||
if (abortHandler && options?.signal) {
|
||||
try {
|
||||
options.signal.removeEventListener("abort", abortHandler);
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
abortHandler = null;
|
||||
}
|
||||
};
|
||||
|
||||
const finish = (permission: AgentPermissionRequest | null) => {
|
||||
cleanup();
|
||||
resolve({ status: currentStatus, permission });
|
||||
resolve({
|
||||
status: currentStatus,
|
||||
permission,
|
||||
lastMessage: this.getLastAssistantMessage(agentId)
|
||||
});
|
||||
};
|
||||
|
||||
const unsubscribe = this.subscribe(
|
||||
// Bug #3 Fix: Set up abort handler BEFORE subscription
|
||||
// to ensure cleanup handlers exist before callback can fire
|
||||
if (options?.signal) {
|
||||
abortHandler = () => {
|
||||
cleanup();
|
||||
reject(createAbortError(options.signal, "wait_for_agent aborted"));
|
||||
};
|
||||
options.signal.addEventListener("abort", abortHandler, { once: true });
|
||||
}
|
||||
|
||||
// Bug #3 Fix: Now subscribe with cleanup handlers already in place
|
||||
// This prevents race condition if callback fires synchronously with replayState: true
|
||||
unsubscribe = this.subscribe(
|
||||
(event) => {
|
||||
// Bug #2 Fix: Only handle agent_state events, remove redundant agent_stream handling
|
||||
if (event.type === "agent_state") {
|
||||
currentStatus = event.agent.status;
|
||||
const pending = event.agent.pendingPermissions[0] ?? null;
|
||||
currentStatus = event.agent.lifecycle;
|
||||
const pending = this.peekPendingPermission(event.agent);
|
||||
if (pending) {
|
||||
finish(pending);
|
||||
return;
|
||||
}
|
||||
if (!isAgentBusy(event.agent.status)) {
|
||||
if (!isAgentBusy(event.agent.lifecycle)) {
|
||||
finish(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type !== "agent_stream") {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.event.type) {
|
||||
case "permission_requested": {
|
||||
currentStatus = "running";
|
||||
finish(event.event.request);
|
||||
break;
|
||||
}
|
||||
case "turn_completed": {
|
||||
currentStatus = "idle";
|
||||
finish(null);
|
||||
break;
|
||||
}
|
||||
case "turn_failed": {
|
||||
currentStatus = "error";
|
||||
finish(null);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
{ agentId, replayState: true }
|
||||
);
|
||||
cleanupFns.push(unsubscribe);
|
||||
|
||||
if (options?.signal) {
|
||||
const abortHandler = () => {
|
||||
cleanup();
|
||||
reject(createAbortError(options.signal, "wait_for_agent aborted"));
|
||||
};
|
||||
|
||||
options.signal.addEventListener("abort", abortHandler, { once: true });
|
||||
cleanupFns.push(() =>
|
||||
options.signal?.removeEventListener("abort", abortHandler)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -594,12 +691,12 @@ export class AgentManager {
|
||||
session: AgentSession,
|
||||
config: AgentSessionConfig,
|
||||
agentId: string
|
||||
): Promise<AgentSnapshot> {
|
||||
): Promise<ManagedAgent> {
|
||||
if (this.agents.has(agentId)) {
|
||||
throw new Error(`Agent with id ${agentId} already exists`);
|
||||
}
|
||||
|
||||
const managed: ManagedAgent = {
|
||||
const managed = {
|
||||
id: agentId,
|
||||
provider: config.provider,
|
||||
cwd: config.cwd,
|
||||
@@ -607,7 +704,7 @@ export class AgentManager {
|
||||
sessionId: session.id,
|
||||
capabilities: session.capabilities,
|
||||
config,
|
||||
status: "initializing",
|
||||
lifecycle: "initializing",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
availableModes: [],
|
||||
@@ -618,18 +715,18 @@ export class AgentManager {
|
||||
persistence: session.describePersistence(),
|
||||
historyPrimed: false,
|
||||
lastUserMessageAt: null,
|
||||
};
|
||||
} as ActiveManagedAgent;
|
||||
|
||||
this.agents.set(agentId, managed);
|
||||
this.emitState(managed);
|
||||
|
||||
await this.refreshSessionState(managed);
|
||||
managed.status = "idle";
|
||||
managed.lifecycle = "idle";
|
||||
this.emitState(managed);
|
||||
return this.toSnapshot(managed);
|
||||
return this.createAgentView(managed);
|
||||
}
|
||||
|
||||
private async refreshSessionState(agent: ManagedAgent): Promise<void> {
|
||||
private async refreshSessionState(agent: ActiveManagedAgent): Promise<void> {
|
||||
try {
|
||||
const modes = await agent.session.getAvailableModes();
|
||||
agent.availableModes = modes;
|
||||
@@ -653,7 +750,7 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async primeHistory(agent: ManagedAgent): Promise<void> {
|
||||
private async primeHistory(agent: ActiveManagedAgent): Promise<void> {
|
||||
if (agent.historyPrimed) {
|
||||
return;
|
||||
}
|
||||
@@ -668,7 +765,7 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
private handleStreamEvent(
|
||||
agent: ManagedAgent,
|
||||
agent: ActiveManagedAgent,
|
||||
event: AgentStreamEvent,
|
||||
options?: { fromHistory?: boolean }
|
||||
): void {
|
||||
@@ -722,7 +819,10 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
private emitState(agent: ManagedAgent): void {
|
||||
this.dispatch({ type: "agent_state", agent: this.toSnapshot(agent) });
|
||||
this.dispatch({
|
||||
type: "agent_state",
|
||||
agent: this.createAgentView(agent),
|
||||
});
|
||||
}
|
||||
|
||||
private dispatchStream(agentId: string, event: AgentStreamEvent): void {
|
||||
@@ -749,25 +849,156 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
private toSnapshot(agent: ManagedAgent): AgentSnapshot {
|
||||
private createAgentView(agent: ManagedAgent): ManagedAgent {
|
||||
const clone = this.cloneAgent(agent);
|
||||
clone.pendingPermissions = this.createReadonlyPendingPermissionsMap(
|
||||
clone.pendingPermissions
|
||||
);
|
||||
|
||||
this.deepFreezeValue(clone.availableModes);
|
||||
this.deepFreezeValue(clone.timeline);
|
||||
this.deepFreezeValue(clone.capabilities);
|
||||
this.deepFreezeValue(clone.config);
|
||||
if (clone.persistence) {
|
||||
this.deepFreezeValue(clone.persistence);
|
||||
}
|
||||
if (clone.lastUsage) {
|
||||
this.deepFreezeValue(clone.lastUsage);
|
||||
}
|
||||
if (clone.lastUserMessageAt) {
|
||||
Object.freeze(clone.lastUserMessageAt);
|
||||
}
|
||||
Object.freeze(clone.createdAt);
|
||||
Object.freeze(clone.updatedAt);
|
||||
|
||||
return Object.freeze(clone) as ManagedAgent;
|
||||
}
|
||||
|
||||
private cloneAgent(agent: ManagedAgent): ManagedAgent {
|
||||
return {
|
||||
id: agent.id,
|
||||
provider: agent.provider,
|
||||
cwd: agent.cwd,
|
||||
model: agent.config.model ?? null,
|
||||
createdAt: agent.createdAt,
|
||||
updatedAt: agent.updatedAt,
|
||||
lastUserMessageAt: agent.lastUserMessageAt,
|
||||
status: agent.status,
|
||||
sessionId: agent.sessionId,
|
||||
capabilities: agent.capabilities,
|
||||
currentModeId: agent.currentModeId,
|
||||
availableModes: agent.availableModes,
|
||||
pendingPermissions: Array.from(agent.pendingPermissions.values()),
|
||||
persistence: agent.persistence,
|
||||
lastUsage: agent.lastUsage,
|
||||
lastError: agent.lastError,
|
||||
};
|
||||
...agent,
|
||||
createdAt: new Date(agent.createdAt.getTime()),
|
||||
updatedAt: new Date(agent.updatedAt.getTime()),
|
||||
lastUserMessageAt: agent.lastUserMessageAt
|
||||
? new Date(agent.lastUserMessageAt.getTime())
|
||||
: null,
|
||||
pendingPermissions: new Map(
|
||||
Array.from(agent.pendingPermissions.entries()).map(
|
||||
([key, value]) => [key, this.cloneValue(value)]
|
||||
)
|
||||
),
|
||||
availableModes: this.cloneValue(agent.availableModes),
|
||||
timeline: this.cloneValue(agent.timeline),
|
||||
capabilities: this.cloneValue(agent.capabilities),
|
||||
config: this.cloneValue(agent.config),
|
||||
persistence: agent.persistence
|
||||
? this.cloneValue(agent.persistence)
|
||||
: agent.persistence,
|
||||
lastUsage: agent.lastUsage ? this.cloneValue(agent.lastUsage) : undefined,
|
||||
} as ManagedAgent;
|
||||
}
|
||||
|
||||
private createReadonlyPendingPermissionsMap(
|
||||
source: Map<string, AgentPermissionRequest>
|
||||
): Map<string, AgentPermissionRequest> {
|
||||
const readonlyMap = new ReadonlyPendingPermissionsMap(source);
|
||||
for (const request of readonlyMap.values()) {
|
||||
this.deepFreezeValue(request);
|
||||
}
|
||||
return Object.freeze(readonlyMap) as Map<string, AgentPermissionRequest>;
|
||||
}
|
||||
|
||||
private cloneValue<T>(value: T): T {
|
||||
if (value === null || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return new Date(value.getTime()) as T;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => this.cloneValue(item)) as T;
|
||||
}
|
||||
if (value instanceof Map) {
|
||||
const clone = new Map();
|
||||
for (const [key, entryValue] of value.entries()) {
|
||||
clone.set(this.cloneValue(key), this.cloneValue(entryValue));
|
||||
}
|
||||
return clone as T;
|
||||
}
|
||||
if (value instanceof Set) {
|
||||
const clone = new Set();
|
||||
for (const entryValue of value.values()) {
|
||||
clone.add(this.cloneValue(entryValue));
|
||||
}
|
||||
return clone as T;
|
||||
}
|
||||
if (!this.isPlainObject(value)) {
|
||||
return value;
|
||||
}
|
||||
const clonedObject: Record<string, unknown> = {};
|
||||
for (const [key, entryValue] of Object.entries(value)) {
|
||||
clonedObject[key] = this.cloneValue(entryValue);
|
||||
}
|
||||
return clonedObject as T;
|
||||
}
|
||||
|
||||
private deepFreezeValue<T>(value: T, seen = new WeakSet<object>()): T {
|
||||
if (value === null || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
Object.freeze(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
const objectValue = value as object;
|
||||
if (seen.has(objectValue)) {
|
||||
return value;
|
||||
}
|
||||
seen.add(objectValue);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
this.deepFreezeValue(item, seen);
|
||||
}
|
||||
Object.freeze(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value instanceof Map) {
|
||||
for (const mapValue of value.values()) {
|
||||
this.deepFreezeValue(mapValue, seen);
|
||||
}
|
||||
Object.freeze(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value instanceof Set) {
|
||||
for (const setValue of value.values()) {
|
||||
this.deepFreezeValue(setValue, seen);
|
||||
}
|
||||
Object.freeze(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
if (!this.isPlainObject(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
for (const key of Object.keys(record)) {
|
||||
this.deepFreezeValue(record[key], seen);
|
||||
}
|
||||
Object.freeze(record);
|
||||
return value;
|
||||
}
|
||||
|
||||
private isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (value === null || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
private async normalizeConfig(
|
||||
@@ -804,7 +1035,7 @@ export class AgentManager {
|
||||
return client;
|
||||
}
|
||||
|
||||
private requireAgent(id: string): ManagedAgent {
|
||||
private requireAgent(id: string): ActiveManagedAgent {
|
||||
const agent = this.agents.get(id);
|
||||
if (!agent) {
|
||||
throw new Error(`Unknown agent '${id}'`);
|
||||
|
||||
294
packages/server/src/server/agent/agent-projections.test.ts
Normal file
294
packages/server/src/server/agent/agent-projections.test.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
AGENT_LIFECYCLE_STATUSES,
|
||||
type AgentLifecycleStatus,
|
||||
} from "./agent-manager.js";
|
||||
import { toAgentPayload, toStoredAgentRecord, type ManagedAgent } from "./agent-projections.js";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
AgentPersistenceHandle,
|
||||
AgentSessionConfig,
|
||||
} from "./agent-sdk-types.js";
|
||||
|
||||
type ManagedAgentOverrides = Omit<
|
||||
Partial<ManagedAgent>,
|
||||
"config" | "pendingPermissions"
|
||||
> & {
|
||||
config?: Partial<AgentSessionConfig>;
|
||||
pendingPermissions?: Map<string, AgentPermissionRequest>;
|
||||
};
|
||||
|
||||
function createManagedAgent(
|
||||
overrides: ManagedAgentOverrides = {}
|
||||
): ManagedAgent {
|
||||
const now = new Date("2025-01-01T00:00:00.000Z");
|
||||
const baseConfig: AgentSessionConfig = {
|
||||
provider: "claude",
|
||||
cwd: "/tmp/project",
|
||||
modeId: "plan",
|
||||
model: "claude-3.5-sonnet",
|
||||
extra: {
|
||||
claude: { tone: "friendly" },
|
||||
},
|
||||
};
|
||||
|
||||
const basePersistence: AgentPersistenceHandle = {
|
||||
provider: "claude",
|
||||
sessionId: "persist-1",
|
||||
metadata: { branch: "feature/refactor" },
|
||||
};
|
||||
|
||||
const configOverrides = overrides.config ?? {};
|
||||
const {
|
||||
config: _ignoredConfig,
|
||||
pendingPermissions: pendingPermissionsOverride,
|
||||
lifecycle = "idle",
|
||||
...restOverrides
|
||||
} = overrides;
|
||||
|
||||
const sessionValue =
|
||||
lifecycle === "closed" ? null : restOverrides.session ?? ({} as any);
|
||||
const pendingRunValue =
|
||||
restOverrides.pendingRun ??
|
||||
(lifecycle === "running" ? (async function* noop() {})() : null);
|
||||
const lastErrorValue =
|
||||
restOverrides.lastError ??
|
||||
(lifecycle === "error" ? "encountered error" : undefined);
|
||||
|
||||
const agent: ManagedAgent = {
|
||||
id: "agent-123",
|
||||
provider: "claude",
|
||||
cwd: "/tmp/project",
|
||||
session: sessionValue,
|
||||
sessionId: "session-123",
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
config: { ...baseConfig, ...configOverrides },
|
||||
lifecycle,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
availableModes: [
|
||||
{ id: "plan", label: "Planning" },
|
||||
{ id: "build", label: "Building", description: "Detailed" },
|
||||
],
|
||||
currentModeId: "plan",
|
||||
pendingPermissions:
|
||||
pendingPermissionsOverride ?? new Map<string, AgentPermissionRequest>(),
|
||||
pendingRun: pendingRunValue as ManagedAgent["pendingRun"],
|
||||
timeline: [],
|
||||
persistence: { ...basePersistence },
|
||||
lastUsage: undefined,
|
||||
lastError: lastErrorValue,
|
||||
historyPrimed: true,
|
||||
lastUserMessageAt: now,
|
||||
};
|
||||
|
||||
return {
|
||||
...agent,
|
||||
...restOverrides,
|
||||
lifecycle,
|
||||
config: agent.config,
|
||||
pendingPermissions: agent.pendingPermissions,
|
||||
};
|
||||
}
|
||||
|
||||
function createPermission(
|
||||
overrides: Partial<AgentPermissionRequest> = {}
|
||||
): AgentPermissionRequest {
|
||||
const base: AgentPermissionRequest = {
|
||||
id: "perm-1",
|
||||
provider: "claude",
|
||||
name: "execute_command",
|
||||
kind: "tool",
|
||||
title: "Run command",
|
||||
description: "Execute shell command",
|
||||
input: { command: "ls", args: undefined },
|
||||
suggestions: [{ behavior: "allow" }],
|
||||
metadata: { requestedAt: new Date("2025-02-01T12:00:00.000Z") },
|
||||
};
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
describe("toStoredAgentRecord", () => {
|
||||
it("captures lifecycle metadata, config, and persistence", () => {
|
||||
const agent = createManagedAgent({
|
||||
currentModeId: "focus",
|
||||
persistence: {
|
||||
provider: "claude",
|
||||
sessionId: "persist-2",
|
||||
metadata: { resumedAt: new Date("2025-01-05T00:00:00.000Z"), note: "warm" },
|
||||
},
|
||||
});
|
||||
|
||||
const record = toStoredAgentRecord(agent, { title: "Refactor Agent" });
|
||||
|
||||
expect(record).toMatchObject({
|
||||
id: agent.id,
|
||||
provider: agent.provider,
|
||||
cwd: agent.cwd,
|
||||
title: "Refactor Agent",
|
||||
lastStatus: agent.lifecycle,
|
||||
lastModeId: "focus",
|
||||
});
|
||||
expect(record.createdAt).toBe(agent.createdAt.toISOString());
|
||||
expect(record.updatedAt).toBe(agent.updatedAt.toISOString());
|
||||
expect(record.lastActivityAt).toBe(agent.updatedAt.toISOString());
|
||||
expect(record.lastUserMessageAt).toBe(agent.lastUserMessageAt?.toISOString());
|
||||
expect(record.persistence).toEqual({
|
||||
provider: "claude",
|
||||
sessionId: "persist-2",
|
||||
metadata: {
|
||||
resumedAt: "2025-01-05T00:00:00.000Z",
|
||||
note: "warm",
|
||||
},
|
||||
});
|
||||
expect(record.config).toEqual({
|
||||
modeId: agent.config.modeId,
|
||||
model: agent.config.model,
|
||||
extra: { claude: { tone: "friendly" } },
|
||||
});
|
||||
|
||||
record.config!.extra!.claude!.tone = "serious";
|
||||
expect(agent.config.extra!.claude!.tone).toBe("friendly");
|
||||
record.persistence!.sessionId = "mutated";
|
||||
expect(agent.persistence!.sessionId).toBe("persist-2");
|
||||
});
|
||||
|
||||
it("falls back to config mode when current mode is null and handles null title", () => {
|
||||
const agent = createManagedAgent({
|
||||
currentModeId: null,
|
||||
config: { modeId: "auto" },
|
||||
lastUserMessageAt: null,
|
||||
});
|
||||
|
||||
const record = toStoredAgentRecord(agent);
|
||||
expect(record.title).toBeNull();
|
||||
expect(record.lastModeId).toBe("auto");
|
||||
expect(record.lastUserMessageAt).toBeNull();
|
||||
});
|
||||
|
||||
it("omits config when no serializable fields exist", () => {
|
||||
const agent = createManagedAgent({
|
||||
config: {
|
||||
modeId: undefined,
|
||||
model: undefined,
|
||||
extra: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const record = toStoredAgentRecord(agent);
|
||||
expect(record.config).toBeNull();
|
||||
});
|
||||
|
||||
it("propagates lifecycle status for all states", () => {
|
||||
for (const status of AGENT_LIFECYCLE_STATUSES) {
|
||||
const agent = createManagedAgent({ lifecycle: status as AgentLifecycleStatus });
|
||||
const record = toStoredAgentRecord(agent);
|
||||
expect(record.lastStatus).toBe(status);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("toAgentPayload", () => {
|
||||
it("serializes dates, clones arrays, and hides session", () => {
|
||||
const permissionA = createPermission({ id: "perm-a" });
|
||||
const permissionB = createPermission({
|
||||
id: "perm-b",
|
||||
provider: "codex",
|
||||
metadata: { requestedAt: new Date("2025-02-02T00:00:00.000Z"), extra: { flag: true } },
|
||||
});
|
||||
const pending = new Map([
|
||||
[permissionA.id, permissionA],
|
||||
[permissionB.id, permissionB],
|
||||
]);
|
||||
const agent = createManagedAgent({
|
||||
pendingPermissions: pending,
|
||||
lastUsage: { inputTokens: 10, outputTokens: 20 },
|
||||
lastError: "boom",
|
||||
});
|
||||
|
||||
const payload = toAgentPayload(agent, { title: "UI Payload" });
|
||||
|
||||
expect(payload.createdAt).toBe(agent.createdAt.toISOString());
|
||||
expect(payload.updatedAt).toBe(agent.updatedAt.toISOString());
|
||||
expect(payload.lastUserMessageAt).toBe(agent.lastUserMessageAt?.toISOString());
|
||||
expect(payload.title).toBe("UI Payload");
|
||||
expect(payload.model).toBe(agent.config.model);
|
||||
expect(payload.sessionId).toBe(agent.sessionId);
|
||||
expect(payload.pendingPermissions.map((item) => item.id)).toEqual([
|
||||
"perm-a",
|
||||
"perm-b",
|
||||
]);
|
||||
expect(payload.pendingPermissions[0]).not.toBe(permissionA);
|
||||
expect(payload.pendingPermissions[0].input).toEqual({ command: "ls" });
|
||||
expect(payload.pendingPermissions[1].metadata).toEqual({
|
||||
requestedAt: "2025-02-02T00:00:00.000Z",
|
||||
extra: { flag: true },
|
||||
});
|
||||
expect(payload.availableModes).not.toBe(agent.availableModes);
|
||||
expect(payload.availableModes).toEqual(agent.availableModes);
|
||||
expect(payload.capabilities).not.toBe(agent.capabilities);
|
||||
expect(payload.capabilities).toEqual(agent.capabilities);
|
||||
expect(payload.lastUsage).toEqual(agent.lastUsage);
|
||||
expect(payload.lastUsage).not.toBe(agent.lastUsage);
|
||||
expect(payload.lastError).toBe("boom");
|
||||
expect((payload as any).session).toBeUndefined();
|
||||
|
||||
payload.availableModes[0].label = "Changed";
|
||||
expect(agent.availableModes[0].label).toBe("Planning");
|
||||
payload.capabilities.supportsStreaming = false;
|
||||
expect(agent.capabilities.supportsStreaming).toBe(true);
|
||||
payload.pendingPermissions[0].title = "Mutated title";
|
||||
expect(permissionA.title).toBe("Run command");
|
||||
});
|
||||
|
||||
it("produces null title and current mode even without overrides", () => {
|
||||
const agent = createManagedAgent({ currentModeId: null, lastUserMessageAt: null });
|
||||
const payload = toAgentPayload(agent);
|
||||
expect(payload.title).toBeNull();
|
||||
expect(payload.currentModeId).toBeNull();
|
||||
expect(payload.lastUserMessageAt).toBeNull();
|
||||
expect(payload.pendingPermissions).toEqual([]);
|
||||
});
|
||||
|
||||
it("propagates lifecycle status for all states", () => {
|
||||
for (const status of AGENT_LIFECYCLE_STATUSES) {
|
||||
const agent = createManagedAgent({ lifecycle: status as AgentLifecycleStatus });
|
||||
const payload = toAgentPayload(agent);
|
||||
expect(payload.status).toBe(status);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps persistence handles sanitized and detached", () => {
|
||||
const agent = createManagedAgent({
|
||||
persistence: {
|
||||
provider: "codex",
|
||||
sessionId: "persist-99",
|
||||
nativeHandle: { id: "native" } as any,
|
||||
metadata: { restored: new Date("2025-03-01T00:00:00.000Z"), empty: {} },
|
||||
},
|
||||
});
|
||||
const payload = toAgentPayload(agent);
|
||||
expect(payload.persistence).toEqual({
|
||||
provider: "codex",
|
||||
sessionId: "persist-99",
|
||||
nativeHandle: { id: "native" },
|
||||
metadata: { restored: "2025-03-01T00:00:00.000Z" },
|
||||
});
|
||||
(payload.persistence as AgentPersistenceHandle).sessionId = "mutated";
|
||||
expect(agent.persistence!.sessionId).toBe("persist-99");
|
||||
});
|
||||
|
||||
it("omits lastUsage when not available", () => {
|
||||
const agent = createManagedAgent({ lastUsage: undefined });
|
||||
const payload = toAgentPayload(agent);
|
||||
expect(payload).not.toHaveProperty("lastUsage");
|
||||
});
|
||||
});
|
||||
179
packages/server/src/server/agent/agent-projections.ts
Normal file
179
packages/server/src/server/agent/agent-projections.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import type { AgentSnapshotPayload } from "../messages.js";
|
||||
import type {
|
||||
SerializableAgentConfig,
|
||||
StoredAgentRecord,
|
||||
} from "./agent-registry.js";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentMode,
|
||||
AgentPermissionRequest,
|
||||
AgentPersistenceHandle,
|
||||
AgentSessionConfig,
|
||||
AgentUsage,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
|
||||
export type { ManagedAgent };
|
||||
|
||||
type ProjectionOptions = {
|
||||
title?: string | null;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export function toStoredAgentRecord(
|
||||
agent: ManagedAgent,
|
||||
options?: ProjectionOptions
|
||||
): StoredAgentRecord {
|
||||
const createdAt = options?.createdAt ?? agent.createdAt.toISOString();
|
||||
const config = buildSerializableConfig(agent.config);
|
||||
const persistence = sanitizePersistenceHandle(agent.persistence);
|
||||
|
||||
return {
|
||||
id: agent.id,
|
||||
provider: agent.provider,
|
||||
cwd: agent.cwd,
|
||||
createdAt,
|
||||
updatedAt: agent.updatedAt.toISOString(),
|
||||
lastActivityAt: agent.updatedAt.toISOString(),
|
||||
lastUserMessageAt: agent.lastUserMessageAt
|
||||
? agent.lastUserMessageAt.toISOString()
|
||||
: null,
|
||||
title: options?.title ?? null,
|
||||
lastStatus: agent.lifecycle,
|
||||
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
|
||||
config: config ?? null,
|
||||
persistence,
|
||||
} satisfies StoredAgentRecord;
|
||||
}
|
||||
|
||||
export function toAgentPayload(
|
||||
agent: ManagedAgent,
|
||||
options?: ProjectionOptions
|
||||
): AgentSnapshotPayload {
|
||||
const payload: AgentSnapshotPayload = {
|
||||
id: agent.id,
|
||||
provider: agent.provider,
|
||||
cwd: agent.cwd,
|
||||
model: agent.config.model ?? null,
|
||||
createdAt: agent.createdAt.toISOString(),
|
||||
updatedAt: agent.updatedAt.toISOString(),
|
||||
lastUserMessageAt: agent.lastUserMessageAt
|
||||
? agent.lastUserMessageAt.toISOString()
|
||||
: null,
|
||||
status: agent.lifecycle,
|
||||
sessionId: agent.sessionId,
|
||||
capabilities: cloneCapabilities(agent.capabilities),
|
||||
currentModeId: agent.currentModeId,
|
||||
availableModes: cloneAvailableModes(agent.availableModes),
|
||||
pendingPermissions: sanitizePendingPermissions(agent.pendingPermissions),
|
||||
persistence: sanitizePersistenceHandle(agent.persistence),
|
||||
title: options?.title ?? null,
|
||||
};
|
||||
|
||||
const usage = sanitizeOptionalJsonValue<AgentUsage>(agent.lastUsage);
|
||||
if (usage !== undefined) {
|
||||
payload.lastUsage = usage;
|
||||
}
|
||||
|
||||
if (agent.lastError !== undefined) {
|
||||
payload.lastError = agent.lastError;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function buildSerializableConfig(
|
||||
config: AgentSessionConfig
|
||||
): SerializableAgentConfig | null {
|
||||
const serializable: SerializableAgentConfig = {};
|
||||
if (config.modeId) {
|
||||
serializable.modeId = config.modeId;
|
||||
}
|
||||
if (config.model) {
|
||||
serializable.model = config.model;
|
||||
}
|
||||
const extra = sanitizeOptionalJsonValue(config.extra);
|
||||
if (extra !== undefined) {
|
||||
serializable.extra = extra;
|
||||
}
|
||||
return Object.keys(serializable).length ? serializable : null;
|
||||
}
|
||||
|
||||
function sanitizePendingPermissions(
|
||||
pending: Map<string, AgentPermissionRequest>
|
||||
): AgentPermissionRequest[] {
|
||||
return Array.from(pending.values()).map((request) => (
|
||||
{
|
||||
...request,
|
||||
input: sanitizeOptionalJsonValue(request.input),
|
||||
suggestions: sanitizeOptionalJsonValue(request.suggestions),
|
||||
metadata: sanitizeOptionalJsonValue(request.metadata),
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
function sanitizePersistenceHandle(
|
||||
handle: AgentPersistenceHandle | null
|
||||
): AgentPersistenceHandle | null {
|
||||
if (!handle) {
|
||||
return null;
|
||||
}
|
||||
const sanitized: AgentPersistenceHandle = {
|
||||
provider: handle.provider,
|
||||
sessionId: handle.sessionId,
|
||||
};
|
||||
if (handle.nativeHandle !== undefined) {
|
||||
sanitized.nativeHandle = handle.nativeHandle;
|
||||
}
|
||||
const metadata = sanitizeOptionalJsonValue(handle.metadata);
|
||||
if (metadata !== undefined) {
|
||||
sanitized.metadata = metadata;
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function cloneCapabilities(
|
||||
capabilities: AgentCapabilityFlags
|
||||
): AgentCapabilityFlags {
|
||||
return { ...capabilities };
|
||||
}
|
||||
|
||||
function cloneAvailableModes(modes: AgentMode[]): AgentMode[] {
|
||||
return modes.map((mode) => ({ ...mode }));
|
||||
}
|
||||
|
||||
function sanitizeOptionalJson(value: unknown): unknown {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const sanitized = value
|
||||
.map((item) => sanitizeOptionalJson(item))
|
||||
.filter((item) => item !== undefined);
|
||||
return sanitized;
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
||||
const sanitized = sanitizeOptionalJson(val);
|
||||
if (sanitized !== undefined) {
|
||||
result[key] = sanitized;
|
||||
}
|
||||
}
|
||||
return Object.keys(result).length ? result : undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sanitizeOptionalJsonValue<T>(
|
||||
value: T | null | undefined
|
||||
): T | undefined {
|
||||
const sanitized = sanitizeOptionalJson(value);
|
||||
return sanitized == null ? undefined : (sanitized as T);
|
||||
}
|
||||
@@ -4,34 +4,81 @@ import path from "node:path";
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
||||
|
||||
import { AgentRegistry } from "./agent-registry.js";
|
||||
import type { AgentSnapshot } from "./agent-manager.js";
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
} from "./agent-sdk-types.js";
|
||||
|
||||
function createSnapshot(overrides?: Partial<AgentSnapshot>): AgentSnapshot {
|
||||
const now = new Date();
|
||||
return {
|
||||
id: "agent-test",
|
||||
provider: "claude",
|
||||
cwd: "/tmp/project",
|
||||
model: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastUserMessageAt: null,
|
||||
status: "idle",
|
||||
sessionId: null,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
...overrides,
|
||||
type ManagedAgentOverrides = Omit<
|
||||
Partial<ManagedAgent>,
|
||||
"config" | "pendingPermissions" | "session" | "pendingRun"
|
||||
> & {
|
||||
config?: Partial<AgentSessionConfig>;
|
||||
pendingPermissions?: Map<string, AgentPermissionRequest>;
|
||||
session?: AgentSession | null;
|
||||
pendingRun?: ManagedAgent["pendingRun"];
|
||||
};
|
||||
|
||||
function createManagedAgent(
|
||||
overrides: ManagedAgentOverrides = {}
|
||||
): ManagedAgent {
|
||||
const now = overrides.updatedAt ?? new Date("2025-01-01T00:00:00.000Z");
|
||||
const provider = overrides.provider ?? "claude";
|
||||
const cwd = overrides.cwd ?? "/tmp/project";
|
||||
const lifecycle = overrides.lifecycle ?? "idle";
|
||||
const configOverrides = overrides.config ?? {};
|
||||
const config: AgentSessionConfig = {
|
||||
provider,
|
||||
cwd,
|
||||
modeId: configOverrides.modeId ?? "plan",
|
||||
model: configOverrides.model ?? "gpt-5.1",
|
||||
extra: configOverrides.extra ?? { claude: { maxThinkingTokens: 1024 } },
|
||||
};
|
||||
const session =
|
||||
lifecycle === "closed"
|
||||
? null
|
||||
: overrides.session ?? ({} as AgentSession);
|
||||
const pendingRun =
|
||||
overrides.pendingRun ??
|
||||
(lifecycle === "running" ? (async function* noop() {})() : null);
|
||||
|
||||
const agent: ManagedAgent = {
|
||||
id: overrides.id ?? "agent-test",
|
||||
provider,
|
||||
cwd,
|
||||
session,
|
||||
sessionId: overrides.sessionId ?? "session-123",
|
||||
capabilities:
|
||||
overrides.capabilities ??
|
||||
{
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
config,
|
||||
lifecycle,
|
||||
createdAt: overrides.createdAt ?? now,
|
||||
updatedAt: overrides.updatedAt ?? now,
|
||||
availableModes: overrides.availableModes ?? [],
|
||||
currentModeId: overrides.currentModeId ?? config.modeId ?? null,
|
||||
pendingPermissions:
|
||||
overrides.pendingPermissions ??
|
||||
new Map<string, AgentPermissionRequest>(),
|
||||
pendingRun,
|
||||
timeline: overrides.timeline ?? [],
|
||||
persistence: overrides.persistence ?? null,
|
||||
historyPrimed: overrides.historyPrimed ?? true,
|
||||
lastUserMessageAt: overrides.lastUserMessageAt ?? now,
|
||||
lastUsage: overrides.lastUsage,
|
||||
lastError: overrides.lastError,
|
||||
};
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
describe("AgentRegistry", () => {
|
||||
@@ -49,23 +96,18 @@ describe("AgentRegistry", () => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("persists configs and snapshot metadata", async () => {
|
||||
await registry.recordConfig(
|
||||
"agent-1",
|
||||
"claude",
|
||||
"/tmp/project",
|
||||
{
|
||||
modeId: "coding",
|
||||
model: "gpt-5.1",
|
||||
extra: { claude: { maxThinkingTokens: 1024 } },
|
||||
}
|
||||
);
|
||||
|
||||
test("applySnapshot persists configs and snapshot metadata", async () => {
|
||||
await registry.applySnapshot(
|
||||
createSnapshot({
|
||||
createManagedAgent({
|
||||
id: "agent-1",
|
||||
cwd: "/tmp/project",
|
||||
currentModeId: "coding",
|
||||
status: "idle",
|
||||
lifecycle: "idle",
|
||||
config: {
|
||||
modeId: "coding",
|
||||
model: "gpt-5.1",
|
||||
extra: { claude: { maxThinkingTokens: 1024 } },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -84,9 +126,33 @@ describe("AgentRegistry", () => {
|
||||
expect(persisted.config?.extra?.claude).toMatchObject({ maxThinkingTokens: 1024 });
|
||||
});
|
||||
|
||||
test("applySnapshot preserves original createdAt timestamp", async () => {
|
||||
const agentId = "agent-created-at";
|
||||
const firstTimestamp = new Date("2025-01-01T00:00:00.000Z");
|
||||
await registry.applySnapshot(
|
||||
createManagedAgent({ id: agentId, createdAt: firstTimestamp })
|
||||
);
|
||||
|
||||
const initialRecord = await registry.get(agentId);
|
||||
expect(initialRecord?.createdAt).toBe(firstTimestamp.toISOString());
|
||||
|
||||
await registry.applySnapshot(
|
||||
createManagedAgent({
|
||||
id: agentId,
|
||||
createdAt: new Date("2025-02-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2025-02-01T00:00:00.000Z"),
|
||||
lifecycle: "running",
|
||||
})
|
||||
);
|
||||
|
||||
const updatedRecord = await registry.get(agentId);
|
||||
expect(updatedRecord?.createdAt).toBe(firstTimestamp.toISOString());
|
||||
expect(updatedRecord?.lastStatus).toBe("running");
|
||||
});
|
||||
|
||||
test("stores titles independently of snapshots", async () => {
|
||||
await registry.applySnapshot(
|
||||
createSnapshot({
|
||||
createManagedAgent({
|
||||
id: "agent-2",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/second",
|
||||
@@ -102,18 +168,30 @@ describe("AgentRegistry", () => {
|
||||
expect(persisted?.title).toBe("Fix Login Bug");
|
||||
});
|
||||
|
||||
test("recordConfig seeds lastModeId before snapshots", async () => {
|
||||
await registry.recordConfig(
|
||||
"agent-3",
|
||||
"claude",
|
||||
"/tmp/project",
|
||||
{
|
||||
modeId: "plan",
|
||||
}
|
||||
test("applySnapshot preserves custom titles while updating metadata", async () => {
|
||||
const agentId = "agent-3";
|
||||
await registry.applySnapshot(
|
||||
createManagedAgent({
|
||||
id: agentId,
|
||||
lifecycle: "idle",
|
||||
currentModeId: "plan",
|
||||
})
|
||||
);
|
||||
await registry.setTitle(agentId, "Important Bug Fix");
|
||||
|
||||
await registry.applySnapshot(
|
||||
createManagedAgent({
|
||||
id: agentId,
|
||||
lifecycle: "running",
|
||||
currentModeId: "build",
|
||||
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
|
||||
})
|
||||
);
|
||||
|
||||
const record = await registry.get("agent-3");
|
||||
expect(record?.lastModeId).toBe("plan");
|
||||
const record = await registry.get(agentId);
|
||||
expect(record?.title).toBe("Important Bug Fix");
|
||||
expect(record?.lastModeId).toBe("build");
|
||||
expect(record?.lastStatus).toBe("running");
|
||||
});
|
||||
|
||||
test("recovers from trailing garbage in agents.json", async () => {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AgentSnapshot } from "./agent-manager.js";
|
||||
import type { AgentProvider, AgentSessionConfig } from "./agent-sdk-types.js";
|
||||
import { AgentStatusSchema } from "../messages.js";
|
||||
import { resolvePaseoHome } from "../config.js";
|
||||
import { toStoredAgentRecord } from "./agent-projections.js";
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentSessionConfig } from "./agent-sdk-types.js";
|
||||
|
||||
const SERIALIZABLE_CONFIG_SCHEMA = z
|
||||
.object({
|
||||
@@ -36,7 +37,7 @@ const STORED_AGENT_SCHEMA = z.object({
|
||||
lastActivityAt: z.string().optional(),
|
||||
lastUserMessageAt: z.string().nullable().optional(),
|
||||
title: z.string().nullable().optional(),
|
||||
lastStatus: z.string().nullable().optional(),
|
||||
lastStatus: AgentStatusSchema.default("closed"),
|
||||
lastModeId: z.string().nullable().optional(),
|
||||
config: SERIALIZABLE_CONFIG_SCHEMA,
|
||||
persistence: PERSISTENCE_HANDLE_SCHEMA,
|
||||
@@ -55,8 +56,7 @@ export class AgentRegistry {
|
||||
private filePath: string;
|
||||
|
||||
constructor(filePath?: string) {
|
||||
this.filePath =
|
||||
filePath ?? path.join(resolveServerPackageRoot(), "agents.json");
|
||||
this.filePath = filePath ?? path.join(resolvePaseoHome(), "agents.json");
|
||||
}
|
||||
|
||||
async load(): Promise<StoredAgentRecord[]> {
|
||||
@@ -102,78 +102,14 @@ export class AgentRegistry {
|
||||
await this.flush();
|
||||
}
|
||||
|
||||
async recordConfig(
|
||||
agentId: string,
|
||||
provider: AgentProvider,
|
||||
cwd: string,
|
||||
config?: SerializableAgentConfig
|
||||
): Promise<void> {
|
||||
async applySnapshot(agent: ManagedAgent): Promise<void> {
|
||||
await this.load();
|
||||
const now = new Date().toISOString();
|
||||
const existing = this.cache.get(agentId);
|
||||
const sanitizedConfig = config ? sanitizeConfig(config) : existing?.config;
|
||||
const nextModeId =
|
||||
config?.modeId ??
|
||||
existing?.lastModeId ??
|
||||
sanitizedConfig?.modeId ??
|
||||
null;
|
||||
const updated: StoredAgentRecord = {
|
||||
id: agentId,
|
||||
provider,
|
||||
cwd,
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
lastActivityAt: existing?.lastActivityAt ?? existing?.updatedAt ?? now,
|
||||
lastUserMessageAt: existing?.lastUserMessageAt ?? null,
|
||||
const existing = this.cache.get(agent.id);
|
||||
const record = toStoredAgentRecord(agent, {
|
||||
title: existing?.title ?? null,
|
||||
lastStatus: existing?.lastStatus ?? null,
|
||||
lastModeId: nextModeId,
|
||||
config: sanitizedConfig,
|
||||
persistence: existing?.persistence ?? null,
|
||||
};
|
||||
this.cache.set(agentId, updated);
|
||||
await this.flush();
|
||||
}
|
||||
|
||||
async applySnapshot(snapshot: AgentSnapshot): Promise<void> {
|
||||
await this.load();
|
||||
const now = new Date().toISOString();
|
||||
const existing = this.cache.get(snapshot.id);
|
||||
if (!existing) {
|
||||
const record: StoredAgentRecord = {
|
||||
id: snapshot.id,
|
||||
provider: snapshot.provider,
|
||||
cwd: snapshot.cwd,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastActivityAt: snapshot.updatedAt.toISOString(),
|
||||
lastUserMessageAt: snapshot.lastUserMessageAt
|
||||
? snapshot.lastUserMessageAt.toISOString()
|
||||
: null,
|
||||
title: null,
|
||||
lastStatus: snapshot.status,
|
||||
lastModeId: snapshot.currentModeId ?? null,
|
||||
config: null,
|
||||
persistence: snapshot.persistence ?? null,
|
||||
};
|
||||
this.cache.set(snapshot.id, record);
|
||||
await this.flush();
|
||||
return;
|
||||
}
|
||||
const updated: StoredAgentRecord = {
|
||||
...existing,
|
||||
provider: snapshot.provider,
|
||||
cwd: snapshot.cwd,
|
||||
updatedAt: now,
|
||||
lastActivityAt: snapshot.updatedAt.toISOString(),
|
||||
lastUserMessageAt: snapshot.lastUserMessageAt
|
||||
? snapshot.lastUserMessageAt.toISOString()
|
||||
: existing.lastUserMessageAt ?? null,
|
||||
lastStatus: snapshot.status,
|
||||
lastModeId: snapshot.currentModeId ?? null,
|
||||
persistence: snapshot.persistence ?? existing.persistence ?? null,
|
||||
};
|
||||
this.cache.set(snapshot.id, updated);
|
||||
createdAt: existing?.createdAt,
|
||||
});
|
||||
this.cache.set(agent.id, record);
|
||||
await this.flush();
|
||||
}
|
||||
|
||||
@@ -250,35 +186,6 @@ export class AgentRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeConfig(
|
||||
config: SerializableAgentConfig | undefined
|
||||
): SerializableAgentConfig | undefined {
|
||||
if (!config) {
|
||||
return undefined;
|
||||
}
|
||||
const cleaned: SerializableAgentConfig = {};
|
||||
if (config.modeId) cleaned.modeId = config.modeId;
|
||||
if (config.model) cleaned.model = config.model;
|
||||
if (config.extra) cleaned.extra = JSON.parse(JSON.stringify(config.extra));
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
function resolveServerPackageRoot(): string {
|
||||
let currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
while (true) {
|
||||
if (existsSync(path.join(currentDir, "package.json"))) {
|
||||
return currentDir;
|
||||
}
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir) {
|
||||
throw new Error(
|
||||
"[AgentRegistry] Failed to locate server package root for agents.json"
|
||||
);
|
||||
}
|
||||
currentDir = parentDir;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeFileAtomically(targetPath: string, payload: string) {
|
||||
const directory = path.dirname(targetPath);
|
||||
const tempPath = path.join(
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
} from "./agent-sdk-types.js";
|
||||
import type {
|
||||
AgentManager,
|
||||
AgentSnapshot,
|
||||
ManagedAgent,
|
||||
WaitForAgentResult,
|
||||
} from "./agent-manager.js";
|
||||
import {
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
AgentSnapshotPayloadSchema,
|
||||
serializeAgentSnapshot,
|
||||
} from "../messages.js";
|
||||
import { toAgentPayload } from "./agent-projections.js";
|
||||
import { curateAgentActivity } from "./activity-curator.js";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-manifest.js";
|
||||
import { AgentRegistry } from "./agent-registry.js";
|
||||
@@ -90,33 +91,12 @@ async function resolveAgentTitle(
|
||||
|
||||
async function serializeSnapshotWithMetadata(
|
||||
agentRegistry: AgentRegistry,
|
||||
snapshot: AgentSnapshot
|
||||
snapshot: ManagedAgent
|
||||
) {
|
||||
const title = await resolveAgentTitle(agentRegistry, snapshot.id);
|
||||
return serializeAgentSnapshot(snapshot, { title });
|
||||
}
|
||||
|
||||
function buildActivityPayload(
|
||||
agentManager: AgentManager,
|
||||
agentId: string
|
||||
): {
|
||||
format: "curated";
|
||||
updateCount: number;
|
||||
currentModeId: string | null;
|
||||
content: string;
|
||||
} {
|
||||
const timeline = agentManager.getTimeline(agentId);
|
||||
const snapshot = agentManager.getAgent(agentId);
|
||||
const curatedText = curateAgentActivity(timeline);
|
||||
|
||||
return {
|
||||
format: "curated",
|
||||
updateCount: timeline.length,
|
||||
currentModeId: snapshot?.currentModeId ?? null,
|
||||
content: curatedText,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createAgentMcpServer(
|
||||
options: AgentMcpServerOptions
|
||||
): Promise<McpServer> {
|
||||
@@ -159,6 +139,13 @@ export async function createAgentMcpServer(
|
||||
.describe(
|
||||
"Optional git worktree branch name (lowercase alphanumerics + hyphen)."
|
||||
),
|
||||
background: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe(
|
||||
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately."
|
||||
),
|
||||
},
|
||||
outputSchema: {
|
||||
agentId: z.string(),
|
||||
@@ -173,9 +160,11 @@ export async function createAgentMcpServer(
|
||||
description: z.string().nullable().optional(),
|
||||
})
|
||||
),
|
||||
lastMessage: z.string().nullable().optional(),
|
||||
permission: AgentPermissionRequestPayloadSchema.nullable().optional(),
|
||||
},
|
||||
},
|
||||
async ({ cwd, agentType, initialPrompt, initialMode, worktreeName }) => {
|
||||
async ({ cwd, agentType, initialPrompt, initialMode, worktreeName, background = false }) => {
|
||||
let resolvedCwd = expandPath(cwd);
|
||||
|
||||
if (worktreeName) {
|
||||
@@ -203,27 +192,56 @@ export async function createAgentMcpServer(
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
startAgentRun(agentManager, snapshot.id, initialPrompt);
|
||||
|
||||
// If not running in background, wait for completion
|
||||
if (!background) {
|
||||
const result = await agentManager.waitForAgentEvent(snapshot.id);
|
||||
|
||||
const responseData = {
|
||||
agentId: snapshot.id,
|
||||
type: provider,
|
||||
status: result.status,
|
||||
cwd: snapshot.cwd,
|
||||
currentModeId: snapshot.currentModeId,
|
||||
availableModes: snapshot.availableModes,
|
||||
lastMessage: result.lastMessage,
|
||||
permission: result.permission,
|
||||
};
|
||||
const validJson = ensureValidJson(responseData);
|
||||
|
||||
const response = {
|
||||
content: [],
|
||||
structuredContent: validJson,
|
||||
};
|
||||
return response;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Agent MCP] Failed to run initial prompt for ${snapshot.id}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
return {
|
||||
// Return immediately if background=true or no initialPrompt
|
||||
const response = {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({
|
||||
agentId: snapshot.id,
|
||||
type: provider,
|
||||
status: snapshot.status,
|
||||
status: snapshot.lifecycle,
|
||||
cwd: snapshot.cwd,
|
||||
currentModeId: snapshot.currentModeId,
|
||||
availableModes: snapshot.availableModes,
|
||||
lastMessage: null,
|
||||
permission: null,
|
||||
}),
|
||||
};
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -240,12 +258,7 @@ export async function createAgentMcpServer(
|
||||
agentId: z.string(),
|
||||
status: AgentStatusEnum,
|
||||
permission: AgentPermissionRequestPayloadSchema.nullable(),
|
||||
activity: z.object({
|
||||
format: z.literal("curated"),
|
||||
updateCount: z.number(),
|
||||
currentModeId: z.string().nullable(),
|
||||
content: z.string(),
|
||||
}),
|
||||
lastMessage: z.string().nullable(),
|
||||
},
|
||||
},
|
||||
async ({ agentId }, { signal }) => {
|
||||
@@ -293,17 +306,19 @@ export async function createAgentMcpServer(
|
||||
await agentManager.waitForAgentEvent(agentId, {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
const activity = buildActivityPayload(agentManager, agentId);
|
||||
|
||||
return {
|
||||
const validJson = ensureValidJson({
|
||||
agentId,
|
||||
status: result.status,
|
||||
permission: result.permission,
|
||||
lastMessage: result.lastMessage,
|
||||
});
|
||||
|
||||
const response = {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({
|
||||
agentId,
|
||||
status: result.status,
|
||||
permission: result.permission,
|
||||
activity,
|
||||
}),
|
||||
structuredContent: validJson,
|
||||
};
|
||||
return response;
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
@@ -323,13 +338,23 @@ export async function createAgentMcpServer(
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional mode to set before running the prompt."),
|
||||
background: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe(
|
||||
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately."
|
||||
),
|
||||
},
|
||||
outputSchema: {
|
||||
success: z.boolean(),
|
||||
status: AgentStatusEnum,
|
||||
lastMessage: z.string().nullable().optional(),
|
||||
permission: AgentPermissionRequestPayloadSchema.nullable().optional(),
|
||||
},
|
||||
},
|
||||
async ({ agentId, prompt, sessionMode }) => {
|
||||
async ({ agentId, prompt, sessionMode, background = false }) => {
|
||||
|
||||
if (sessionMode) {
|
||||
await agentManager.setAgentMode(agentId, sessionMode);
|
||||
}
|
||||
@@ -344,15 +369,43 @@ export async function createAgentMcpServer(
|
||||
}
|
||||
|
||||
startAgentRun(agentManager, agentId, prompt);
|
||||
|
||||
// If not running in background, wait for completion
|
||||
if (!background) {
|
||||
const result = await agentManager.waitForAgentEvent(agentId);
|
||||
|
||||
const responseData = {
|
||||
success: true,
|
||||
status: result.status,
|
||||
lastMessage: result.lastMessage,
|
||||
permission: result.permission,
|
||||
};
|
||||
const validJson = ensureValidJson(responseData);
|
||||
|
||||
const response = {
|
||||
content: [],
|
||||
structuredContent: validJson,
|
||||
};
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
// Return immediately if background=true
|
||||
const snapshot = agentManager.getAgent(agentId);
|
||||
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({
|
||||
success: true,
|
||||
status: snapshot?.status ?? "idle",
|
||||
}),
|
||||
const responseData = {
|
||||
success: true,
|
||||
status: snapshot?.lifecycle ?? "idle",
|
||||
lastMessage: null,
|
||||
permission: null,
|
||||
};
|
||||
const validJson = ensureValidJson(responseData);
|
||||
|
||||
const response = {
|
||||
content: [],
|
||||
structuredContent: validJson,
|
||||
};
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -383,7 +436,7 @@ export async function createAgentMcpServer(
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({
|
||||
status: snapshot.status,
|
||||
status: snapshot.lifecycle,
|
||||
snapshot: structuredSnapshot,
|
||||
}),
|
||||
};
|
||||
@@ -564,13 +617,14 @@ export async function createAgentMcpServer(
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
const permissions = agentManager.listAgents().flatMap((agent) =>
|
||||
agent.pendingPermissions.map((request) => ({
|
||||
const permissions = agentManager.listAgents().flatMap((agent) => {
|
||||
const payload = toAgentPayload(agent);
|
||||
return payload.pendingPermissions.map((request) => ({
|
||||
agentId: agent.id,
|
||||
status: agent.status,
|
||||
status: payload.status,
|
||||
request,
|
||||
}))
|
||||
);
|
||||
}));
|
||||
});
|
||||
|
||||
return {
|
||||
content: [],
|
||||
|
||||
@@ -13,6 +13,16 @@ import {
|
||||
import type { AgentStreamEventPayload } from "../../messages.js";
|
||||
import type { AgentProvider, AgentSessionConfig, AgentStreamEvent, AgentTimelineItem } from "../agent-sdk-types.js";
|
||||
|
||||
const claudeIntegrationEnabled =
|
||||
process.env.RUN_CLAUDE_AGENT_TESTS === "1" || Boolean(process.env.ANTHROPIC_API_KEY?.trim()?.length);
|
||||
const describeClaudeIntegration = claudeIntegrationEnabled ? describe : describe.skip;
|
||||
|
||||
if (!claudeIntegrationEnabled) {
|
||||
console.warn(
|
||||
"Skipping ClaudeAgentClient integration tests. Set RUN_CLAUDE_AGENT_TESTS=1 and provide ANTHROPIC_API_KEY to enable them."
|
||||
);
|
||||
}
|
||||
|
||||
function tmpCwd(): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "claude-agent-e2e-"));
|
||||
try {
|
||||
@@ -62,7 +72,7 @@ function isSleepCommandToolCall(item: ToolCallItem): boolean {
|
||||
return inputCommand.includes("sleep 60");
|
||||
}
|
||||
|
||||
describe("ClaudeAgentClient (SDK integration)", () => {
|
||||
describeClaudeIntegration("ClaudeAgentClient (SDK integration)", () => {
|
||||
test(
|
||||
"responds with text",
|
||||
async () => {
|
||||
@@ -247,14 +257,24 @@ describe("ClaudeAgentClient (SDK integration)", () => {
|
||||
!item.displayName.startsWith("permission:")
|
||||
);
|
||||
const fileChangeEvent = toolCalls.find((item) => {
|
||||
if (!item.output || typeof item.output !== "object") {
|
||||
return false;
|
||||
// Check for file changes in structured output.files array
|
||||
if (item.output && typeof item.output === "object") {
|
||||
const files = (item.output as Record<string, unknown>).files;
|
||||
if (Array.isArray(files) && files.some((file) => typeof file?.path === "string" && file.path.includes("tool-test.txt"))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const files = (item.output as Record<string, unknown>).files;
|
||||
if (!Array.isArray(files)) {
|
||||
return false;
|
||||
// Also check for file path in output structure (write/edit tools)
|
||||
if (item.output && typeof item.output === "object") {
|
||||
const output = item.output as Record<string, unknown>;
|
||||
if (output.type === "file_write" || output.type === "file_edit") {
|
||||
const filePath = output.filePath;
|
||||
if (typeof filePath === "string" && filePath.includes("tool-test.txt")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return files.some((file) => typeof file?.path === "string" && file.path.includes("tool-test.txt"));
|
||||
return false;
|
||||
});
|
||||
|
||||
const sawPwdCommand = commandEvents.some(
|
||||
@@ -521,10 +541,10 @@ describe("ClaudeAgentClient (SDK integration)", () => {
|
||||
(snapshot.data.displayName ?? "").toLowerCase().includes("pwd")
|
||||
);
|
||||
const editTool = liveSnapshots.find((snapshot) =>
|
||||
rawContainsText(snapshot.data.raw, "hydrate-proof.txt")
|
||||
rawContainsText(snapshot.data.result, "hydrate-proof.txt")
|
||||
);
|
||||
const readTool = liveSnapshots.find((snapshot) =>
|
||||
rawContainsText(snapshot.data.raw, "HYDRATION_PROOF_LINE_TWO")
|
||||
rawContainsText(snapshot.data.result, "HYDRATION_PROOF_LINE_TWO")
|
||||
);
|
||||
|
||||
expect(commandTool).toBeTruthy();
|
||||
@@ -561,11 +581,10 @@ describe("ClaudeAgentClient (SDK integration)", () => {
|
||||
commandTool!,
|
||||
hydratedMap,
|
||||
(data) =>
|
||||
rawContainsText(data.raw, cwd) ||
|
||||
rawContainsText(data.result, cwd),
|
||||
({ live, hydrated }) => {
|
||||
expect(rawContainsText(live.raw, cwd)).toBe(true);
|
||||
expect(rawContainsText(hydrated.raw, cwd)).toBe(true);
|
||||
expect(rawContainsText(live.result, cwd)).toBe(true);
|
||||
expect(rawContainsText(hydrated.result, cwd)).toBe(true);
|
||||
expect((live.displayName ?? "").toLowerCase()).toContain("pwd");
|
||||
expect((hydrated.displayName ?? "").toLowerCase()).toContain("pwd");
|
||||
}
|
||||
@@ -574,13 +593,12 @@ describe("ClaudeAgentClient (SDK integration)", () => {
|
||||
editTool!,
|
||||
hydratedMap,
|
||||
(data) =>
|
||||
Array.isArray((data.result as any)?.files) &&
|
||||
((data.result as any).files as Array<{ path?: string }>).some((entry) =>
|
||||
(entry.path ?? "").includes("hydrate-proof.txt")
|
||||
),
|
||||
(data.result as any)?.type === "file_write" &&
|
||||
typeof (data.result as any)?.filePath === "string" &&
|
||||
((data.result as any).filePath as string).includes("hydrate-proof.txt"),
|
||||
({ live, hydrated }) => {
|
||||
const liveDiff = JSON.stringify(live.result ?? live.raw ?? {});
|
||||
const hydratedDiff = JSON.stringify(hydrated.result ?? hydrated.raw ?? {});
|
||||
const liveDiff = JSON.stringify(live.result ?? {});
|
||||
const hydratedDiff = JSON.stringify(hydrated.result ?? {});
|
||||
expect(liveDiff).toContain("hydrate-proof.txt");
|
||||
expect(hydratedDiff).toContain("hydrate-proof.txt");
|
||||
}
|
||||
@@ -589,11 +607,11 @@ describe("ClaudeAgentClient (SDK integration)", () => {
|
||||
readTool!,
|
||||
hydratedMap,
|
||||
(data) =>
|
||||
rawContainsText(data.raw, "HYDRATION_PROOF_LINE_ONE") &&
|
||||
rawContainsText(data.raw, "HYDRATION_PROOF_LINE_TWO"),
|
||||
rawContainsText(data.result, "HYDRATION_PROOF_LINE_ONE") &&
|
||||
rawContainsText(data.result, "HYDRATION_PROOF_LINE_TWO"),
|
||||
({ live, hydrated }) => {
|
||||
const liveReads = JSON.stringify(live.raw ?? {});
|
||||
const hydratedReads = JSON.stringify(hydrated.raw ?? {});
|
||||
const liveReads = JSON.stringify(live.result ?? {});
|
||||
const hydratedReads = JSON.stringify(hydrated.result ?? {});
|
||||
expect(liveReads).toContain("HYDRATION_PROOF_LINE_ONE");
|
||||
expect(hydratedReads).toContain("HYDRATION_PROOF_LINE_ONE");
|
||||
expect(liveReads).toContain("HYDRATION_PROOF_LINE_TWO");
|
||||
@@ -721,7 +739,6 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
{
|
||||
type: "user_message",
|
||||
text: "Run npm test",
|
||||
raw: entry.message,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ import type {
|
||||
ListPersistedAgentsOptions,
|
||||
PersistedAgentDescriptor,
|
||||
} from "../agent-sdk-types.js";
|
||||
import { resolvePaseoPort } from "../../config.js";
|
||||
|
||||
const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
@@ -92,7 +93,7 @@ type ClaudeAgentSessionOptions = {
|
||||
};
|
||||
|
||||
const DEFAULT_AGENT_CONTROL_MCP: AgentControlMcpConfig = {
|
||||
url: "http://127.0.0.1:6767/mcp/agents",
|
||||
url: `http://127.0.0.1:${resolvePaseoPort()}/mcp/agents`,
|
||||
headers: {
|
||||
Authorization: "Basic bW86Ym8=",
|
||||
},
|
||||
@@ -491,6 +492,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
permissionMode: this.currentMode,
|
||||
agents: this.defaults?.agents,
|
||||
canUseTool: this.handlePermissionRequest,
|
||||
stderr: (data: string) => {
|
||||
console.error("[ClaudeAgentSDK]", data.trim());
|
||||
},
|
||||
...this.config.extra?.claude,
|
||||
};
|
||||
|
||||
|
||||
37
packages/server/src/server/config.ts
Normal file
37
packages/server/src/server/config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
let cachedHomeDir: string | null = null;
|
||||
let cachedPort: number | null = null;
|
||||
|
||||
function expandHomeDir(input: string): string {
|
||||
if (input.startsWith("~/")) {
|
||||
return path.join(os.homedir(), input.slice(2));
|
||||
}
|
||||
if (input === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
export function resolvePaseoHome(): string {
|
||||
if (cachedHomeDir) {
|
||||
return cachedHomeDir;
|
||||
}
|
||||
const raw = process.env.PASEO_HOME ?? process.env.PASEO_HOME_DIR ?? "~/.paseo";
|
||||
const expanded = path.resolve(expandHomeDir(raw));
|
||||
mkdirSync(expanded, { recursive: true });
|
||||
cachedHomeDir = expanded;
|
||||
return cachedHomeDir;
|
||||
}
|
||||
|
||||
export function resolvePaseoPort(): number {
|
||||
if (cachedPort !== null) {
|
||||
return cachedPort;
|
||||
}
|
||||
const raw = process.env.PASEO_PORT ?? process.env.PORT ?? "6767";
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
cachedPort = Number.isFinite(parsed) ? parsed : 6767;
|
||||
return cachedPort;
|
||||
}
|
||||
@@ -11,11 +11,9 @@ import { AgentManager } from "./agent/agent-manager.js";
|
||||
import { AgentRegistry } from "./agent/agent-registry.js";
|
||||
import { ClaudeAgentClient } from "./agent/providers/claude-agent.js";
|
||||
import { CodexAgentClient } from "./agent/providers/codex-agent.js";
|
||||
import { resolvePaseoPort } from "./config.js";
|
||||
import { initializeTitleGenerator } from "../services/agent-title-generator.js";
|
||||
import {
|
||||
attachAgentRegistryPersistence,
|
||||
restorePersistedAgents,
|
||||
} from "./persistence-hooks.js";
|
||||
import { attachAgentRegistryPersistence } from "./persistence-hooks.js";
|
||||
import { createAgentMcpServer } from "./agent/mcp-server.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||
@@ -76,7 +74,7 @@ function createServer() {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const port = parseInt(process.env.PORT || "6767", 10);
|
||||
const port = resolvePaseoPort();
|
||||
const agentMcpRoute = "/mcp/agents";
|
||||
const agentMcpUrl = `http://127.0.0.1:${port}${agentMcpRoute}`;
|
||||
const [agentMcpUser, agentMcpPassword] =
|
||||
@@ -108,17 +106,22 @@ async function main() {
|
||||
});
|
||||
|
||||
attachAgentRegistryPersistence(agentManager, agentRegistry);
|
||||
const persistedRecords = await agentRegistry.list();
|
||||
console.log(
|
||||
`✓ Agent registry loaded (${persistedRecords.length} record${
|
||||
persistedRecords.length === 1 ? "" : "s"
|
||||
}); agents will initialize on demand`
|
||||
);
|
||||
|
||||
await restorePersistedAgents(agentManager, agentRegistry);
|
||||
console.log("✓ Global agent manager initialized with persisted agents");
|
||||
|
||||
const agentMcpServer = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentRegistry,
|
||||
});
|
||||
const agentMcpTransports: AgentMcpTransportMap = new Map();
|
||||
|
||||
const createAgentMcpTransport = async () => {
|
||||
// Create a NEW McpServer instance per session (not shared across sessions)
|
||||
const agentMcpServer = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentRegistry,
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: (sessionId) => {
|
||||
@@ -140,7 +143,9 @@ async function main() {
|
||||
transport.onerror = (error) => {
|
||||
console.error("[Agent MCP] Transport error:", error);
|
||||
};
|
||||
|
||||
await agentMcpServer.connect(transport);
|
||||
|
||||
return transport;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { z } from "zod";
|
||||
import type { AgentSnapshot } from "./agent/agent-manager.js";
|
||||
import {
|
||||
AGENT_LIFECYCLE_STATUSES,
|
||||
type ManagedAgent,
|
||||
} from "./agent/agent-manager.js";
|
||||
import { toAgentPayload } from "./agent/agent-projections.js";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentModelDefinition,
|
||||
@@ -13,27 +17,10 @@ import type {
|
||||
AgentUsage,
|
||||
} from "./agent/agent-sdk-types.js";
|
||||
|
||||
export type AgentSnapshotPayload = Omit<
|
||||
AgentSnapshot,
|
||||
"createdAt" | "updatedAt" | "lastUserMessageAt"
|
||||
> & {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastUserMessageAt: string | null;
|
||||
title: string | null;
|
||||
};
|
||||
|
||||
const AGENT_PROVIDERS: [AgentProvider, AgentProvider] = ["claude", "codex"];
|
||||
const AgentProviderSchema = z.enum(AGENT_PROVIDERS);
|
||||
|
||||
const AGENT_LIFECYCLE_STATUSES = [
|
||||
"initializing",
|
||||
"idle",
|
||||
"running",
|
||||
"error",
|
||||
"closed",
|
||||
] as const;
|
||||
const AgentStatusSchema = z.enum(AGENT_LIFECYCLE_STATUSES);
|
||||
export const AgentStatusSchema = z.enum(AGENT_LIFECYCLE_STATUSES);
|
||||
|
||||
const AgentModeSchema: z.ZodType<AgentMode> = z.object({
|
||||
id: z.string(),
|
||||
@@ -234,96 +221,17 @@ export const AgentSnapshotPayloadSchema = z.object({
|
||||
title: z.string().nullable(),
|
||||
});
|
||||
|
||||
function sanitizeOptionalJson(value: unknown): unknown {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => sanitizeOptionalJson(item))
|
||||
.filter((item) => item !== undefined);
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
||||
const sanitized = sanitizeOptionalJson(val);
|
||||
if (sanitized !== undefined) {
|
||||
result[key] = sanitized;
|
||||
}
|
||||
}
|
||||
return Object.keys(result).length ? result : undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sanitizeOptionalJsonValue<T>(value: T | undefined): T | undefined {
|
||||
const sanitized = sanitizeOptionalJson(value);
|
||||
return sanitized === undefined ? undefined : (sanitized as T);
|
||||
}
|
||||
|
||||
function sanitizePersistenceHandle(
|
||||
handle: AgentPersistenceHandle | null
|
||||
): AgentPersistenceHandle | null {
|
||||
if (!handle) {
|
||||
return null;
|
||||
}
|
||||
const sanitized: AgentPersistenceHandle = {
|
||||
provider: handle.provider,
|
||||
sessionId: handle.sessionId,
|
||||
};
|
||||
if (handle.nativeHandle !== undefined) {
|
||||
sanitized.nativeHandle = handle.nativeHandle;
|
||||
}
|
||||
const metadata = sanitizeOptionalJsonValue(handle.metadata);
|
||||
if (metadata !== undefined) {
|
||||
sanitized.metadata = metadata;
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
export type AgentSnapshotPayload = z.infer<typeof AgentSnapshotPayloadSchema>;
|
||||
|
||||
export type AgentStreamEventPayload = z.infer<
|
||||
typeof AgentStreamEventPayloadSchema
|
||||
>;
|
||||
|
||||
export function serializeAgentSnapshot(
|
||||
snapshot: AgentSnapshot,
|
||||
agent: ManagedAgent,
|
||||
options?: { title?: string | null }
|
||||
): AgentSnapshotPayload {
|
||||
const payload: AgentSnapshotPayload = {
|
||||
id: snapshot.id,
|
||||
provider: snapshot.provider,
|
||||
cwd: snapshot.cwd,
|
||||
model: snapshot.model,
|
||||
createdAt: snapshot.createdAt.toISOString(),
|
||||
updatedAt: snapshot.updatedAt.toISOString(),
|
||||
lastUserMessageAt: snapshot.lastUserMessageAt
|
||||
? snapshot.lastUserMessageAt.toISOString()
|
||||
: null,
|
||||
status: snapshot.status,
|
||||
sessionId: snapshot.sessionId,
|
||||
capabilities: snapshot.capabilities,
|
||||
currentModeId: snapshot.currentModeId,
|
||||
availableModes: snapshot.availableModes,
|
||||
pendingPermissions: snapshot.pendingPermissions,
|
||||
persistence: sanitizePersistenceHandle(snapshot.persistence),
|
||||
title: options?.title ?? null,
|
||||
};
|
||||
|
||||
const lastUsage = sanitizeOptionalJsonValue<AgentUsage>(snapshot.lastUsage);
|
||||
if (lastUsage !== undefined) {
|
||||
payload.lastUsage = lastUsage;
|
||||
}
|
||||
if (snapshot.lastError !== undefined) {
|
||||
payload.lastError = snapshot.lastError;
|
||||
}
|
||||
|
||||
return payload;
|
||||
return toAgentPayload(agent, options);
|
||||
}
|
||||
|
||||
export function serializeAgentStreamEvent(
|
||||
|
||||
@@ -1,38 +1,84 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { AgentSnapshot } from "./agent/agent-manager.js";
|
||||
import type { ManagedAgent } from "./agent/agent-manager.js";
|
||||
import type { StoredAgentRecord } from "./agent/agent-registry.js";
|
||||
import {
|
||||
attachAgentRegistryPersistence,
|
||||
restorePersistedAgents,
|
||||
} from "./persistence-hooks.js";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
} from "./agent/agent-sdk-types.js";
|
||||
|
||||
function createSnapshot(overrides?: Partial<AgentSnapshot>): AgentSnapshot {
|
||||
const now = new Date();
|
||||
return {
|
||||
id: "agent-1",
|
||||
provider: "claude",
|
||||
cwd: "/tmp/project",
|
||||
model: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastUserMessageAt: null,
|
||||
status: "idle",
|
||||
sessionId: null,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: "plan",
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
...overrides,
|
||||
type ManagedAgentOverrides = Omit<
|
||||
Partial<ManagedAgent>,
|
||||
"config" | "pendingPermissions" | "session" | "pendingRun"
|
||||
> & {
|
||||
config?: Partial<AgentSessionConfig>;
|
||||
pendingPermissions?: Map<string, AgentPermissionRequest>;
|
||||
session?: AgentSession | null;
|
||||
pendingRun?: ManagedAgent["pendingRun"];
|
||||
};
|
||||
|
||||
function createManagedAgent(
|
||||
overrides: ManagedAgentOverrides = {}
|
||||
): ManagedAgent {
|
||||
const now = overrides.updatedAt ?? new Date("2025-01-01T00:00:00.000Z");
|
||||
const provider = overrides.provider ?? "claude";
|
||||
const cwd = overrides.cwd ?? "/tmp/project";
|
||||
const lifecycle = overrides.lifecycle ?? "idle";
|
||||
const configOverrides = overrides.config ?? {};
|
||||
const config: AgentSessionConfig = {
|
||||
provider,
|
||||
cwd,
|
||||
modeId: configOverrides.modeId ?? "plan",
|
||||
model: configOverrides.model ?? "claude-3.5-sonnet",
|
||||
extra: configOverrides.extra ?? { claude: { tone: "focused" } },
|
||||
};
|
||||
const session =
|
||||
lifecycle === "closed"
|
||||
? null
|
||||
: overrides.session ?? ({} as AgentSession);
|
||||
const pendingRun =
|
||||
overrides.pendingRun ??
|
||||
(lifecycle === "running" ? (async function* noop() {})() : null);
|
||||
|
||||
const agent: ManagedAgent = {
|
||||
id: overrides.id ?? "agent-1",
|
||||
provider,
|
||||
cwd,
|
||||
session,
|
||||
sessionId: overrides.sessionId ?? "session-123",
|
||||
capabilities:
|
||||
overrides.capabilities ??
|
||||
{
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
config,
|
||||
lifecycle,
|
||||
createdAt: overrides.createdAt ?? now,
|
||||
updatedAt: overrides.updatedAt ?? now,
|
||||
availableModes: overrides.availableModes ?? [],
|
||||
currentModeId: overrides.currentModeId ?? config.modeId ?? null,
|
||||
pendingPermissions:
|
||||
overrides.pendingPermissions ??
|
||||
new Map<string, AgentPermissionRequest>(),
|
||||
pendingRun,
|
||||
timeline: overrides.timeline ?? [],
|
||||
persistence: overrides.persistence ?? null,
|
||||
historyPrimed: overrides.historyPrimed ?? true,
|
||||
lastUserMessageAt: overrides.lastUserMessageAt ?? now,
|
||||
lastUsage: overrides.lastUsage,
|
||||
lastError: overrides.lastError,
|
||||
};
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
function createRecord(
|
||||
@@ -58,65 +104,6 @@ function createRecord(
|
||||
}
|
||||
|
||||
describe("persistence hooks", () => {
|
||||
test("restorePersistedAgents resumes and creates agents", async () => {
|
||||
const resumeAgent = vi.fn().mockResolvedValue(null);
|
||||
const createAgent = vi.fn().mockResolvedValue(null);
|
||||
const agentManager = {
|
||||
resumeAgent,
|
||||
createAgent,
|
||||
};
|
||||
const records: StoredAgentRecord[] = [
|
||||
createRecord({
|
||||
id: "claude-agent",
|
||||
lastModeId: "plan",
|
||||
}),
|
||||
createRecord({
|
||||
id: "codex-agent",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/codex",
|
||||
lastModeId: null,
|
||||
config: { modeId: "auto", model: "gpt-4.1", extra: { codex: { policy: "auto" } } },
|
||||
persistence: null,
|
||||
}),
|
||||
createRecord({
|
||||
id: "unknown",
|
||||
provider: "mystery" as any,
|
||||
}),
|
||||
];
|
||||
const registry = {
|
||||
list: vi.fn().mockResolvedValue(records),
|
||||
applySnapshot: vi.fn(),
|
||||
};
|
||||
|
||||
await restorePersistedAgents(
|
||||
agentManager as any,
|
||||
registry as any
|
||||
);
|
||||
|
||||
expect(resumeAgent).toHaveBeenCalledTimes(1);
|
||||
expect(resumeAgent).toHaveBeenCalledWith(
|
||||
records[0].persistence,
|
||||
expect.objectContaining({
|
||||
cwd: records[0].cwd,
|
||||
modeId: records[0].lastModeId,
|
||||
model: records[0].config?.model,
|
||||
}),
|
||||
records[0].id
|
||||
);
|
||||
|
||||
expect(createAgent).toHaveBeenCalledTimes(1);
|
||||
expect(createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "codex",
|
||||
cwd: "/tmp/codex",
|
||||
modeId: "auto",
|
||||
model: "gpt-4.1",
|
||||
extra: { codex: { policy: "auto" } },
|
||||
}),
|
||||
"codex-agent"
|
||||
);
|
||||
});
|
||||
|
||||
test("attachAgentRegistryPersistence forwards agent snapshots", async () => {
|
||||
const applySnapshot = vi.fn().mockResolvedValue(undefined);
|
||||
let subscriber: (event: any) => void = () => {
|
||||
@@ -138,13 +125,13 @@ describe("persistence hooks", () => {
|
||||
} as any);
|
||||
|
||||
expect(agentManager.subscribe).toHaveBeenCalledTimes(1);
|
||||
const snapshot = createSnapshot();
|
||||
subscriber({ type: "agent_state", agent: snapshot });
|
||||
expect(applySnapshot).toHaveBeenCalledWith(snapshot);
|
||||
const agent = createManagedAgent();
|
||||
subscriber({ type: "agent_state", agent });
|
||||
expect(applySnapshot).toHaveBeenCalledWith(agent);
|
||||
|
||||
subscriber({
|
||||
type: "agent_stream",
|
||||
agentId: snapshot.id,
|
||||
agentId: agent.id,
|
||||
event: { type: "timeline", item: { type: "assistant_message", text: "hi" } },
|
||||
});
|
||||
expect(applySnapshot).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
import type {
|
||||
AgentPersistenceHandle,
|
||||
AgentProvider,
|
||||
AgentSessionConfig,
|
||||
} from "./agent/agent-sdk-types.js";
|
||||
@@ -11,7 +10,10 @@ import type {
|
||||
|
||||
type AgentRegistryPersistence = Pick<AgentRegistry, "applySnapshot" | "list">;
|
||||
type AgentManagerStateSource = Pick<AgentManager, "subscribe">;
|
||||
type AgentManagerRestorer = Pick<AgentManager, "resumeAgent" | "createAgent">;
|
||||
|
||||
function isKnownProvider(provider: string): provider is AgentProvider {
|
||||
return provider === "claude" || provider === "codex";
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach AgentRegistry persistence to an AgentManager instance so every
|
||||
@@ -33,83 +35,6 @@ export function attachAgentRegistryPersistence(
|
||||
return unsubscribe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore persisted agents from the AgentRegistry at server startup.
|
||||
*/
|
||||
export async function restorePersistedAgents(
|
||||
agentManager: AgentManagerRestorer,
|
||||
registry: AgentRegistryPersistence
|
||||
): Promise<void> {
|
||||
const records = await registry.list();
|
||||
if (records.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let resumed = 0;
|
||||
let created = 0;
|
||||
|
||||
for (const record of records) {
|
||||
if (!isKnownProvider(record.provider)) {
|
||||
console.warn(
|
||||
`[Agents] Skipping persisted agent ${record.id} with unknown provider '${record.provider}'`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const handle = buildPersistenceHandle(record);
|
||||
try {
|
||||
if (handle) {
|
||||
await agentManager.resumeAgent(
|
||||
handle,
|
||||
buildConfigOverrides(record),
|
||||
record.id
|
||||
);
|
||||
resumed += 1;
|
||||
} else {
|
||||
await agentManager.createAgent(
|
||||
buildSessionConfig(record),
|
||||
record.id
|
||||
);
|
||||
created += 1;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Agents] Failed to restore agent ${record.id} from registry:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Agents] Loaded ${records.length} persisted agent record(s); resumed ${resumed}, created ${created}`
|
||||
);
|
||||
}
|
||||
|
||||
function isKnownProvider(provider: string): provider is AgentProvider {
|
||||
return provider === "claude" || provider === "codex";
|
||||
}
|
||||
|
||||
function buildPersistenceHandle(
|
||||
record: StoredAgentRecord
|
||||
): AgentPersistenceHandle | null {
|
||||
if (!record.persistence) {
|
||||
return null;
|
||||
}
|
||||
const { provider, sessionId, nativeHandle, metadata } = record.persistence;
|
||||
if (!isKnownProvider(provider)) {
|
||||
console.warn(
|
||||
`[Agents] Skipping persisted handle for ${record.id} with unknown provider '${provider}'`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider,
|
||||
sessionId,
|
||||
nativeHandle,
|
||||
metadata,
|
||||
} satisfies AgentPersistenceHandle;
|
||||
}
|
||||
|
||||
export function buildConfigOverrides(
|
||||
record: StoredAgentRecord
|
||||
): Partial<AgentSessionConfig> {
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
OpenRouterProviderOptions,
|
||||
} from "@openrouter/ai-sdk-provider";
|
||||
import {
|
||||
serializeAgentSnapshot,
|
||||
serializeAgentStreamEvent,
|
||||
type AgentSnapshotPayload,
|
||||
type SessionInboundMessage,
|
||||
@@ -39,7 +38,8 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import { createTerminalMcpServer } from "./terminal-mcp/index.js";
|
||||
import { fetchProviderModelCatalog } from "./agent/model-catalog.js";
|
||||
import { AgentManager } from "./agent/agent-manager.js";
|
||||
import type { AgentSnapshot } from "./agent/agent-manager.js";
|
||||
import type { ManagedAgent } from "./agent/agent-manager.js";
|
||||
import { toAgentPayload } from "./agent/agent-projections.js";
|
||||
import type {
|
||||
AgentPermissionResponse,
|
||||
AgentPromptInput,
|
||||
@@ -73,6 +73,7 @@ type AgentMcpClientConfig = {
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const ACTIVE_TITLE_GENERATIONS = new Set<string>();
|
||||
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
|
||||
let restartRequested = false;
|
||||
const KNOWN_AGENT_PROVIDERS: AgentProvider[] = ["claude", "codex"];
|
||||
const RESTART_EXIT_DELAY_MS = 250;
|
||||
@@ -321,7 +322,7 @@ export class Session {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
if (snapshot.status !== "running") {
|
||||
if (snapshot.lifecycle !== "running") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -528,10 +529,10 @@ export class Session {
|
||||
}
|
||||
|
||||
private async buildAgentPayload(
|
||||
agent: AgentSnapshot
|
||||
agent: ManagedAgent
|
||||
): Promise<AgentSnapshotPayload> {
|
||||
const title = await this.getStoredAgentTitle(agent.id);
|
||||
return serializeAgentSnapshot(agent, { title });
|
||||
return toAgentPayload(agent, { title });
|
||||
}
|
||||
|
||||
private buildStoredAgentPayload(record: StoredAgentRecord): AgentSnapshotPayload {
|
||||
@@ -559,7 +560,7 @@ export class Session {
|
||||
createdAt: createdAt.toISOString(),
|
||||
updatedAt: updatedAt.toISOString(),
|
||||
lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null,
|
||||
status: (record.lastStatus as any) ?? "closed",
|
||||
status: record.lastStatus,
|
||||
sessionId: null,
|
||||
capabilities: defaultCapabilities,
|
||||
currentModeId: record.lastModeId ?? null,
|
||||
@@ -572,7 +573,53 @@ export class Session {
|
||||
};
|
||||
}
|
||||
|
||||
private async forwardAgentState(agent: AgentSnapshot): Promise<void> {
|
||||
private async ensureAgentLoaded(agentId: string): Promise<ManagedAgent> {
|
||||
const existing = this.agentManager.getAgent(agentId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const inflight = pendingAgentInitializations.get(agentId);
|
||||
if (inflight) {
|
||||
return inflight;
|
||||
}
|
||||
|
||||
const initPromise = (async () => {
|
||||
const record = await this.agentRegistry.get(agentId);
|
||||
if (!record) {
|
||||
throw new Error(`Agent not found: ${agentId}`);
|
||||
}
|
||||
|
||||
const handle = toAgentPersistenceHandle(record.persistence);
|
||||
let snapshot: ManagedAgent;
|
||||
if (handle) {
|
||||
snapshot = await this.agentManager.resumeAgent(
|
||||
handle,
|
||||
buildConfigOverrides(record),
|
||||
agentId
|
||||
);
|
||||
} else {
|
||||
const config = buildSessionConfig(record);
|
||||
snapshot = await this.agentManager.createAgent(config, agentId);
|
||||
}
|
||||
|
||||
await this.agentManager.primeAgentHistory(agentId);
|
||||
return this.agentManager.getAgent(agentId) ?? snapshot;
|
||||
})();
|
||||
|
||||
pendingAgentInitializations.set(agentId, initPromise);
|
||||
|
||||
try {
|
||||
return await initPromise;
|
||||
} finally {
|
||||
const current = pendingAgentInitializations.get(agentId);
|
||||
if (current === initPromise) {
|
||||
pendingAgentInitializations.delete(agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async forwardAgentState(agent: ManagedAgent): Promise<void> {
|
||||
try {
|
||||
const payload = await this.buildAgentPayload(agent);
|
||||
this.emit({
|
||||
@@ -959,6 +1006,17 @@ export class Session {
|
||||
}] Sending text to agent ${agentId}: ${text.substring(0, 50)}...${images && images.length > 0 ? ` with ${images.length} image attachment(s)` : ''}`
|
||||
);
|
||||
|
||||
try {
|
||||
await this.ensureAgentLoaded(agentId);
|
||||
} catch (error) {
|
||||
this.handleAgentRunError(
|
||||
agentId,
|
||||
error,
|
||||
"Failed to initialize agent before sending prompt"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.interruptAgentIfRunning(agentId);
|
||||
} catch (error) {
|
||||
@@ -1053,6 +1111,17 @@ export class Session {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.ensureAgentLoaded(agentId);
|
||||
} catch (error) {
|
||||
this.handleAgentRunError(
|
||||
agentId,
|
||||
error,
|
||||
"Failed to initialize agent before sending audio prompt"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.interruptAgentIfRunning(agentId);
|
||||
} catch (error) {
|
||||
@@ -1108,27 +1177,7 @@ export class Session {
|
||||
);
|
||||
|
||||
try {
|
||||
let snapshot = this.agentManager.getAgent(agentId);
|
||||
if (!snapshot) {
|
||||
const record = await this.agentRegistry.get(agentId);
|
||||
if (!record) {
|
||||
throw new Error(`Agent not found: ${agentId}`);
|
||||
}
|
||||
|
||||
const handle = toAgentPersistenceHandle(record.persistence);
|
||||
if (handle) {
|
||||
snapshot = await this.agentManager.resumeAgent(
|
||||
handle,
|
||||
buildConfigOverrides(record),
|
||||
agentId
|
||||
);
|
||||
} else {
|
||||
const config = buildSessionConfig(record);
|
||||
snapshot = await this.agentManager.createAgent(config, agentId);
|
||||
}
|
||||
}
|
||||
|
||||
await this.agentManager.primeAgentHistory(agentId);
|
||||
const snapshot = await this.ensureAgentLoaded(agentId);
|
||||
await this.forwardAgentState(snapshot);
|
||||
|
||||
// Send timeline snapshot after hydration (if any)
|
||||
@@ -1140,6 +1189,7 @@ export class Session {
|
||||
payload: {
|
||||
status: "agent_initialized",
|
||||
agentId,
|
||||
agentStatus: snapshot.lifecycle,
|
||||
requestId,
|
||||
timelineSize,
|
||||
},
|
||||
@@ -1147,7 +1197,7 @@ export class Session {
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Session ${this.clientId}] Agent ${agentId} initialized with ${timelineSize} timeline item(s)`
|
||||
`[Session ${this.clientId}] Agent ${agentId} initialized with ${timelineSize} timeline item(s); status=${snapshot.lifecycle}`
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
@@ -1187,24 +1237,6 @@ export class Session {
|
||||
);
|
||||
const snapshot = await this.agentManager.createAgent(sessionConfig);
|
||||
this.setCachedTitle(snapshot.id, null);
|
||||
try {
|
||||
await this.agentRegistry.recordConfig(
|
||||
snapshot.id,
|
||||
snapshot.provider,
|
||||
snapshot.cwd,
|
||||
{
|
||||
modeId: sessionConfig.modeId,
|
||||
model: sessionConfig.model,
|
||||
extra: sessionConfig.extra,
|
||||
}
|
||||
);
|
||||
} catch (registryError) {
|
||||
console.error(
|
||||
`[Session ${this.clientId}] Failed to record agent config for ${snapshot.id}:`,
|
||||
registryError
|
||||
);
|
||||
}
|
||||
|
||||
await this.forwardAgentState(snapshot);
|
||||
|
||||
const trimmedPrompt = initialPrompt?.trim();
|
||||
@@ -1338,7 +1370,7 @@ export class Session {
|
||||
);
|
||||
|
||||
try {
|
||||
let snapshot: AgentSnapshot;
|
||||
let snapshot: ManagedAgent;
|
||||
const existing = this.agentManager.getAgent(agentId);
|
||||
if (existing) {
|
||||
await this.interruptAgentIfRunning(agentId);
|
||||
@@ -2016,7 +2048,7 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private emitAgentTimelineSnapshot(agent: AgentSnapshot): number {
|
||||
private emitAgentTimelineSnapshot(agent: ManagedAgent): number {
|
||||
const timeline = this.agentManager.getTimeline(agent.id);
|
||||
const events = timeline.map((item) => ({
|
||||
event: serializeAgentStreamEvent({
|
||||
|
||||
@@ -4,6 +4,18 @@ import { findSessionByName, killSession } from "./tmux.js";
|
||||
|
||||
const TEST_SESSION = "test-terminal-manager";
|
||||
|
||||
const ANSI_ESCAPE_REGEX = /\u001B[\[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return value.replace(ANSI_ESCAPE_REGEX, "");
|
||||
}
|
||||
|
||||
function expectMissingPathMessage(output: string, missingPath: string): void {
|
||||
const normalized = stripAnsi(output).toLowerCase();
|
||||
expect(normalized).toContain(missingPath.toLowerCase());
|
||||
expect(normalized).toMatch(/cannot access|no such file|not found/);
|
||||
}
|
||||
|
||||
describe("TerminalManager - Command Execution", () => {
|
||||
let manager: TerminalManager;
|
||||
|
||||
@@ -55,7 +67,7 @@ describe("TerminalManager - Command Execution", () => {
|
||||
|
||||
expect(result.isDead).toBe(true);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.output).toContain("No such file");
|
||||
expectMissingPathMessage(result.output, "nonexistent-directory-test");
|
||||
});
|
||||
|
||||
it("should handle directory changes in command", async () => {
|
||||
|
||||
@@ -46,6 +46,16 @@ export type ShellType = "bash" | "zsh" | "fish";
|
||||
|
||||
let shellConfig: { type: ShellType } = { type: "bash" };
|
||||
|
||||
const ANSI_ESCAPE_REGEX =
|
||||
/\u001B[\[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
|
||||
|
||||
function stripAnsiSequences(value: string): string {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
return value.replace(ANSI_ESCAPE_REGEX, "");
|
||||
}
|
||||
|
||||
export function setShellConfig(config: { type: string }): void {
|
||||
// Validate shell type
|
||||
const validShells: ShellType[] = ["bash", "zsh", "fish"];
|
||||
@@ -216,8 +226,9 @@ export async function capturePaneContent(
|
||||
const trimmed = output.trimEnd();
|
||||
const allLines = trimmed.split("\n");
|
||||
const lastLines = allLines.slice(-lines);
|
||||
const joined = lastLines.join("\n");
|
||||
|
||||
return lastLines.join("\n");
|
||||
return includeColors ? joined : stripAnsiSequences(joined);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
16
packages/server/test-results-after.txt
Normal file
16
packages/server/test-results-after.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
> @paseo/server@0.1.0 test
|
||||
> vitest run
|
||||
|
||||
|
||||
RUN v3.2.4 /home/moboudra/dev/voice-dev/packages/server
|
||||
|
||||
stdout | src/server/agent/providers/codex-agent.test.ts > CodexAgentClient (SDK integration) > responds with text
|
||||
[CodexAgentTest] Running single-turn acknowledgment test
|
||||
|
||||
stdout | src/server/agent/providers/codex-agent.test.ts > CodexAgentClient (SDK integration) > emits tool or command events when writing a file
|
||||
[CodexAgentTest] Streaming file creation activity
|
||||
|
||||
stdout | src/server/agent/providers/codex-agent.test.ts > CodexAgentClient (SDK integration) > emits Codex tool calls that hydrate into specific UI entries
|
||||
[CodexAgentTest] Streaming Codex run for tool call hydration test
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const CLAUDE_AGENT_ID = 'f7644b18-7fb9-4491-80f3-788d0d27a119';
|
||||
const CODEX_AGENT_ID = 'd7c360ea-1579-4091-b224-ee8442400823';
|
||||
|
||||
const url = 'http://127.0.0.1:6767/mcp/agents';
|
||||
const auth = Buffer.from('mo:bo').toString('base64');
|
||||
|
||||
async function makeRequest(method, params) {
|
||||
console.log(`\n[TEST] Calling ${method} with params:`, JSON.stringify(params, null, 2));
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Basic ${auth}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: Date.now(),
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: method,
|
||||
arguments: params
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
console.log(`[TEST] Response received in ${elapsed}ms`);
|
||||
|
||||
const data = await response.json();
|
||||
console.log(`[TEST] Response:`, JSON.stringify(data, null, 2));
|
||||
return data;
|
||||
} catch (error) {
|
||||
const elapsed = Date.now() - startTime;
|
||||
console.error(`[TEST] Request failed after ${elapsed}ms:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
console.log('[TEST] Initializing MCP session...');
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Basic ${auth}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: {
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
console.log('[TEST] Initialized:', JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('=== Testing MCP Agent Control Tools ===\n');
|
||||
|
||||
// Initialize session
|
||||
await initialize();
|
||||
|
||||
// Test 1: Get status of Codex agent (should work)
|
||||
console.log('\n--- Test 1: get_agent_status on Codex agent ---');
|
||||
await makeRequest('get_agent_status', { agentId: CODEX_AGENT_ID });
|
||||
|
||||
// Test 2: Get status of Claude agent (might hang)
|
||||
console.log('\n--- Test 2: get_agent_status on Claude agent ---');
|
||||
await makeRequest('get_agent_status', { agentId: CLAUDE_AGENT_ID });
|
||||
|
||||
console.log('\n=== All tests complete ===');
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
Reference in New Issue
Block a user