@gotgenes/pi-subagents 13.1.0 → 13.2.1
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/CHANGELOG.md +15 -0
- package/README.md +19 -10
- package/docs/architecture/architecture.md +31 -118
- package/docs/architecture/history/phase-16-invert-dependencies.md +143 -0
- package/docs/plans/0277-encapsulate-agent-session.md +304 -0
- package/docs/retro/0265-born-complete-subagent-session.md +39 -0
- package/docs/retro/0277-encapsulate-agent-session.md +80 -0
- package/package.json +1 -1
- package/src/lifecycle/agent-manager.ts +2 -2
- package/src/lifecycle/agent.ts +51 -16
- package/src/lifecycle/subagent-session.ts +31 -1
- package/src/observation/notification.ts +2 -2
- package/src/service/service-adapter.ts +1 -7
- package/src/tools/agent-tool.ts +1 -1
- package/src/tools/background-spawner.ts +4 -3
- package/src/tools/foreground-runner.ts +4 -3
- package/src/tools/get-result-tool.ts +4 -8
- package/src/tools/steer-tool.ts +7 -13
- package/src/ui/agent-menu.ts +1 -3
- package/src/ui/conversation-viewer.ts +5 -10
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 277
|
|
3
|
+
issue_title: "Encapsulate AgentSession behind SubagentSession; retire the remaining agent.session reach-throughs"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Encapsulate AgentSession behind SubagentSession
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
Callers outside the `lifecycle/` domain reach through `Agent.session` (which returns the raw SDK `AgentSession` via `this.subagentSession?.session`) and operate on the session object directly.
|
|
11
|
+
This violates Law of Demeter and Tell-Don't-Ask — the missing abstractions are intent-revealing methods on `Agent` and `SubagentSession` that delegate internally.
|
|
12
|
+
|
|
13
|
+
Issue #265 introduced `SubagentSession` and routed the run/resume/dispose path through it.
|
|
14
|
+
The remaining consumer-facing reach-throughs were deferred to this issue.
|
|
15
|
+
|
|
16
|
+
## Goals
|
|
17
|
+
|
|
18
|
+
- Add intent-revealing methods to `Agent` and `SubagentSession` that replace every raw `AgentSession` access outside `lifecycle/`.
|
|
19
|
+
- Collapse the duplicated steer buffer-or-deliver logic into a single `Agent.steer()` method.
|
|
20
|
+
- Narrow the `onSessionCreated` observer callback to stop delivering the raw `AgentSession` to spawners.
|
|
21
|
+
- Remove the `Agent.session` getter.
|
|
22
|
+
- After this change, no production module outside `lifecycle/` references the raw `AgentSession`.
|
|
23
|
+
|
|
24
|
+
## Non-Goals
|
|
25
|
+
|
|
26
|
+
- The `Agent` → `Subagent` class rename — independent of this issue, can land in either order.
|
|
27
|
+
- Changing the public `SubagentsService` API surface in `service/service.ts` — it already uses `SubagentRecord` (no live session objects).
|
|
28
|
+
- Refactoring the conversation viewer's rich-rendering internals — only its session reference changes.
|
|
29
|
+
- Architecture doc updates for the file-layout listing — `SubagentSession` and `Agent` files are not being added, moved, or removed.
|
|
30
|
+
|
|
31
|
+
## Background
|
|
32
|
+
|
|
33
|
+
### Affected reach-through sites
|
|
34
|
+
|
|
35
|
+
Every site reaches the raw `AgentSession` exposed by the `Agent.session` getter.
|
|
36
|
+
|
|
37
|
+
| Reach-through | Production files | Current pattern |
|
|
38
|
+
| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
|
39
|
+
| Steer buffer-or-deliver (duplicated) | `service/service-adapter.ts:93`, `tools/steer-tool.ts:43` | `record.session` → `session.steer()` or `record.queueSteer()` |
|
|
40
|
+
| Context-percent stats | `tools/get-result-tool.ts:56`, `tools/steer-tool.ts:57`, `observation/notification.ts:49`, `ui/conversation-viewer.ts:145` | `getSessionContextPercent(record.session)` |
|
|
41
|
+
| Conversation viewing | `tools/get-result-tool.ts:83-84`, `ui/agent-menu.ts:255`, `ui/conversation-viewer.ts:63,223` | `getAgentConversation(record.session)` / `session.messages` |
|
|
42
|
+
| Session-readiness guard | `tools/agent-tool.ts:111`, `lifecycle/agent-manager.ts:205` | `!record.session` / `!agent?.session` |
|
|
43
|
+
| Observer callback delivers raw session | `tools/background-spawner.ts:56`, `tools/foreground-runner.ts:111` | `onSessionCreated(_agent, session)` → `tracker.setSession(session)` + `subscribeUIObserver(session, tracker)` |
|
|
44
|
+
| Activity-tracker session stats | `ui/widget-renderer.ts:106` | `activity?.session` → `getSessionContextPercent(activity.session)` (via `AgentActivityTracker.session`) |
|
|
45
|
+
|
|
46
|
+
### Existing delegate methods
|
|
47
|
+
|
|
48
|
+
`SubagentSession` already has `steer(message)`, `runTurnLoop()`, `resumeTurnLoop()`, and `dispose()`.
|
|
49
|
+
The `session` getter is marked "retired by #277" in its JSDoc.
|
|
50
|
+
|
|
51
|
+
`subscribeAgentObserver` in `observation/record-observer.ts` already accepts `SubscribableSession` (not `AgentSession`), so it can accept `SubagentSession` directly once `SubagentSession` implements the `subscribe()` delegate.
|
|
52
|
+
|
|
53
|
+
### Public API surface
|
|
54
|
+
|
|
55
|
+
The public API (`SubagentsService` + `SubagentRecord` in `service/service.ts`) does not expose `Agent` or `AgentSession`.
|
|
56
|
+
Removing `Agent.session` is not a breaking change for cross-extension consumers.
|
|
57
|
+
|
|
58
|
+
## Design Overview
|
|
59
|
+
|
|
60
|
+
### New delegate methods on `SubagentSession`
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
// Delegates to getAgentConversation(this._session)
|
|
64
|
+
getConversation(): string
|
|
65
|
+
|
|
66
|
+
// Delegates to getSessionContextPercent(this._session)
|
|
67
|
+
getContextPercent(): number | null
|
|
68
|
+
|
|
69
|
+
// Delegates to this._session.subscribe(fn) — satisfies SubscribableSession
|
|
70
|
+
subscribe(fn: (event: AgentSessionEvent) => void): () => void
|
|
71
|
+
|
|
72
|
+
// Delegates to this._session.getSessionStats() — satisfies SessionLike
|
|
73
|
+
getSessionStats(): SessionStatsLike
|
|
74
|
+
|
|
75
|
+
// Delegates to this._session.messages
|
|
76
|
+
get messages(): readonly unknown[]
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
With `subscribe()` and `getSessionStats()`, `SubagentSession` structurally satisfies both `SubscribableSession` and `SessionLike`.
|
|
80
|
+
This lets spawners pass a `SubagentSession` directly to `subscribeUIObserver()` and `tracker.setSession()` without exposing the raw `AgentSession`.
|
|
81
|
+
|
|
82
|
+
### New intent-revealing methods on `Agent`
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
// Buffer-or-deliver: returns true if delivered, false if buffered
|
|
86
|
+
async steer(message: string): Promise<boolean>
|
|
87
|
+
|
|
88
|
+
// Returns true when a SubagentSession is available
|
|
89
|
+
isSessionReady(): boolean
|
|
90
|
+
|
|
91
|
+
// Delegates to SubagentSession.getConversation(), returns undefined if no session
|
|
92
|
+
getConversation(): string | undefined
|
|
93
|
+
|
|
94
|
+
// Delegates to SubagentSession.getContextPercent(), returns null if no session
|
|
95
|
+
getContextPercent(): number | null
|
|
96
|
+
|
|
97
|
+
// Delegates to SubagentSession.subscribe() for conversation-viewer live updates
|
|
98
|
+
subscribeToUpdates(fn: (event: AgentSessionEvent) => void): (() => void) | undefined
|
|
99
|
+
|
|
100
|
+
// Delegates to SubagentSession.messages for conversation-viewer rendering
|
|
101
|
+
get messages(): readonly unknown[]
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The `steer()` method replaces the duplicated buffer-or-deliver logic:
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
async steer(message: string): Promise<boolean> {
|
|
108
|
+
if (!this.subagentSession) {
|
|
109
|
+
this.queueSteer(message);
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
await this.subagentSession.steer(message);
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Observer callback narrowing
|
|
118
|
+
|
|
119
|
+
`AgentLifecycleObserver.onSessionCreated` changes from `(agent: Agent, session: AgentSession)` to `(agent: Agent)`.
|
|
120
|
+
Spawners access `agent.subagentSession!` (already public) which satisfies `SubscribableSession & SessionLike`:
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
// tools/background-spawner.ts — after
|
|
124
|
+
onSessionCreated: (agent) => {
|
|
125
|
+
const sub = agent.subagentSession!;
|
|
126
|
+
bgState.setSession(sub); // SubagentSession satisfies SessionLike
|
|
127
|
+
subscribeUIObserver(sub, bgState); // SubagentSession satisfies SubscribableSession
|
|
128
|
+
},
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Internal lifecycle wiring
|
|
132
|
+
|
|
133
|
+
`Agent.run()` already passes the session to `subscribeAgentObserver()`, which accepts `SubscribableSession`.
|
|
134
|
+
After the change, it passes `this.subagentSession` directly instead of `this.subagentSession.session`:
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
// lifecycle/agent.ts — Agent.run(), after
|
|
138
|
+
this.attachObserver(subscribeAgentObserver(this.subagentSession, this, { ... }));
|
|
139
|
+
this.observer?.onSessionCreated?.(this);
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Removals
|
|
143
|
+
|
|
144
|
+
- `Agent.session` getter — removed entirely.
|
|
145
|
+
- `SubagentSession.session` getter — marked `@internal`, kept for lifecycle-internal uses (the `getLastAssistantText()` private helper reads `this._session.messages`).
|
|
146
|
+
- `Agent.queueSteer()` — becomes private (called only from `Agent.steer()` and `Agent.flushPendingSteers()`).
|
|
147
|
+
- `Agent.flushPendingSteers()` — becomes private (called only from `Agent.run()`).
|
|
148
|
+
|
|
149
|
+
## Module-Level Changes
|
|
150
|
+
|
|
151
|
+
### `src/lifecycle/subagent-session.ts`
|
|
152
|
+
|
|
153
|
+
- Add `getConversation()`, `getContextPercent()`, `subscribe()`, `getSessionStats()`, `messages` getter.
|
|
154
|
+
- Import `getAgentConversation` from `#src/session/conversation` and `getSessionContextPercent`, `SessionStatsLike` from `#src/lifecycle/usage`.
|
|
155
|
+
- Mark the `session` getter with `@internal` JSDoc.
|
|
156
|
+
|
|
157
|
+
### `src/lifecycle/agent.ts`
|
|
158
|
+
|
|
159
|
+
- Add `steer()`, `isSessionReady()`, `getConversation()`, `getContextPercent()`, `subscribeToUpdates()`, `messages` getter.
|
|
160
|
+
- Remove the `session` getter.
|
|
161
|
+
- Make `queueSteer()` and `flushPendingSteers()` private.
|
|
162
|
+
- In `run()`: pass `this.subagentSession` (not `this.subagentSession.session`) to `subscribeAgentObserver()` and fire `onSessionCreated(this)` without the session param.
|
|
163
|
+
- In `resume()`: pass `subagentSession` (not `subagentSession.session`) to `subscribeAgentObserver()`.
|
|
164
|
+
- Remove the `AgentSession` import.
|
|
165
|
+
- Update `AgentLifecycleObserver.onSessionCreated` to `(agent: Agent)` — no session param.
|
|
166
|
+
|
|
167
|
+
### `src/service/service-adapter.ts`
|
|
168
|
+
|
|
169
|
+
- Replace the 6-line steer reach-through with `await record.steer(message)`.
|
|
170
|
+
- Remove the `!session` guard — `Agent.steer()` owns it.
|
|
171
|
+
|
|
172
|
+
### `src/tools/steer-tool.ts`
|
|
173
|
+
|
|
174
|
+
- Replace the buffer-or-deliver dance with `const delivered = await record.steer(message)`.
|
|
175
|
+
- Use `record.getContextPercent()` instead of `getSessionContextPercent(session)`.
|
|
176
|
+
- Remove the `getSessionContextPercent` import.
|
|
177
|
+
|
|
178
|
+
### `src/tools/get-result-tool.ts`
|
|
179
|
+
|
|
180
|
+
- Use `record.getContextPercent()` instead of `getSessionContextPercent(record.session)`.
|
|
181
|
+
- Use `record.getConversation()` instead of `getAgentConversation(record.session)`.
|
|
182
|
+
- Remove the `getSessionContextPercent` and `getAgentConversation` imports.
|
|
183
|
+
|
|
184
|
+
### `src/tools/agent-tool.ts`
|
|
185
|
+
|
|
186
|
+
- Use `existing.isSessionReady()` instead of `!existing.session`.
|
|
187
|
+
|
|
188
|
+
### `src/lifecycle/agent-manager.ts`
|
|
189
|
+
|
|
190
|
+
- Use `agent.isSessionReady()` instead of `agent?.session`.
|
|
191
|
+
- Update the `onSessionCreated` relay to `(agent) => options.observer!.onSessionCreated!(agent)` — no session param.
|
|
192
|
+
|
|
193
|
+
### `src/observation/notification.ts`
|
|
194
|
+
|
|
195
|
+
- Use `record.getContextPercent()` instead of `getSessionContextPercent(record.session)`.
|
|
196
|
+
- Remove the `getSessionContextPercent` import.
|
|
197
|
+
|
|
198
|
+
### `src/ui/conversation-viewer.ts`
|
|
199
|
+
|
|
200
|
+
- Remove `session: AgentSession` from `ConversationViewerOptions`.
|
|
201
|
+
- Remove the `private session: AgentSession` field.
|
|
202
|
+
- Use `this.record.subscribeToUpdates(() => ...)` instead of `session.subscribe(...)`.
|
|
203
|
+
- Use `this.record.messages` instead of `this.session.messages`.
|
|
204
|
+
- Use `this.record.getContextPercent()` instead of `getSessionContextPercent(this.record.session)`.
|
|
205
|
+
- Remove the `AgentSession` and `getSessionContextPercent` imports.
|
|
206
|
+
|
|
207
|
+
### `src/ui/agent-menu.ts`
|
|
208
|
+
|
|
209
|
+
- Use `record.isSessionReady()` instead of `record.session` check.
|
|
210
|
+
- Remove the `session` variable and stop passing it to `ConversationViewer`.
|
|
211
|
+
|
|
212
|
+
### `src/tools/background-spawner.ts`
|
|
213
|
+
|
|
214
|
+
- Update `onSessionCreated` callback: `(agent) => { const sub = agent.subagentSession!; ... }`.
|
|
215
|
+
- Pass `sub` instead of `session` to `bgState.setSession()` and `subscribeUIObserver()`.
|
|
216
|
+
|
|
217
|
+
### `src/tools/foreground-runner.ts`
|
|
218
|
+
|
|
219
|
+
- Same pattern as `background-spawner.ts`.
|
|
220
|
+
|
|
221
|
+
### `test/helpers/mock-session.ts`
|
|
222
|
+
|
|
223
|
+
- Update `createSubagentSessionStub` to include the new delegate methods (`getConversation`, `getContextPercent`, `subscribe`, `getSessionStats`, `messages`).
|
|
224
|
+
- The stub's `subscribe` can delegate to the underlying mock session's `subscribe`.
|
|
225
|
+
|
|
226
|
+
### Test files requiring updates
|
|
227
|
+
|
|
228
|
+
- `test/lifecycle/subagent-session.test.ts` — add tests for new delegate methods.
|
|
229
|
+
- `test/lifecycle/agent.test.ts` — add tests for `steer()`, `isSessionReady()`, `getConversation()`, `getContextPercent()`; update `queueSteer` and `flushPendingSteers` tests (now private, tested through `steer()`); update `session` getter tests to `isSessionReady()`.
|
|
230
|
+
- `test/service/service-adapter.test.ts` — update steer tests (no more session reach-through).
|
|
231
|
+
- `test/tools/steer-tool.test.ts` — update to use `Agent.steer()` semantics.
|
|
232
|
+
- `test/tools/get-result-tool.test.ts` — update context-percent and conversation assertions.
|
|
233
|
+
- `test/tools/agent-tool.test.ts` — update resume guard to use `isSessionReady()`.
|
|
234
|
+
- `test/tools/background-spawner.test.ts` — update `onSessionCreated` callback assertions.
|
|
235
|
+
- `test/tools/foreground-runner.test.ts` — update `onSessionCreated` callback assertions.
|
|
236
|
+
- `test/observation/notification.test.ts` — update context-percent assertions.
|
|
237
|
+
- `test/conversation-viewer.test.ts` — remove `session` from viewer options, update to use `record` methods.
|
|
238
|
+
- `test/ui/agent-menu.test.ts` — update session guard assertions.
|
|
239
|
+
- `test/lifecycle/agent-manager.test.ts` — update resume guard + observer relay tests.
|
|
240
|
+
|
|
241
|
+
## Test Impact Analysis
|
|
242
|
+
|
|
243
|
+
1. **New unit tests enabled:** Direct testing of `SubagentSession.getConversation()`, `getContextPercent()`, `subscribe()`, `getSessionStats()`, `messages` — these were previously only testable by reaching through the raw session.
|
|
244
|
+
Direct testing of `Agent.steer()` buffer-or-deliver logic — previously scattered across consumer tests.
|
|
245
|
+
2. **Existing tests that become simpler:** `steer-tool.test.ts` and `service-adapter.test.ts` no longer need to set up the session-present/session-absent dance — they test through `Agent.steer()`.
|
|
246
|
+
`get-result-tool.test.ts` no longer needs mock sessions for context-percent assertions.
|
|
247
|
+
3. **Existing tests that stay as-is:** `SubagentSession.runTurnLoop()` and `resumeTurnLoop()` tests — they exercise turn driving, which is unrelated.
|
|
248
|
+
`Agent.run()` integration tests — they verify the full lifecycle including observer wiring.
|
|
249
|
+
|
|
250
|
+
## TDD Order
|
|
251
|
+
|
|
252
|
+
1. Add delegate methods to `SubagentSession` (`getConversation`, `getContextPercent`, `subscribe`, `getSessionStats`, `messages`).
|
|
253
|
+
Update `createSubagentSessionStub` test helper to include the new methods.
|
|
254
|
+
Test: unit tests for each delegate method in `subagent-session.test.ts`.
|
|
255
|
+
Commit: `feat: add delegate methods to SubagentSession for session encapsulation (#277)`.
|
|
256
|
+
|
|
257
|
+
2. Add intent-revealing methods to `Agent` (`steer`, `isSessionReady`, `getConversation`, `getContextPercent`, `subscribeToUpdates`, `messages`).
|
|
258
|
+
Test: unit tests for each method in `agent.test.ts`.
|
|
259
|
+
Commit: `feat: add session-encapsulation methods to Agent (#277)`.
|
|
260
|
+
|
|
261
|
+
3. Migrate steer callers: replace the buffer-or-deliver reach-through in `service-adapter.ts` and `steer-tool.ts` with `record.steer()`.
|
|
262
|
+
Replace `getSessionContextPercent(session)` in `steer-tool.ts` with `record.getContextPercent()`.
|
|
263
|
+
Make `queueSteer()` and `flushPendingSteers()` private on `Agent`.
|
|
264
|
+
Update tests in `service-adapter.test.ts`, `steer-tool.test.ts`, `agent.test.ts` (the `queueSteer`/`flushPendingSteers` tests become tests for `Agent.steer()`; existing tests from step 2 may cover this).
|
|
265
|
+
Commit: `refactor: use Agent.steer for buffer-or-deliver (#277)`.
|
|
266
|
+
|
|
267
|
+
4. Migrate context-percent, conversation, and readiness callers: `get-result-tool.ts`, `agent-tool.ts`, `agent-manager.ts`, `notification.ts`.
|
|
268
|
+
Update corresponding test files.
|
|
269
|
+
Commit: `refactor: replace session reach-throughs in tools and observation (#277)`.
|
|
270
|
+
|
|
271
|
+
5. Refactor conversation viewer and agent-menu: remove `session: AgentSession` from `ConversationViewerOptions`, use `record` methods for subscribe/messages/contextPercent.
|
|
272
|
+
Update `conversation-viewer.test.ts` and `agent-menu.test.ts`.
|
|
273
|
+
Commit: `refactor: remove raw session from conversation viewer (#277)`.
|
|
274
|
+
|
|
275
|
+
6. Narrow `onSessionCreated` callback: change `AgentLifecycleObserver.onSessionCreated` to `(agent: Agent)` — no session param.
|
|
276
|
+
Update `Agent.run()` and `Agent.resume()` to pass `SubagentSession` (not raw session) to `subscribeAgentObserver`.
|
|
277
|
+
Update spawners (`background-spawner.ts`, `foreground-runner.ts`) and `agent-manager.ts` relay.
|
|
278
|
+
Update corresponding test files.
|
|
279
|
+
Commit: `refactor: narrow onSessionCreated to hide raw AgentSession (#277)`.
|
|
280
|
+
|
|
281
|
+
7. Remove `Agent.session` getter.
|
|
282
|
+
Mark `SubagentSession.session` getter as `@internal`.
|
|
283
|
+
Remove `AgentSession` import from `agent.ts`.
|
|
284
|
+
Update any remaining tests that reference `Agent.session` (use `agent.isSessionReady()` or `agent.subagentSession`).
|
|
285
|
+
Verify with `pnpm run check` and `pnpm -r run test`.
|
|
286
|
+
Commit: `refactor: remove Agent.session getter (#277)`.
|
|
287
|
+
|
|
288
|
+
8. Update the architecture doc's "Session encapsulation debt" section to reflect completion.
|
|
289
|
+
Commit: `docs: mark session encapsulation debt resolved (#277)`.
|
|
290
|
+
|
|
291
|
+
## Risks and Mitigations
|
|
292
|
+
|
|
293
|
+
| Risk | Mitigation |
|
|
294
|
+
| --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
295
|
+
| `SubagentSession` might not structurally satisfy `SubscribableSession` or `SessionLike` | Verify with `pnpm run check` after step 1 — the delegate methods must match the expected signatures exactly. |
|
|
296
|
+
| Making `queueSteer`/`flushPendingSteers` private breaks tests | Step 3 explicitly migrates those tests to exercise `Agent.steer()` instead. |
|
|
297
|
+
| The conversation viewer's `messages` accessor returns `readonly unknown[]` which loses typing | The viewer already casts messages to `{ role: string; [key: string]: unknown }` — the cast is unchanged, and the viewer's rendering is unaffected. |
|
|
298
|
+
| Narrowing `onSessionCreated` could break the `agent-manager.ts` relay's non-null assertion | The relay checks `options.observer?.onSessionCreated` before wiring — the narrowed signature is structurally compatible; the only change is dropping the second parameter. |
|
|
299
|
+
| Large number of test files to update | Steps 3–6 group migrations by concern (steer, stats/conversation/readiness, viewer, observer) so each commit is self-contained and reviewable. |
|
|
300
|
+
|
|
301
|
+
## Open Questions
|
|
302
|
+
|
|
303
|
+
- None — the issue's proposed methods are unambiguous and the acceptance criteria are clear.
|
|
304
|
+
The only design addition beyond the issue is narrowing `onSessionCreated` and adding `messages`/`subscribeToUpdates` for the conversation viewer, both of which are required to satisfy the "no production module outside lifecycle/ references AgentSession" acceptance criterion.
|
|
@@ -56,3 +56,42 @@ Pre-completion reviewer: initial FAIL (MD060 table alignment in SKILL.md, auto-f
|
|
|
56
56
|
- `print-mode.test.ts` now mocks `#src/lifecycle/create-subagent-session` (was `#src/lifecycle/agent-runner`); `index.ts` wraps the factory as `(params) => createSubagentSession(params, deps)`, so the module mock still intercepts it.
|
|
57
57
|
- fallow stayed clean throughout — the transient duplication of IO interfaces + turn-loop helpers between `agent-runner.ts` and the new modules (steps 3–5) was removed in step 6 before the pre-completion gate ran.
|
|
58
58
|
- Reviewer's two minor non-blocking notes: SKILL.md Session-domain count now lists `conversation.ts` but still omits the pre-existing `content-items.ts` (drift predates this issue); `create-subagent-session.ts` keeps an accurate "old runner's runAgent()" provenance comment.
|
|
59
|
+
|
|
60
|
+
## Stage: Final Retrospective (2026-05-30T13:37:00Z)
|
|
61
|
+
|
|
62
|
+
### Session summary
|
|
63
|
+
|
|
64
|
+
Shipped issue #265 (`pi-subagents-v13.1.0`) and ran the retrospective across all three stages (planning, TDD implementation, ship).
|
|
65
|
+
The implementation landed cleanly in 7 TDD commits + 1 docs commit; test count 951 → 960.
|
|
66
|
+
|
|
67
|
+
### Observations
|
|
68
|
+
|
|
69
|
+
#### What went well
|
|
70
|
+
|
|
71
|
+
- The lift-and-shift strategy (new modules alongside old, swap consumers, delete old) kept every intermediate commit compiling and the suite green — zero broken-baseline moments across 7 steps.
|
|
72
|
+
- The `createSubagentSessionStub` pattern (steer/dispose delegate to the wrapped `MockSession`) let 6 tool/service test files migrate with a one-line change each, preserving all existing session-spy assertions.
|
|
73
|
+
- Verification ran incrementally: `pnpm vitest run <file>` after every Red/Green, `pnpm run check` after every interface change, and `pnpm -r run test` (full cross-package) after step 6's deletion to confirm the permission system (1504 tests) was unaffected.
|
|
74
|
+
|
|
75
|
+
#### What caused friction (agent side)
|
|
76
|
+
|
|
77
|
+
1. `missing-context` — The plan's step-5 file list omitted 6 tool/service test files (`steer-tool`, `agent-tool`, `background-spawner`, `foreground-runner`, `get-result-tool`, `service-adapter`) that directly set `record.execution = { session, outputFile }`.
|
|
78
|
+
The existing planning rule ("grep all test files for every removed symbol") was present but was not applied to the renamed `.execution` property — only to removed type imports.
|
|
79
|
+
Impact: step 5 took ~2× expected time; each file was discovered reactively via `tsc --noEmit` errors.
|
|
80
|
+
2. `rabbit-hole` — Step 6's `sed` invocation for import-path renames failed silently on macOS BSD `sed` because the `#` delimiter clashed with `#test/helpers/...` paths. 3 consecutive tool calls were spent diagnosing and retrying before switching to per-file `sed` with `@` delimiters.
|
|
81
|
+
Impact: added ~3 minutes of friction; the `edit` tool would have been safer for targeted, known-file replacements.
|
|
82
|
+
3. `other` (autoformat race) — The `pi-autoformat` extension ran concurrently with `git commit` twice during the docs phase, causing `.git/index.lock` conflicts.
|
|
83
|
+
Recovery was mechanical (remove lock, retry) but required user intervention once.
|
|
84
|
+
Impact: one user prompt to retry; no rework.
|
|
85
|
+
4. `other` (markdown table alignment) — Replacing short table cells with long module lists in SKILL.md broke MD060's compact-table rule.
|
|
86
|
+
The pre-completion reviewer caught it (initial FAIL); `rumdl fmt` auto-fixed it.
|
|
87
|
+
Impact: one amend + re-lint cycle; self-identified after reviewer report.
|
|
88
|
+
|
|
89
|
+
#### What caused friction (user side)
|
|
90
|
+
|
|
91
|
+
- No user-side friction observed.
|
|
92
|
+
The user's only intervention was a retry prompt after the autoformat/git-lock race — a timing issue, not a judgment or context gap.
|
|
93
|
+
|
|
94
|
+
### Changes made
|
|
95
|
+
|
|
96
|
+
1. `.pi/skills/testing/SKILL.md` — added rule about `??` swallowing explicit `undefined` in factory overrides (under "Vitest mock patterns").
|
|
97
|
+
2. `packages/pi-subagents/docs/retro/0265-born-complete-subagent-session.md` — appended Final Retrospective stage entry.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 277
|
|
3
|
+
issue_title: "Encapsulate AgentSession behind SubagentSession; retire the remaining agent.session reach-throughs"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #277 — Encapsulate AgentSession behind SubagentSession
|
|
7
|
+
|
|
8
|
+
## Stage: Planning (2026-05-30T12:00:00Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Produced an 8-step TDD plan covering delegate methods on `SubagentSession`, intent-revealing methods on `Agent`, caller migration across tools/service/UI/observation, observer callback narrowing, and `Agent.session` getter removal.
|
|
13
|
+
The plan extends the issue's three proposed methods with `getContextPercent()`, `subscribeToUpdates()`, and `messages` to fully satisfy the acceptance criterion that no production module outside `lifecycle/` references the raw `AgentSession`.
|
|
14
|
+
|
|
15
|
+
### Observations
|
|
16
|
+
|
|
17
|
+
- `subscribeAgentObserver` already accepts `SubscribableSession` (not `AgentSession`), so adding `subscribe()` to `SubagentSession` enables passing it directly — no `session` getter needed for observer wiring.
|
|
18
|
+
- The `onSessionCreated` observer callback delivers raw `AgentSession` to spawners in `tools/`.
|
|
19
|
+
The plan narrows it to `(agent: Agent)` and has spawners use `agent.subagentSession!` which structurally satisfies both `SubscribableSession` and `SessionLike` via the new delegate methods.
|
|
20
|
+
- The conversation viewer uses `session.messages` for rendering and `session.subscribe()` for live updates — both require delegate methods beyond the issue's three proposed methods.
|
|
21
|
+
- `queueSteer()` and `flushPendingSteers()` become private after migration, which requires migrating existing tests that call them directly.
|
|
22
|
+
- The public API surface (`SubagentsService` + `SubagentRecord`) is unaffected — `Agent` is internal.
|
|
23
|
+
|
|
24
|
+
## Stage: Implementation — TDD (2026-05-30T15:10:00Z)
|
|
25
|
+
|
|
26
|
+
### Session summary
|
|
27
|
+
|
|
28
|
+
All 8 TDD steps completed in one session. 13 commits landed: 2 `feat:`, 5 `refactor:`, 2 `docs:`, 1 `style:`, plus the earlier plan and retro commits.
|
|
29
|
+
Test count went from 960 → 973 (+13 net: +18 new tests, −5 removed tests for the retired `session` getter and `queueSteer`/`flushPendingSteers` private methods).
|
|
30
|
+
|
|
31
|
+
### Observations
|
|
32
|
+
|
|
33
|
+
- `subscribeAgentObserver` already accepted `SubscribableSession`, so `SubagentSession` (once it grew a `subscribe()` delegate) could be passed directly in `Agent.run()` and `Agent.resume()` — no cast needed.
|
|
34
|
+
- Adding `messages` to `MockSession` interface was a minor unplanned step: the field existed at runtime (via `createMockSession`'s spread) but the interface didn't declare it, causing a type error when `createSubagentSessionStub` tried to forward it.
|
|
35
|
+
- `onSessionCreated` tests in `foreground-runner.test.ts` called the callback with 2 args `(record, mockSess)`.
|
|
36
|
+
After narrowing, the first test case was missing a `subagentSession` setup; fixed by setting `record.subagentSession` before invoking the callback.
|
|
37
|
+
- The `get-result-tool.test.ts` verbose conversation test needed updating: previously built a mock session with messages and passed it directly; now the stub's `getConversation` must return the expected text via `mockReturnValue`.
|
|
38
|
+
- Pre-completion reviewer returned **WARN** for two stale `architecture.md` passages: (1) conversation-viewer description still said "subscribes directly to `AgentSession`"; (2) `Agent` classDiagram still listed `queueSteer`/`flushPendingSteers` as public and omitted the 6 new public members.
|
|
39
|
+
Both fixed in a `docs:` commit before closing.
|
|
40
|
+
|
|
41
|
+
## Stage: Final Retrospective (2026-05-30T16:00:00Z)
|
|
42
|
+
|
|
43
|
+
### Session summary
|
|
44
|
+
|
|
45
|
+
Issue #277 shipped across three sessions (planning, TDD, ship) in a single day.
|
|
46
|
+
All 8 TDD steps completed cleanly, 13 implementation commits landed, and `pi-subagents-v13.2.0` released.
|
|
47
|
+
|
|
48
|
+
### Observations
|
|
49
|
+
|
|
50
|
+
#### What went well
|
|
51
|
+
|
|
52
|
+
- Planning correctly identified scope beyond the issue's three proposed methods: `getContextPercent()`, `subscribeToUpdates()`, and `messages` were needed to satisfy the "no production module outside `lifecycle/` references `AgentSession`" acceptance criterion.
|
|
53
|
+
This prevented mid-TDD design revisions.
|
|
54
|
+
- The discovery that `subscribeAgentObserver` already accepted `SubscribableSession` meant adding `subscribe()` to `SubagentSession` was sufficient for observer wiring — no type cast or adapter needed.
|
|
55
|
+
- Lift-and-shift execution was precise: new methods added first (steps 1-2), callers migrated by concern (steps 3-6), old getter removed last (step 7).
|
|
56
|
+
No step broke any other step's tests.
|
|
57
|
+
- Pre-completion reviewer caught two stale `architecture.md` passages (classDiagram with removed public methods, conversation-viewer description) that the implementation steps missed.
|
|
58
|
+
Both fixed in a `docs:` commit before closing.
|
|
59
|
+
|
|
60
|
+
#### What caused friction (agent side)
|
|
61
|
+
|
|
62
|
+
1. `missing-context` — `MockSession` interface lacked `messages`.
|
|
63
|
+
The field existed at runtime (via `createMockSession`'s spread + `Record<string, unknown>` return type) but the `MockSession` interface didn't declare it.
|
|
64
|
+
Adding `messages` to `createSubagentSessionStub` triggered a type error.
|
|
65
|
+
Impact: 2 extra edits to `mock-session.ts` (add field to interface, add to `base` object), no rework.
|
|
66
|
+
2. `missing-context` — `get-result-tool.test.ts` conversation test relied on raw mock session messages.
|
|
67
|
+
After migrating to `record.getConversation()`, the stub's `getConversation` returned `""` by default.
|
|
68
|
+
The test needed `stub.getConversation.mockReturnValue("...")` instead.
|
|
69
|
+
Impact: 1 test update, caught immediately by `pnpm vitest run`.
|
|
70
|
+
3. `missing-context` — `foreground-runner.test.ts` called `onSessionCreated(record, mockSess)` with 2 args.
|
|
71
|
+
After narrowing the callback to `(agent: Agent)`, the first test lacked `record.subagentSession` setup.
|
|
72
|
+
Impact: 2 test blocks updated, caught by `pnpm vitest run`.
|
|
73
|
+
4. `missing-context` — First `get-result-tool.ts` edit left a double-nested `if (conversation)` block.
|
|
74
|
+
The multi-edit replaced the outer `if (params.verbose && record.session)` but preserved the inner guard, creating `if (conversation) { if (conversation) { ... } }`.
|
|
75
|
+
Impact: Self-caught on immediate read-back; fixed in the same commit, no rework.
|
|
76
|
+
|
|
77
|
+
#### What caused friction (user side)
|
|
78
|
+
|
|
79
|
+
- None observed.
|
|
80
|
+
The user's involvement was limited to the `/plan-issue`, `/tdd-plan`, `/ship-issue`, and `/retro` prompts — no mid-session corrections or redirections were needed.
|
package/package.json
CHANGED
|
@@ -113,7 +113,7 @@ export class AgentManager {
|
|
|
113
113
|
this.observer?.onAgentStarted(agent);
|
|
114
114
|
},
|
|
115
115
|
onSessionCreated: options.observer?.onSessionCreated
|
|
116
|
-
? (agent
|
|
116
|
+
? (agent) => options.observer!.onSessionCreated!(agent)
|
|
117
117
|
: undefined,
|
|
118
118
|
onRunFinished: (agent) => {
|
|
119
119
|
if (options.isBackground) {
|
|
@@ -202,7 +202,7 @@ export class AgentManager {
|
|
|
202
202
|
signal?: AbortSignal,
|
|
203
203
|
): Promise<Agent | undefined> {
|
|
204
204
|
const agent = this.agents.get(id);
|
|
205
|
-
if (!agent?.
|
|
205
|
+
if (!agent?.isSessionReady()) return undefined;
|
|
206
206
|
await agent.resume(prompt, signal);
|
|
207
207
|
return agent;
|
|
208
208
|
}
|
package/src/lifecycle/agent.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import type { Model } from "@earendil-works/pi-ai";
|
|
22
|
-
import type {
|
|
22
|
+
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
23
23
|
import { debugLog } from "#src/debug";
|
|
24
24
|
import type { CreateSubagentSessionParams } from "#src/lifecycle/create-subagent-session";
|
|
25
25
|
import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
|
|
@@ -36,8 +36,8 @@ import type { AgentInvocation, CompactionInfo, ParentSessionInfo, SubagentType,
|
|
|
36
36
|
export interface AgentLifecycleObserver {
|
|
37
37
|
/** Fires when the agent transitions to running (inside run(), after markRunning). */
|
|
38
38
|
onStarted?(agent: Agent): void;
|
|
39
|
-
/** Fires once the session is created —
|
|
40
|
-
onSessionCreated?(agent: Agent
|
|
39
|
+
/** Fires once the session is created — the agent's subagentSession is now available. */
|
|
40
|
+
onSessionCreated?(agent: Agent): void;
|
|
41
41
|
/** Fires once when the run completes or fails (for concurrency drain). */
|
|
42
42
|
onRunFinished?(agent: Agent): void;
|
|
43
43
|
/** Fires on compaction events during the run. */
|
|
@@ -154,16 +154,52 @@ export class Agent {
|
|
|
154
154
|
/** Number of steer messages waiting to be delivered. */
|
|
155
155
|
get pendingSteerCount(): number { return this._pendingSteers.length; }
|
|
156
156
|
|
|
157
|
-
/** The active agent session, or undefined before the session is created. */
|
|
158
|
-
get session(): AgentSession | undefined {
|
|
159
|
-
return this.subagentSession?.session;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
157
|
/** Path to the agent's session JSONL file, or undefined if not yet available. */
|
|
163
158
|
get outputFile(): string | undefined {
|
|
164
159
|
return this.subagentSession?.outputFile;
|
|
165
160
|
}
|
|
166
161
|
|
|
162
|
+
/** Returns true when a SubagentSession is available (session is ready). */
|
|
163
|
+
isSessionReady(): boolean {
|
|
164
|
+
return this.subagentSession != null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Deliver or buffer a steer message.
|
|
169
|
+
* Returns true when delivered immediately; false when buffered for later delivery.
|
|
170
|
+
*/
|
|
171
|
+
async steer(message: string): Promise<boolean> {
|
|
172
|
+
if (!this.subagentSession) {
|
|
173
|
+
this.queueSteer(message);
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
await this.subagentSession.steer(message);
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Return the session conversation as formatted text, or undefined if no session. */
|
|
181
|
+
getConversation(): string | undefined {
|
|
182
|
+
return this.subagentSession?.getConversation();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Return the session context window utilization (0-100), or null if unavailable. */
|
|
186
|
+
getContextPercent(): number | null {
|
|
187
|
+
return this.subagentSession?.getContextPercent() ?? null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Subscribe to session events for live updates (e.g., conversation viewer).
|
|
192
|
+
* Returns an unsubscribe function, or undefined if no session is available.
|
|
193
|
+
*/
|
|
194
|
+
subscribeToUpdates(fn: (event: AgentSessionEvent) => void): (() => void) | undefined {
|
|
195
|
+
return this.subagentSession?.subscribe(fn);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The session's message history, or an empty array if no session. */
|
|
199
|
+
get messages(): readonly unknown[] {
|
|
200
|
+
return this.subagentSession?.messages ?? [];
|
|
201
|
+
}
|
|
202
|
+
|
|
167
203
|
constructor(init: AgentInit) {
|
|
168
204
|
// Identity
|
|
169
205
|
this.id = init.id;
|
|
@@ -264,12 +300,11 @@ export class Agent {
|
|
|
264
300
|
return;
|
|
265
301
|
}
|
|
266
302
|
|
|
267
|
-
const session = this.subagentSession.session;
|
|
268
303
|
this.flushPendingSteers();
|
|
269
|
-
this.attachObserver(subscribeAgentObserver(
|
|
304
|
+
this.attachObserver(subscribeAgentObserver(this.subagentSession, this, {
|
|
270
305
|
onCompact: (r, info) => this.observer?.onCompacted?.(r, info),
|
|
271
306
|
}));
|
|
272
|
-
this.observer?.onSessionCreated?.(this
|
|
307
|
+
this.observer?.onSessionCreated?.(this);
|
|
273
308
|
|
|
274
309
|
const runConfig = this._getRunConfig?.();
|
|
275
310
|
try {
|
|
@@ -301,7 +336,7 @@ export class Agent {
|
|
|
301
336
|
}
|
|
302
337
|
|
|
303
338
|
this.resetForResume(Date.now());
|
|
304
|
-
this.attachObserver(subscribeAgentObserver(subagentSession
|
|
339
|
+
this.attachObserver(subscribeAgentObserver(subagentSession, this, {
|
|
305
340
|
onCompact: (r, info) => this.observer?.onCompacted?.(r, info),
|
|
306
341
|
}));
|
|
307
342
|
|
|
@@ -404,17 +439,17 @@ export class Agent {
|
|
|
404
439
|
|
|
405
440
|
/**
|
|
406
441
|
* Buffer a steer message for delivery once the session is ready.
|
|
407
|
-
* Called
|
|
442
|
+
* Called internally from steer() before the session is ready.
|
|
408
443
|
*/
|
|
409
|
-
queueSteer(message: string): void {
|
|
444
|
+
private queueSteer(message: string): void {
|
|
410
445
|
this._pendingSteers.push(message);
|
|
411
446
|
}
|
|
412
447
|
|
|
413
448
|
/**
|
|
414
449
|
* Flush all buffered steer messages to the session and clear the buffer.
|
|
415
|
-
* Called once the session is available
|
|
450
|
+
* Called once the session is available (inside run()).
|
|
416
451
|
*/
|
|
417
|
-
flushPendingSteers(): void {
|
|
452
|
+
private flushPendingSteers(): void {
|
|
418
453
|
for (const msg of this._pendingSteers) {
|
|
419
454
|
this.subagentSession?.steer(msg).catch(() => {});
|
|
420
455
|
}
|
|
@@ -16,7 +16,9 @@ import {
|
|
|
16
16
|
} from "@earendil-works/pi-coding-agent";
|
|
17
17
|
import type { ChildLifecyclePublisher } from "#src/lifecycle/child-lifecycle";
|
|
18
18
|
import { normalizeMaxTurns } from "#src/lifecycle/turn-limits";
|
|
19
|
+
import { getSessionContextPercent, type SessionStatsLike } from "#src/lifecycle/usage";
|
|
19
20
|
import { extractText } from "#src/session/context";
|
|
21
|
+
import { getAgentConversation } from "#src/session/conversation";
|
|
20
22
|
|
|
21
23
|
/** Outcome of one turn loop. */
|
|
22
24
|
export interface TurnLoopResult {
|
|
@@ -61,7 +63,10 @@ export class SubagentSession {
|
|
|
61
63
|
private readonly meta: SubagentSessionMeta,
|
|
62
64
|
) {}
|
|
63
65
|
|
|
64
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* Wrapped session — for lifecycle-internal use only.
|
|
68
|
+
* @internal consumers outside lifecycle/ use the delegate methods below.
|
|
69
|
+
*/
|
|
65
70
|
get session(): AgentSession {
|
|
66
71
|
return this._session;
|
|
67
72
|
}
|
|
@@ -146,6 +151,31 @@ export class SubagentSession {
|
|
|
146
151
|
await this._session.steer(message);
|
|
147
152
|
}
|
|
148
153
|
|
|
154
|
+
/** Return the session's conversation as formatted text. */
|
|
155
|
+
getConversation(): string {
|
|
156
|
+
return getAgentConversation(this._session);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Return the session context window utilization (0-100), or null when unavailable. */
|
|
160
|
+
getContextPercent(): number | null {
|
|
161
|
+
return getSessionContextPercent(this._session);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Subscribe to session events. Satisfies `SubscribableSession`. */
|
|
165
|
+
subscribe(fn: (event: AgentSessionEvent) => void): () => void {
|
|
166
|
+
return this._session.subscribe(fn);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Return session token statistics. Satisfies `SessionLike`. */
|
|
170
|
+
getSessionStats(): SessionStatsLike {
|
|
171
|
+
return this._session.getSessionStats();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The session's message history. */
|
|
175
|
+
get messages(): readonly unknown[] {
|
|
176
|
+
return this._session.messages as readonly unknown[];
|
|
177
|
+
}
|
|
178
|
+
|
|
149
179
|
/** Tear down: session.dispose() + emit `disposed` (registry unregister). */
|
|
150
180
|
dispose(): void {
|
|
151
181
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- dispose may not exist on all session implementations
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { debugLog } from "#src/debug";
|
|
2
|
-
import { getLifetimeTotal
|
|
2
|
+
import { getLifetimeTotal } from "#src/lifecycle/usage";
|
|
3
3
|
import type { Agent } from "#src/types";
|
|
4
4
|
import type { AgentActivityTracker } from "#src/ui/agent-activity-tracker";
|
|
5
5
|
|
|
@@ -46,7 +46,7 @@ export function formatTaskNotification(record: Agent, resultMaxLen: number): str
|
|
|
46
46
|
const status = getStatusLabel(record.status, record.error);
|
|
47
47
|
const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
|
|
48
48
|
const totalTokens = getLifetimeTotal(record.lifetimeUsage);
|
|
49
|
-
const contextPercent =
|
|
49
|
+
const contextPercent = record.getContextPercent();
|
|
50
50
|
const ctxXml = contextPercent !== null ? `<context_percent>${Math.round(contextPercent)}</context_percent>` : "";
|
|
51
51
|
const compactXml = record.compactionCount ? `<compactions>${record.compactionCount}</compactions>` : "";
|
|
52
52
|
|