@hienlh/ppm 0.8.95 → 0.8.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude.bak/agent-memory/tester/MEMORY.md +3 -0
- package/.claude.bak/agent-memory/tester/project-ppm-test-conventions.md +32 -0
- package/CHANGELOG.md +11 -3
- package/package.json +1 -1
- package/src/providers/claude-agent-sdk.ts +62 -23
- package/src/services/claude-usage.service.ts +9 -4
- package/docs/streaming-input-guide.md +0 -267
- package/snapshot-state.md +0 -1526
- package/test-session-ops.mjs +0 -444
- package/test-tokens.mjs +0 -212
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: PPM test conventions and gotchas
|
|
3
|
+
description: Key patterns, pitfalls, and setup details for writing tests in the PPM project
|
|
4
|
+
type: project
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Test runner: `bun test` (Jest-compatible API from `bun:test`)
|
|
8
|
+
|
|
9
|
+
## Test structure
|
|
10
|
+
- `tests/setup.ts` — shared helpers: `createTempDir`, `cleanupDir`, `createTempGitRepo`, `buildTestApp`
|
|
11
|
+
- `tests/unit/services/` — unit tests for ConfigService, ProjectService, FileService, GitService
|
|
12
|
+
- `tests/integration/api/` — integration tests using `app.request()` (no real server needed)
|
|
13
|
+
|
|
14
|
+
## Critical gotchas
|
|
15
|
+
|
|
16
|
+
### ppm.yaml in CWD
|
|
17
|
+
The project root has a real `ppm.yaml` with `port: 5555`. `ConfigService.load(missingPath)` falls through to `LOCAL_CONFIG = "ppm.yaml"` in CWD when the given path doesn't exist. Always write an actual file before calling `load()` to avoid picking up this real config.
|
|
18
|
+
|
|
19
|
+
### Global configService in git routes
|
|
20
|
+
`src/server/routes/git.ts` imports and uses the global `configService` singleton (not injected). Integration tests for git API must mutate `configService.config.projects` directly to register the test repo. Restore to `[]` in `afterEach`.
|
|
21
|
+
|
|
22
|
+
### ConfigService.load() fallback behavior
|
|
23
|
+
Candidates checked in order: explicit path → PPM_CONFIG env → LOCAL_CONFIG (ppm.yaml) → HOME_CONFIG (~/.ppm/config.yaml). A missing explicit path does NOT stop the fallback chain.
|
|
24
|
+
|
|
25
|
+
### buildTestApp in setup.ts
|
|
26
|
+
Overrides `configService.save = () => {}` (no-op) to prevent tests writing to disk. Injects config directly by mutating private fields via `as unknown as`.
|
|
27
|
+
|
|
28
|
+
### Real git repos for git tests
|
|
29
|
+
`createTempGitRepo()` uses `Bun.spawn` with git env vars (author name/email) to create a real repo with an initial commit. No mocks for git operations.
|
|
30
|
+
|
|
31
|
+
**Why:** Tests must use real implementations — no fakes/mocks that diverge from production behavior.
|
|
32
|
+
**How to apply:** Always use `createTempGitRepo` for anything touching GitService or git API routes.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [0.8.
|
|
3
|
+
## [0.8.96] - 2026-04-03
|
|
4
|
+
|
|
5
|
+
_Version bump — 0.8.95 published with incomplete commit set._
|
|
6
|
+
|
|
7
|
+
## [0.8.95] - 2026-04-03
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **Rate-limit auto-retry**: SDK automatically retries on rate_limit/server_error with exponential backoff (15s, 30s, 60s) up to 3 attempts
|
|
11
|
+
- **Increased max turns**: Default maxTurns bumped from 100 to 1000 for longer agent sessions
|
|
4
12
|
|
|
5
13
|
### Fixed
|
|
6
|
-
- **
|
|
7
|
-
- **
|
|
14
|
+
- **Session mapping on resume**: Preserve existing sdk_id mapping when resuming sessions to prevent orphaning JSONL conversation history
|
|
15
|
+
- **Usage list expired accounts**: Exclude expired temporary accounts (no refresh token) from usage dashboard
|
|
8
16
|
|
|
9
17
|
## [0.8.94] - 2026-04-02
|
|
10
18
|
|
package/package.json
CHANGED
|
@@ -650,7 +650,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
650
650
|
allowDangerouslySkipPermissions: isBypass,
|
|
651
651
|
...(providerConfig.model && { model: providerConfig.model }),
|
|
652
652
|
...(providerConfig.effort && { effort: providerConfig.effort }),
|
|
653
|
-
maxTurns: providerConfig.max_turns ??
|
|
653
|
+
maxTurns: providerConfig.max_turns ?? 1000,
|
|
654
654
|
...(providerConfig.max_budget_usd && { maxBudgetUsd: providerConfig.max_budget_usd }),
|
|
655
655
|
...(providerConfig.thinking_budget_tokens != null && {
|
|
656
656
|
thinkingBudgetTokens: providerConfig.thinking_budget_tokens,
|
|
@@ -688,7 +688,10 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
688
688
|
// it's a transient subprocess failure — retry once before surfacing the error.
|
|
689
689
|
// Also handles authentication_failed by refreshing OAuth token and retrying.
|
|
690
690
|
const MAX_RETRIES = 1;
|
|
691
|
+
const MAX_RATE_LIMIT_RETRIES = 3;
|
|
692
|
+
const RATE_LIMIT_BACKOFF_MS = [15_000, 30_000, 60_000]; // 15s, 30s, 60s
|
|
691
693
|
let retryCount = 0;
|
|
694
|
+
let rateLimitRetryCount = 0;
|
|
692
695
|
let authRetried = false;
|
|
693
696
|
|
|
694
697
|
let hadAnyEvents = false;
|
|
@@ -736,7 +739,17 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
736
739
|
if (subtype === "init") {
|
|
737
740
|
const initMsg = msg as any;
|
|
738
741
|
if (initMsg.session_id && initMsg.session_id !== sessionId) {
|
|
739
|
-
|
|
742
|
+
// Only update sdk_id mapping for brand-new sessions (first message).
|
|
743
|
+
// For resumed sessions the SDK may create a new session_id, but the
|
|
744
|
+
// old JSONL (keyed by the original sdk_id) still holds the full
|
|
745
|
+
// conversation history. Overwriting the mapping would orphan it.
|
|
746
|
+
const existingSdkId = getSessionMapping(sessionId);
|
|
747
|
+
const isFirstMessage = existingSdkId === null || existingSdkId === sessionId;
|
|
748
|
+
if (isFirstMessage) {
|
|
749
|
+
setSessionMapping(sessionId, initMsg.session_id, meta.projectName, meta.projectPath);
|
|
750
|
+
} else {
|
|
751
|
+
console.log(`[sdk] session=${sessionId} ignoring new sdk_id=${initMsg.session_id} to preserve existing mapping → ${existingSdkId}`);
|
|
752
|
+
}
|
|
740
753
|
const oldMeta = this.activeSessions.get(sessionId);
|
|
741
754
|
if (oldMeta) {
|
|
742
755
|
this.activeSessions.set(initMsg.session_id, { ...oldMeta, id: initMsg.session_id });
|
|
@@ -910,24 +923,36 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
910
923
|
}
|
|
911
924
|
}
|
|
912
925
|
|
|
913
|
-
//
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
926
|
+
// Rate limit — auto-retry with exponential backoff
|
|
927
|
+
if ((assistantError === "rate_limit" || assistantError === "server_error") && rateLimitRetryCount < MAX_RATE_LIMIT_RETRIES) {
|
|
928
|
+
const backoff = RATE_LIMIT_BACKOFF_MS[rateLimitRetryCount] ?? 60_000;
|
|
929
|
+
rateLimitRetryCount++;
|
|
930
|
+
if (account) accountSelector.onRateLimit(account.id);
|
|
931
|
+
console.warn(`[sdk] session=${sessionId} rate limited — retrying in ${backoff / 1000}s (attempt ${rateLimitRetryCount}/${MAX_RATE_LIMIT_RETRIES})`);
|
|
932
|
+
yield { type: "error", message: `Rate limited. Auto-retrying in ${backoff / 1000}s... (${rateLimitRetryCount}/${MAX_RATE_LIMIT_RETRIES})` };
|
|
933
|
+
await new Promise((r) => setTimeout(r, backoff));
|
|
934
|
+
// Close failed query and recreate
|
|
935
|
+
streamCtrl.done();
|
|
936
|
+
q.close();
|
|
937
|
+
const { generator: rlRetryGen, controller: rlRetryCtrl } = createMessageChannel();
|
|
938
|
+
rlRetryCtrl.push(firstMsg);
|
|
939
|
+
const retryOpts = { ...queryOptions, sessionId: undefined, resume: undefined };
|
|
940
|
+
const rq = query({
|
|
941
|
+
prompt: rlRetryGen,
|
|
942
|
+
options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
|
|
943
|
+
});
|
|
944
|
+
this.streamingSessions.set(sessionId, { meta, query: rq, controller: rlRetryCtrl });
|
|
945
|
+
this.activeQueries.set(sessionId, rq);
|
|
946
|
+
eventSource = rq;
|
|
947
|
+
continue retryLoop;
|
|
923
948
|
}
|
|
924
949
|
|
|
925
950
|
const errorHints: Record<string, string> = {
|
|
926
951
|
authentication_failed: "API authentication failed. Check your account credentials in Settings → Accounts.",
|
|
927
952
|
billing_error: "Billing error on this account. Check your subscription status.",
|
|
928
|
-
rate_limit:
|
|
953
|
+
rate_limit: `Rate limited by the API. Retried ${MAX_RATE_LIMIT_RETRIES} times without success.`,
|
|
929
954
|
invalid_request: "Invalid request sent to the API.",
|
|
930
|
-
server_error:
|
|
955
|
+
server_error: `Anthropic API server error. Retried ${MAX_RATE_LIMIT_RETRIES} times without success.`,
|
|
931
956
|
unknown: `API error in project "${effectiveCwd}". Debug:\n1. Run: \`cd ${effectiveCwd} && claude -p "hi"\`\n2. Check env: \`echo $ANTHROPIC_API_KEY $ANTHROPIC_BASE_URL\` — stale/invalid keys cause this\n3. Try: \`ANTHROPIC_API_KEY="" ANTHROPIC_BASE_URL="" claude -p "hi"\`\n4. Refresh auth: \`claude login\``,
|
|
932
957
|
};
|
|
933
958
|
const hint = errorHints[assistantError] ?? `API error: ${assistantError}`;
|
|
@@ -985,8 +1010,28 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
985
1010
|
const errCode = this.detectResultErrorCode(msg);
|
|
986
1011
|
if (errCode === 429) {
|
|
987
1012
|
accountSelector.onRateLimit(account.id);
|
|
988
|
-
//
|
|
989
|
-
|
|
1013
|
+
// Auto-retry with backoff for result-level 429
|
|
1014
|
+
if (rateLimitRetryCount < MAX_RATE_LIMIT_RETRIES) {
|
|
1015
|
+
const backoff = RATE_LIMIT_BACKOFF_MS[rateLimitRetryCount] ?? 60_000;
|
|
1016
|
+
rateLimitRetryCount++;
|
|
1017
|
+
console.warn(`[sdk] session=${sessionId} result 429 — retrying in ${backoff / 1000}s (attempt ${rateLimitRetryCount}/${MAX_RATE_LIMIT_RETRIES})`);
|
|
1018
|
+
yield { type: "error", message: `Rate limited. Auto-retrying in ${backoff / 1000}s... (${rateLimitRetryCount}/${MAX_RATE_LIMIT_RETRIES})` };
|
|
1019
|
+
await new Promise((r) => setTimeout(r, backoff));
|
|
1020
|
+
streamCtrl.done();
|
|
1021
|
+
q.close();
|
|
1022
|
+
const { generator: rlRetryGen, controller: rlRetryCtrl } = createMessageChannel();
|
|
1023
|
+
rlRetryCtrl.push(firstMsg);
|
|
1024
|
+
const retryOpts = { ...queryOptions, sessionId: undefined, resume: undefined };
|
|
1025
|
+
const rq = query({
|
|
1026
|
+
prompt: rlRetryGen,
|
|
1027
|
+
options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
|
|
1028
|
+
});
|
|
1029
|
+
this.streamingSessions.set(sessionId, { meta, query: rq, controller: rlRetryCtrl });
|
|
1030
|
+
this.activeQueries.set(sessionId, rq);
|
|
1031
|
+
eventSource = rq;
|
|
1032
|
+
continue retryLoop;
|
|
1033
|
+
}
|
|
1034
|
+
yield { type: "error", message: `Rate limited. Retried ${MAX_RATE_LIMIT_RETRIES} times without success.` };
|
|
990
1035
|
continue;
|
|
991
1036
|
} else if (errCode === 401) {
|
|
992
1037
|
// Refresh token and retry with fresh session (same logic as assistant-level auth retry)
|
|
@@ -1170,13 +1215,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
1170
1215
|
}
|
|
1171
1216
|
} finally {
|
|
1172
1217
|
this.activeQueries.delete(sessionId);
|
|
1173
|
-
|
|
1174
|
-
const ss = this.streamingSessions.get(sessionId);
|
|
1175
|
-
if (ss) {
|
|
1176
|
-
ss.controller.done();
|
|
1177
|
-
ss.query.close();
|
|
1178
|
-
this.streamingSessions.delete(sessionId);
|
|
1179
|
-
}
|
|
1218
|
+
this.streamingSessions.delete(sessionId);
|
|
1180
1219
|
console.log(`[sdk] session=${sessionId} streaming session ended`);
|
|
1181
1220
|
}
|
|
1182
1221
|
|
|
@@ -278,18 +278,23 @@ export function getAllAccountUsages(): AccountUsageEntry[] {
|
|
|
278
278
|
const accounts = accountService.list();
|
|
279
279
|
const snapshots = getAllLatestSnapshots();
|
|
280
280
|
const snapshotMap = new Map(snapshots.map(s => [s.account_id, s]));
|
|
281
|
-
|
|
281
|
+
const nowS = Math.floor(Date.now() / 1000);
|
|
282
|
+
const result: AccountUsageEntry[] = [];
|
|
283
|
+
for (const acc of accounts) {
|
|
282
284
|
const withTokens = accountService.getWithTokens(acc.id);
|
|
285
|
+
// Skip expired accounts without refresh token (temporary/disposable)
|
|
286
|
+
if (acc.expiresAt && acc.expiresAt < nowS && !withTokens?.refreshToken) continue;
|
|
283
287
|
const isOAuth = withTokens?.accessToken.startsWith("sk-ant-oat") ?? false;
|
|
284
288
|
const row = snapshotMap.get(acc.id);
|
|
285
|
-
|
|
289
|
+
result.push({
|
|
286
290
|
accountId: acc.id,
|
|
287
291
|
accountLabel: acc.label,
|
|
288
292
|
accountStatus: acc.status,
|
|
289
293
|
isOAuth,
|
|
290
294
|
usage: row ? snapshotToUsage(row) : {},
|
|
291
|
-
};
|
|
292
|
-
}
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
return result;
|
|
293
298
|
}
|
|
294
299
|
|
|
295
300
|
/** Get cached usage for active account (used by chat header) */
|
|
@@ -1,267 +0,0 @@
|
|
|
1
|
-
# Streaming Input Migration Quick Reference (v0.8.55+)
|
|
2
|
-
|
|
3
|
-
## What Changed?
|
|
4
|
-
|
|
5
|
-
**Before (v0.8.54):** Each message triggered a new SDK query
|
|
6
|
-
```
|
|
7
|
-
Message 1 → SDK subprocess spawn → generate response → close
|
|
8
|
-
Message 2 → SDK subprocess spawn → generate response → close
|
|
9
|
-
(Slow, context resets between messages)
|
|
10
|
-
```
|
|
11
|
-
|
|
12
|
-
**After (v0.8.55):** Single persistent streaming session
|
|
13
|
-
```
|
|
14
|
-
Session created → AsyncGenerator streaming input opened
|
|
15
|
-
Message 1 → Push into generator → process events
|
|
16
|
-
Message 2 → Push into same generator → continue streaming
|
|
17
|
-
(Fast, continuous context, no SDK restarts)
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
## Key Concepts
|
|
21
|
-
|
|
22
|
-
### Session State (BE-Owned)
|
|
23
|
-
The backend maintains a `SessionEntry` per chat session:
|
|
24
|
-
- Tracks connected clients (can be zero if FE disconnected)
|
|
25
|
-
- Maintains streaming phase (idle, connecting, thinking, streaming)
|
|
26
|
-
- Buffers events for reconnection sync
|
|
27
|
-
- Auto-cleans after 5 minutes of FE inactivity
|
|
28
|
-
|
|
29
|
-
### Message Priority (v0.8.55+)
|
|
30
|
-
```typescript
|
|
31
|
-
// Send message with priority
|
|
32
|
-
ws.send({
|
|
33
|
-
type: "message",
|
|
34
|
-
content: "Debug this code",
|
|
35
|
-
priority: "now" // "now" | "next" | "later"
|
|
36
|
-
})
|
|
37
|
-
```
|
|
38
|
-
- **"now"** — Abort current query, restart with this message
|
|
39
|
-
- **"next"** — Queue after current, run next
|
|
40
|
-
- **"later"** — Append to queue, run last
|
|
41
|
-
|
|
42
|
-
### Event Buffering on Reconnect
|
|
43
|
-
When FE WS reconnects after disconnect:
|
|
44
|
-
1. BE sends `session_state` with current phase + pending approval
|
|
45
|
-
2. BE sends `turn_events` with all buffered events since last connection
|
|
46
|
-
3. FE rebuilds chat UI state from buffered events
|
|
47
|
-
4. No message loss (unless session cleaned up after 5min)
|
|
48
|
-
|
|
49
|
-
## Common Patterns
|
|
50
|
-
|
|
51
|
-
### Frontend: Send Message
|
|
52
|
-
```typescript
|
|
53
|
-
// In useChat hook or message input handler
|
|
54
|
-
ws.send(JSON.stringify({
|
|
55
|
-
type: "message",
|
|
56
|
-
content: userInput,
|
|
57
|
-
priority: "now", // Optional
|
|
58
|
-
images: [{ id: "img1", data: "base64..." }] // Optional
|
|
59
|
-
}));
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
### Frontend: Handle Reconnection
|
|
63
|
-
```typescript
|
|
64
|
-
function handleReconnect() {
|
|
65
|
-
// 1. WS open fires
|
|
66
|
-
// 2. Server sends session_state
|
|
67
|
-
const sessionState = JSON.parse(msg);
|
|
68
|
-
// 3. Server sends turn_events
|
|
69
|
-
const turnEvents = JSON.parse(msg);
|
|
70
|
-
|
|
71
|
-
// 4. FE rebuilds state from buffered events
|
|
72
|
-
turnEvents.events.forEach(event => {
|
|
73
|
-
chatStore.addEvent(event);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
// 5. FE is now synced with BE
|
|
77
|
-
}
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
### Backend: Session Lifecycle
|
|
81
|
-
```typescript
|
|
82
|
-
// 1. FE connects
|
|
83
|
-
open(ws) {
|
|
84
|
-
const entry = activeSessions.get(sessionId);
|
|
85
|
-
if (!entry) {
|
|
86
|
-
// Create new session entry
|
|
87
|
-
activeSessions.set(sessionId, {
|
|
88
|
-
phase: "idle",
|
|
89
|
-
clients: new Set([ws]),
|
|
90
|
-
turnEvents: []
|
|
91
|
-
});
|
|
92
|
-
} else {
|
|
93
|
-
// Reconnect: clear cleanup timer, add client
|
|
94
|
-
entry.clients.add(ws);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// 2. FE sends message
|
|
99
|
-
message(ws, data) {
|
|
100
|
-
const parsed = JSON.parse(data);
|
|
101
|
-
if (parsed.type === "message") {
|
|
102
|
-
// Abort current if streaming, wait for cleanup
|
|
103
|
-
if (entry.phase !== "idle") {
|
|
104
|
-
entry.abort.abort();
|
|
105
|
-
await entry.streamPromise;
|
|
106
|
-
}
|
|
107
|
-
// Start new streaming loop (detached)
|
|
108
|
-
entry.streamPromise = runStreamLoop(...);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// 3. Streaming loop runs independently
|
|
113
|
-
async function runStreamLoop() {
|
|
114
|
-
for await (const event of chatService.sendMessage(...)) {
|
|
115
|
-
bufferAndBroadcast(sessionId, event); // To all connected clients
|
|
116
|
-
}
|
|
117
|
-
setPhase(sessionId, "idle"); // Back to idle when done
|
|
118
|
-
if (entry.clients.size === 0) {
|
|
119
|
-
startCleanupTimer(sessionId); // 5-min cleanup
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// 4. FE disconnects
|
|
124
|
-
close(ws) {
|
|
125
|
-
entry.clients.delete(ws);
|
|
126
|
-
// Stream continues! (BE owns the connection)
|
|
127
|
-
// Timer started if no more clients
|
|
128
|
-
}
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
## Phase State Machine
|
|
132
|
-
|
|
133
|
-
```
|
|
134
|
-
┌─ initializing (setup, session resume)
|
|
135
|
-
↓
|
|
136
|
-
idle ←→ connecting (waiting for first SDK event, heartbeat)
|
|
137
|
-
↑ ↓
|
|
138
|
-
│ ┌──→ thinking (extended thinking)
|
|
139
|
-
│ ↓ ↓
|
|
140
|
-
└─── streaming (text/tool_use content)
|
|
141
|
-
↑ ↓
|
|
142
|
-
└─────┘ (dynamic switch)
|
|
143
|
-
```
|
|
144
|
-
|
|
145
|
-
**Transitions:**
|
|
146
|
-
- Heartbeat: `connecting` → (5s elapsed updates) → `thinking` (when content arrives)
|
|
147
|
-
- Content: `thinking` → `streaming` (first text event)
|
|
148
|
-
- Dynamic: `streaming` ↔ `thinking` (based on event types)
|
|
149
|
-
- Done: Any → `idle` (stream complete, ready for next message)
|
|
150
|
-
|
|
151
|
-
## WebSocket Messages (v0.8.55+)
|
|
152
|
-
|
|
153
|
-
### Client → Server
|
|
154
|
-
```typescript
|
|
155
|
-
// Send message
|
|
156
|
-
{ type: "message"; content: string; priority?: string; images?: {...}[] }
|
|
157
|
-
|
|
158
|
-
// Approve tool
|
|
159
|
-
{ type: "approval_response"; requestId: string; approved: boolean }
|
|
160
|
-
|
|
161
|
-
// Cancel current
|
|
162
|
-
{ type: "cancel" }
|
|
163
|
-
|
|
164
|
-
// Handshake after open
|
|
165
|
-
{ type: "ready" }
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
### Server → Client
|
|
169
|
-
```typescript
|
|
170
|
-
// Content
|
|
171
|
-
{ type: "text"; content: string }
|
|
172
|
-
{ type: "thinking"; content: string }
|
|
173
|
-
|
|
174
|
-
// Tool execution
|
|
175
|
-
{ type: "tool_use"; tool: string; input: unknown }
|
|
176
|
-
{ type: "tool_result"; output: string; isError?: boolean }
|
|
177
|
-
|
|
178
|
-
// User approval request
|
|
179
|
-
{ type: "approval_request"; requestId: string; tool: string; input: unknown }
|
|
180
|
-
|
|
181
|
-
// Session state (sent on open/ready)
|
|
182
|
-
{ type: "session_state"; sessionId: string; phase: SessionPhase; pendingApproval: {...} | null }
|
|
183
|
-
|
|
184
|
-
// Buffered events (on reconnect)
|
|
185
|
-
{ type: "turn_events"; events: unknown[] }
|
|
186
|
-
|
|
187
|
-
// Metadata
|
|
188
|
-
{ type: "account_info"; accountId: string; accountLabel: string }
|
|
189
|
-
{ type: "phase_changed"; phase: SessionPhase; elapsed?: number }
|
|
190
|
-
{ type: "title_updated"; title: string }
|
|
191
|
-
|
|
192
|
-
// Completion
|
|
193
|
-
{ type: "done"; sessionId: string; contextWindowPct?: number }
|
|
194
|
-
|
|
195
|
-
// Error
|
|
196
|
-
{ type: "error"; message: string }
|
|
197
|
-
|
|
198
|
-
// Keepalive
|
|
199
|
-
{ type: "ping" }
|
|
200
|
-
```
|
|
201
|
-
|
|
202
|
-
## Benefits
|
|
203
|
-
|
|
204
|
-
| Aspect | Before (v0.8.54) | After (v0.8.55) |
|
|
205
|
-
|--------|------------------|-----------------|
|
|
206
|
-
| **SDK Restarts** | Per message | Once per session |
|
|
207
|
-
| **Context** | Resets between messages | Persistent |
|
|
208
|
-
| **Startup Time** | 2-5s per message | Instant follow-ups |
|
|
209
|
-
| **Reconnection** | Message loss | Event buffering ensures sync |
|
|
210
|
-
| **Concurrency** | N/A | Multiple clients per session |
|
|
211
|
-
| **Tool Approvals** | Restarts query | Integrated in stream |
|
|
212
|
-
|
|
213
|
-
## Troubleshooting
|
|
214
|
-
|
|
215
|
-
### Session Cleaned Up (No Longer Exists)
|
|
216
|
-
**Cause:** FE disconnected for >5 minutes
|
|
217
|
-
**Solution:** Create new session, FE reconnects with new sessionId
|
|
218
|
-
|
|
219
|
-
### Events Missing After Reconnect
|
|
220
|
-
**Cause:** Server-side event buffer (10k event limit) overflowed
|
|
221
|
-
**Solution:** Flush buffer periodically or increase limit if needed
|
|
222
|
-
|
|
223
|
-
### Phase Stuck in "Connecting"
|
|
224
|
-
**Cause:** SDK subprocess not responding (120s timeout)
|
|
225
|
-
**Solution:** Check environment (ANTHROPIC_API_KEY, network), see error message for hints
|
|
226
|
-
|
|
227
|
-
### Multiple Clients Out of Sync
|
|
228
|
-
**Cause:** Broadcast failed for one client, others ahead
|
|
229
|
-
**Solution:** Evicted client will reconnect and re-sync from buffered events
|
|
230
|
-
|
|
231
|
-
## Debugging
|
|
232
|
-
|
|
233
|
-
### Enable Logging
|
|
234
|
-
```bash
|
|
235
|
-
# Check server logs for session lifecycle
|
|
236
|
-
[chat] session=abc123 phase → connecting
|
|
237
|
-
[chat] session=abc123 first SDK event after 1250ms: type=text
|
|
238
|
-
[chat] session=abc123 stream completed (45 events)
|
|
239
|
-
[chat] session=abc123 phase → idle
|
|
240
|
-
```
|
|
241
|
-
|
|
242
|
-
### Check Session State
|
|
243
|
-
```typescript
|
|
244
|
-
// On WS message handler
|
|
245
|
-
console.log(`Session entry:`, activeSessions.get(sessionId));
|
|
246
|
-
// Outputs: { phase, clients.size, pendingApprovalEvent, turnEvents.length }
|
|
247
|
-
```
|
|
248
|
-
|
|
249
|
-
### Monitor Reconnections
|
|
250
|
-
```typescript
|
|
251
|
-
// In WS open handler
|
|
252
|
-
console.log(`FE reconnected (phase=${existing.phase}, clients=${existing.clients.size})`);
|
|
253
|
-
// Tells you: active streaming, how many clients connected
|
|
254
|
-
```
|
|
255
|
-
|
|
256
|
-
## Performance Notes
|
|
257
|
-
|
|
258
|
-
- **No SDK overhead:** Persistent streaming eliminates subprocess spawn overhead
|
|
259
|
-
- **Event buffering:** Clients see all events after reconnect (max 10k events per turn)
|
|
260
|
-
- **Memory:** Session entries cleaned after 5min (bounded memory usage)
|
|
261
|
-
- **Latency:** Follow-up messages start immediately (no SDK init)
|
|
262
|
-
|
|
263
|
-
---
|
|
264
|
-
|
|
265
|
-
**For detailed architecture:** See `docs/system-architecture.md` → "Chat Streaming Flow" section
|
|
266
|
-
**For API types:** See `src/types/api.ts` and `src/types/chat.ts`
|
|
267
|
-
**For implementation:** See `src/server/ws/chat.ts` and `src/providers/claude-agent-sdk.ts`
|