@linxin666/dsh-pet 0.3.4 → 0.3.5

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.
@@ -424,27 +424,18 @@ describe('PetSprite status bubble', () => {
424
424
  expect(screen.queryByText('正在使用 grep')).not.toBeNull()
425
425
  })
426
426
 
427
- it('lets the inner whisper take over the status bubble', () => {
428
- renderPet({ snapshot: { ...workingSnapshot, whisper: '哼哧哼哧,大脑转得飞快~' } })
429
- // The whisper speaks THROUGH the bubble: it replaces (not accompanies)
430
- // the status copy, so the pet never shows two bubbles at once.
431
- expect(screen.queryByText('哼哧哼哧,大脑转得飞快~')).not.toBeNull()
432
- expect(screen.queryByText('正在思考')).toBeNull()
433
- })
434
-
435
- it('lets the whisper take over the display session bubble in the stack', () => {
427
+ it('lets a session whisper take over its own bubble while others stay collapsed', () => {
436
428
  const { onOpenSession } = renderPet({
437
429
  snapshot: {
438
430
  ...workingSnapshot,
439
- whisper: '我在这儿陪着你呢,别急别急',
440
431
  sessions: [
441
- { sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考' },
432
+ { sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考', whisper: '我在这儿陪着你呢,别急别急' },
442
433
  { sessionId: 's-b', animation: 'running-right', phase: 'tool', bubble: '正在使用 grep' },
443
434
  ],
444
435
  },
445
436
  })
446
- // The primary bubble (the display session) speaks the whisper instead
447
- // of its status copy; the collapsed extra session hides behind the badge.
437
+ // The lead session's bubble speaks ITS whisper instead of its status
438
+ // copy; the collapsed extra session hides behind the badge.
448
439
  expect(screen.queryByText('我在这儿陪着你呢,别急别急')).not.toBeNull()
449
440
  expect(screen.queryByText('正在思考')).toBeNull()
450
441
  expect(screen.queryByText('正在使用 grep')).toBeNull()
@@ -456,9 +447,28 @@ describe('PetSprite status bubble', () => {
456
447
  expect(onOpenSession).toHaveBeenCalledWith('s-a')
457
448
  })
458
449
 
450
+ it('renders each session whisper on its own expanded bubble', () => {
451
+ renderPet({
452
+ snapshot: {
453
+ ...workingSnapshot,
454
+ sessions: [
455
+ { sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考', whisper: 'A 的碎碎念' },
456
+ { sessionId: 's-b', animation: 'running-right', phase: 'tool', bubble: '正在使用 grep', whisper: 'B 的碎碎念' },
457
+ ],
458
+ },
459
+ })
460
+ fireEvent.pointerOver(screen.getByText('A 的碎碎念').closest('div')!)
461
+ expect(screen.queryByText('A 的碎碎念')).not.toBeNull()
462
+ expect(screen.queryByText('B 的碎碎念')).not.toBeNull()
463
+ expect(screen.queryByText('正在思考')).toBeNull()
464
+ })
465
+
459
466
  it('lets transient interaction feedback take over the whisper too', () => {
460
467
  renderPet({
461
- snapshot: { ...workingSnapshot, whisper: '哼哧哼哧,大脑转得飞快~' },
468
+ snapshot: {
469
+ ...workingSnapshot,
470
+ sessions: [{ sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考', whisper: '哼哧哼哧,大脑转得飞快~' }],
471
+ },
462
472
  feedback: { text: '摸摸成功', kind: 'pet', at: 1 },
463
473
  })
464
474
  expect(screen.queryByText('摸摸成功')).not.toBeNull()
@@ -754,7 +764,14 @@ describe('PetSprite status decoration (pet-center M5, #567)', () => {
754
764
  })
755
765
 
756
766
  it('yields the bubble to the whisper (voice moment hides the ornament)', () => {
757
- renderPet({ snapshot: { ...snapshot, phase: 'thinking', whisper: '冲了冲了', decoration } })
767
+ renderPet({
768
+ snapshot: {
769
+ ...snapshot,
770
+ phase: 'thinking',
771
+ decoration,
772
+ sessions: [{ sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考', whisper: '冲了冲了' }],
773
+ },
774
+ })
758
775
  expect(ornament()).toBeNull()
759
776
  expect(document.body.textContent).toContain('冲了冲了')
760
777
  })
@@ -432,14 +432,14 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
432
432
  const statusBubble = feedback === null && sessionBubbles.length === 0
433
433
  ? snapshot?.bubble
434
434
  : undefined
435
- // The display session's inner whisper (碎碎念) — short inner-voice copy
436
- // woken by the model's output. Instead of a second bubble of its own, a
437
- // fresh whisper takes over the display session's bubble (the stack top, or
438
- // the single status bubble) and re-tints it, so the pet never wears two
439
- // voices at once. Interaction feedback takes over the whole bubble area
440
- // while it plays, so whispers yield to it like status copy.
441
- const whisper = feedback === null ? snapshot?.whisper : undefined
442
- const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined
435
+ // Each session's inner whisper (碎碎念) rides its own bubble — short
436
+ // inner-voice copy woken by that session's activity, never the model's or
437
+ // another session's. Instead of a second bubble of its own, a fresh
438
+ // whisper takes over its session's bubble and re-tints it, so the pet
439
+ // never wears two voices at once. Interaction feedback takes over the
440
+ // whole bubble area while it plays, so whispers yield to it like status
441
+ // copy.
442
+ const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== undefined
443
443
  const displayName = snapshot?.name ?? definition.displayName
444
444
  // The host-served status decoration (M5, #567); absent = text-only bubbles.
445
445
  const decoration = snapshot?.decoration
@@ -549,7 +549,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
549
549
  {feedback.text}
550
550
  </div>
551
551
  )}
552
- {feedback === null && (sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined) && (
552
+ {feedback === null && (sessionBubbles.length > 0 || statusBubble !== undefined) && (
553
553
  <div
554
554
  ref={bubbleRef}
555
555
  className={styles.bubbleStack}
@@ -557,15 +557,14 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
557
557
  onPointerLeave={() => setStackPeek(false)}
558
558
  >
559
559
  {visibleSessions.map((session, index) => {
560
- // The whisper rides the display session's bubble the stack's
561
- // primary entry (DOM-first, rendered bottom-most by the reversed
562
- // column so it stays glued to the sprite when extras open above).
563
- // The key swap restarts the entrance animation so the mood change
564
- // reads as the bubble re-speaking.
565
- const speaksWhisper = index === 0 && whisper !== undefined
560
+ // A session's whisper rides ITS OWN bubble (the stack lead when
561
+ // the current session has one, any bubble when the stack is
562
+ // expanded). The key swap restarts the entrance animation so the
563
+ // mood change reads as the bubble re-speaking.
564
+ const speaksWhisper = session.whisper !== undefined
566
565
  const bubble = (
567
566
  <button
568
- key={speaksWhisper ? 'whisper:' + whisper : session.sessionId}
567
+ key={speaksWhisper ? 'whisper:' + session.whisper : session.sessionId}
569
568
  type="button"
570
569
  className={clsx(
571
570
  styles.bubble,
@@ -579,7 +578,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
579
578
  {index === 0 && !speaksWhisper && decoration !== undefined && (
580
579
  <StatusOrnament decoration={decoration} phase={phase} />
581
580
  )}
582
- {speaksWhisper ? whisper : session.bubble}
581
+ {session.whisper ?? session.bubble}
583
582
  </button>
584
583
  )
585
584
  // The primary bubble carries the '+N' badge while other sessions
@@ -608,19 +607,17 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
608
607
  </span>
609
608
  )
610
609
  })}
611
- {sessionBubbles.length === 0 && (statusBubble !== undefined || whisper !== undefined) && (
612
- // The key swap (status copy <-> whisper) restarts the entrance
613
- // animation on every mood change.
610
+ {sessionBubbles.length === 0 && statusBubble !== undefined && (
614
611
  <div
615
- key={whisper === undefined ? 'status' : 'whisper:' + whisper}
616
- className={clsx(styles.bubble, styles.bubbleStatus, whisper !== undefined && styles.bubbleWhisper)}
612
+ key="status"
613
+ className={clsx(styles.bubble, styles.bubbleStatus)}
617
614
  role="status"
618
615
  aria-live="polite"
619
616
  >
620
- {whisper === undefined && decoration !== undefined && (
617
+ {decoration !== undefined && (
621
618
  <StatusOrnament decoration={decoration} phase={phase} />
622
619
  )}
623
- {whisper ?? statusBubble}
620
+ {statusBubble}
624
621
  </div>
625
622
  )}
626
623
  </div>
@@ -110,7 +110,16 @@ function fakeContext(): FakeClientLifecycle {
110
110
  },
111
111
  register: () => () => {},
112
112
  },
113
- sessions: undefined,
113
+ sessions: {
114
+ list: {
115
+ getSnapshot: () => ({ current: undefined, byId: {} }),
116
+ subscribe: (listener: () => void) => {
117
+ const set = new Set<() => void>([listener])
118
+ return () => { set.delete(listener) }
119
+ },
120
+ },
121
+ open: () => {},
122
+ },
114
123
  } as unknown as ClientContext
115
124
  let disposed = false
116
125
  const lifecycle: FakeClientLifecycle = {
@@ -36,7 +36,8 @@ import { reportDailyHeartbeat } from './telemetry.ts'
36
36
 
37
37
  /** The host pet API as the browser sees it (same-origin JSON endpoints). */
38
38
  interface PetHttpApi {
39
- state(): Promise<PetStateView>
39
+ /** Poll the host snapshot; the GUI's current session id rides the query. */
40
+ state(currentSessionId?: string): Promise<PetStateView>
40
41
  pets(): Promise<PetDefinition[]>
41
42
  interact(kind: PetInteraction): Promise<PetInteractResult>
42
43
  setVisible(visible: boolean): Promise<{ ok: true; display: PetDisplayConfig }>
@@ -66,7 +67,8 @@ async function petFetch<T>(path: string, body?: unknown): Promise<T> {
66
67
 
67
68
  /** The live host API instance (always defined; failures surface per call). */
68
69
  const petApi: PetHttpApi = {
69
- state: () => petFetch('/api/pet/state'),
70
+ state: (currentSessionId) => petFetch('/api/pet/state'
71
+ + (currentSessionId === undefined ? '' : '?current=' + encodeURIComponent(currentSessionId))),
70
72
  pets: () => petFetch('/api/pet/pets'),
71
73
  interact: (kind) => petFetch('/api/pet/interact', { kind }),
72
74
  setVisible: (visible) => petFetch('/api/pet/set-visible', { visible }),
@@ -197,6 +199,20 @@ export function apply(ctx: ClientContext): void {
197
199
  const setState = petStore.actions.setState
198
200
  const setFeedback = petStore.actions.setFeedback
199
201
 
202
+ // Clicking a session bubble jumps the GUI to that session; the same
203
+ // sessions face reports which session the user is currently on, so the
204
+ // host can lead the bubble stack with it. A bubble can outlive its
205
+ // disposed session by one poll tick, and the sessions service fails
206
+ // loud on unknown ids, so consult the live list first. The pet's type
207
+ // program also loads the host-side dsh-session package through the
208
+ // service types, whose Context merge declares a different 'sessions'
209
+ // face; pin the browser runtime's outward face here.
210
+ const sessions = ctx.sessions as unknown as ISessions
211
+ const currentSessionId = (): string | undefined => {
212
+ const current = sessions.list.getSnapshot().current
213
+ return current === undefined ? undefined : String(current)
214
+ }
215
+
200
216
  // The registry list is fetched lazily with retries baked into the poll
201
217
  // cycle: until it lands, the dock entry renders nothing and every 2s
202
218
  // tick tries again. After it lands, one list feeds both the sprite and
@@ -218,7 +234,7 @@ export function apply(ctx: ClientContext): void {
218
234
  }
219
235
  const seq = stateSeq + 1
220
236
  stateSeq = seq
221
- petApi.state().then((snapshot) => {
237
+ petApi.state(currentSessionId()).then((snapshot) => {
222
238
  if (seq !== stateSeq) return
223
239
  setSnapshot(snapshot)
224
240
  }, () => {
@@ -261,13 +277,16 @@ export function apply(ctx: ClientContext): void {
261
277
  }
262
278
  }, 'pet: poll')
263
279
 
264
- // Clicking a session bubble jumps the GUI to that session. A bubble
265
- // can outlive its disposed session by one poll tick, and the sessions
266
- // service fails loud on unknown ids, so consult the live list first.
267
- // The pet's type program also loads the host-side dsh-session package
268
- // through the service types, whose Context merge declares a different
269
- // 'sessions' face; pin the browser runtime's outward face here.
270
- const sessions = ctx.sessions as unknown as ISessions
280
+ // A current-session switch should re-order the bubble stack right away,
281
+ // not on the next 2s tick. Poll only while the tab is visible, as the
282
+ // poll loop itself does.
283
+ const disposeSessionWatch = ctx.effect(() => {
284
+ const unsubscribe = sessions.list.subscribe(() => {
285
+ if (document.visibilityState === 'visible') pollNow()
286
+ })
287
+ return unsubscribe
288
+ }, 'pet: current-session watch')
289
+
271
290
  const openSession = (sessionId: string): void => {
272
291
  const list = sessions.list.getSnapshot()
273
292
  if (list.byId[sessionId as SessionId] === undefined) return
@@ -5,15 +5,26 @@
5
5
  * {@link ProjectionRuntime} per session and feed events in arrival order.
6
6
  *
7
7
  * Status copy comes from the chatter voice (big rotating pools, per-tool
8
- * families, real-argument hints), and streamed model output feeds the murmur
9
- * engine so the pet can whisper its inner voice (碎碎念). The wall clock is
8
+ * families, real-argument hints), and the projection feeds the murmur engine
9
+ * (碎碎念) with the SITUATION thinking/writing during the stream, the
10
+ * running tool family on tool calls, and the STRUCTURED outcome on
11
+ * tool/result and turn/end — so the pet's inner voice always roughly knows
12
+ * what is going on and never mis-fires on output text. The wall clock is
10
13
  * injected by the caller, keeping every projection reproducible.
11
14
  * @module @linxin666/dsh-pet/event-projection
12
15
  */
13
16
 
14
17
  import type { SessionEvent } from '@deepseek-ai/dsh-session'
15
18
  import type { PetStateInput } from './state.ts'
16
- import { StatusVoice, toolArgHint, WhisperEngine, type VoicePoolsProvider } from './chatter.ts'
19
+ import {
20
+ StatusVoice,
21
+ toolArgHint,
22
+ toolCategory,
23
+ WhisperEngine,
24
+ whisperCategoryOf,
25
+ looksLikeTestTool,
26
+ type VoicePoolsProvider,
27
+ } from './chatter.ts'
17
28
 
18
29
  /** Runtime shape of the optional legacy activity event. */
19
30
  export interface ActivityStatusEventLike {
@@ -25,11 +36,13 @@ export interface ActivityStatusEventLike {
25
36
  /** Per-session facts needed to project the official event stream. */
26
37
  export interface ProjectionRuntime {
27
38
  activeTools: Set<string>
39
+ /** callIds whose tool looked like a test run, marked at tool/call (pet M6). */
40
+ testCalls: Set<string>
28
41
  officialEventsSeen: boolean
29
42
  stepHadFailure: boolean
30
43
  /** Round-robin status copy voice (scene-stable, cadence-rotated). */
31
44
  voice: StatusVoice
32
- /** Inner-whisper engine fed by the model's own streamed output. */
45
+ /** Inner-whisper engine fed by the projection's situation and outcomes. */
33
46
  whispers: WhisperEngine
34
47
  }
35
48
 
@@ -37,7 +50,7 @@ export interface ProjectionRuntime {
37
50
  export interface PetActivityTransition {
38
51
  input: PetStateInput
39
52
  completedTurn?: number
40
- /** A fresh inner whisper woken by this event's model output, when any. */
53
+ /** A fresh inner whisper woken by this event, when any. */
41
54
  whisper?: string
42
55
  }
43
56
 
@@ -50,6 +63,7 @@ export interface PetActivityTransition {
50
63
  export function emptyProjectionRuntime(pools?: VoicePoolsProvider): ProjectionRuntime {
51
64
  return {
52
65
  activeTools: new Set(),
66
+ testCalls: new Set(),
53
67
  officialEventsSeen: false,
54
68
  stepHadFailure: false,
55
69
  voice: new StatusVoice(pools),
@@ -81,6 +95,7 @@ export function projectOfficialEvent(
81
95
  switch (event.type) {
82
96
  case 'turn/start':
83
97
  runtime.activeTools.clear()
98
+ runtime.testCalls.clear()
84
99
  runtime.stepHadFailure = false
85
100
  return { input: { phase: 'waiting', line: runtime.voice.scene('prepare', nowMs) } }
86
101
  case 'step/start':
@@ -90,14 +105,14 @@ export function projectOfficialEvent(
90
105
  case 'assistant/chunk': {
91
106
  const { chunk } = event.data
92
107
  if (chunk.type === 'reasoning-delta' && chunk.text.length > 0) {
93
- const whisper = runtime.whispers.feed(chunk.text, nowMs)
108
+ const whisper = runtime.whispers.feed('thinking', nowMs)
94
109
  return {
95
110
  input: { phase: 'thinking', line: runtime.voice.scene('thinking', nowMs) },
96
111
  ...(whisper === undefined ? {} : { whisper }),
97
112
  }
98
113
  }
99
114
  if (chunk.type === 'text-delta' && chunk.text.length > 0) {
100
- const whisper = runtime.whispers.feed(chunk.text, nowMs)
115
+ const whisper = runtime.whispers.feed('writing', nowMs)
101
116
  return {
102
117
  input: { phase: 'review', line: runtime.voice.scene('review', nowMs) },
103
118
  ...(whisper === undefined ? {} : { whisper }),
@@ -107,8 +122,13 @@ export function projectOfficialEvent(
107
122
  }
108
123
  case 'assistant/message':
109
124
  return { input: { phase: 'review', line: runtime.voice.scene('review', nowMs) } }
110
- case 'tool/call':
111
- runtime.activeTools.add(String(event.data.callId))
125
+ case 'tool/call': {
126
+ const callId = String(event.data.callId)
127
+ runtime.activeTools.add(callId)
128
+ // A test-looking call is remembered so its result (the only place the
129
+ // outcome is known) can wake the test-green mood later.
130
+ if (looksLikeTestTool(event.data.name, event.data.arguments)) runtime.testCalls.add(callId)
131
+ const whisper = runtime.whispers.feed(whisperCategoryOf(toolCategory(event.data.name)), nowMs)
112
132
  return {
113
133
  input: {
114
134
  phase: 'tool',
@@ -119,33 +139,54 @@ export function projectOfficialEvent(
119
139
  nowMs,
120
140
  ),
121
141
  },
142
+ ...(whisper === undefined ? {} : { whisper }),
122
143
  }
144
+ }
123
145
  case 'tool/result': {
124
146
  const block = event.data.message.content[0]
125
- runtime.activeTools.delete(String(event.data.message.source.callId))
126
- runtime.stepHadFailure ||= event.data.error !== undefined || block.isError === true
147
+ const callId = String(event.data.message.source.callId)
148
+ const failed = event.data.error !== undefined || block.isError === true
149
+ const wasTest = runtime.testCalls.delete(callId)
150
+ runtime.activeTools.delete(callId)
151
+ runtime.stepHadFailure ||= failed
152
+ const whisper = failed
153
+ ? runtime.whispers.result('fail', nowMs)
154
+ : wasTest
155
+ ? runtime.whispers.result('pass', nowMs)
156
+ : undefined
157
+ const whisperSpread = whisper === undefined ? {} : { whisper }
127
158
  if (runtime.activeTools.size > 0) {
128
159
  return {
129
160
  input: {
130
161
  phase: 'tool',
131
162
  line: runtime.voice.toolRemaining(runtime.activeTools.size, nowMs),
132
163
  },
164
+ ...whisperSpread,
133
165
  }
134
166
  }
135
167
  return runtime.stepHadFailure
136
- ? { input: { phase: 'failed', line: runtime.voice.scene('toolFailed', nowMs) } }
137
- : { input: { phase: 'thinking', line: runtime.voice.scene('toolResult', nowMs) } }
168
+ ? { input: { phase: 'failed', line: runtime.voice.scene('toolFailed', nowMs) }, ...whisperSpread }
169
+ : { input: { phase: 'thinking', line: runtime.voice.scene('toolResult', nowMs) }, ...whisperSpread }
138
170
  }
139
171
  case 'turn/end': {
140
172
  runtime.activeTools.clear()
173
+ runtime.testCalls.clear()
141
174
  switch (event.data.reason.kind) {
142
- case 'completed':
175
+ case 'completed': {
176
+ const whisper = runtime.whispers.result('done', nowMs)
143
177
  return {
144
178
  input: { phase: 'done', line: runtime.voice.scene('done', nowMs) },
145
179
  completedTurn: event.data.turn,
180
+ ...(whisper === undefined ? {} : { whisper }),
146
181
  }
147
- case 'error':
148
- return { input: { phase: 'failed', line: runtime.voice.scene('failed', nowMs) } }
182
+ }
183
+ case 'error': {
184
+ const whisper = runtime.whispers.result('fail', nowMs)
185
+ return {
186
+ input: { phase: 'failed', line: runtime.voice.scene('failed', nowMs) },
187
+ ...(whisper === undefined ? {} : { whisper }),
188
+ }
189
+ }
149
190
  case 'max-tokens':
150
191
  return { input: { phase: 'failed', line: runtime.voice.scene('maxTokens', nowMs) } }
151
192
  case 'interrupted':
package/src/routes.ts CHANGED
@@ -112,15 +112,15 @@ function guard(ctx: Context, req: IncomingMessage, res: ServerResponse): boolean
112
112
  return false
113
113
  }
114
114
 
115
- /** Wrap one async service call as a GET JSON route. */
116
- function getRoute(ctx: Context, path: string, run: () => Promise<unknown>): WebRoute {
115
+ /** Wrap one async service call as a GET JSON route (request passed through for query params). */
116
+ function getRoute(ctx: Context, path: string, run: (req: IncomingMessage) => Promise<unknown>): WebRoute {
117
117
  return {
118
118
  kind: 'exact',
119
119
  path,
120
120
  handler: (req: IncomingMessage, res: ServerResponse): void => {
121
121
  if (!guard(ctx, req, res)) return
122
122
  if (!requireMethod(req, res, 'GET')) return
123
- run().then((value) => writeJson(res, 200, value), (error) => {
123
+ run(req).then((value) => writeJson(res, 200, value), (error) => {
124
124
  writeJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })
125
125
  })
126
126
  },
@@ -525,7 +525,12 @@ function decorationHandler(ctx: Context, registry: PetRegistry, caps: PetAssetCa
525
525
  export function makePetRoutes(deps: { service: PetService; ctx: Context; assetCaps?: PetAssetCaps } & PetRuntimeRoots): WebRoute[] {
526
526
  const { service, ctx } = deps
527
527
  const apiRoutes: WebRoute[] = [
528
- getRoute(ctx, PET_API_PREFIX + '/state', () => service.state()),
528
+ getRoute(ctx, PET_API_PREFIX + '/state', (req) => {
529
+ // The browser half reports the GUI's current session id so the bubble
530
+ // stack can lead with the session the user is actually looking at.
531
+ const current = new URL(req.url ?? '/', 'http://pet.local').searchParams.get('current')
532
+ return service.state(current === null || current === '' ? undefined : current)
533
+ }),
529
534
  getRoute(ctx, PET_API_PREFIX + '/pets', () => service.pets()),
530
535
  getRoute(ctx, PET_API_PREFIX + '/diagnostics', () => service.diagnostics()),
531
536
  postRoute(ctx, PET_API_PREFIX + '/interact', (body) => {
package/src/service.ts CHANGED
@@ -137,6 +137,8 @@ export interface PetSessionView {
137
137
  bubble: string
138
138
  /** This session's raw activity phase. */
139
139
  phase: PetStateSnapshot['phase']
140
+ /** This session's fresh inner whisper (碎碎念), when one is within its TTL. */
141
+ whisper?: string
140
142
  }
141
143
 
142
144
  /** Hard cap on simultaneously displayed session bubbles (most recent first). */
@@ -149,10 +151,10 @@ export interface PetStateView {
149
151
  phase: PetStateSnapshot['phase']
150
152
  sessionActive: boolean
151
153
  /**
152
- * Per-session bubbles for every concurrently active TOP-LEVEL session,
153
- * most recent first; optional so older hosts without the multi-session
154
- * view stay consumable. The single 'bubble' above mirrors the display
155
- * session.
154
+ * Per-session bubbles for every concurrently active TOP-LEVEL session:
155
+ * the GUI's current session first when reported, the rest most recently
156
+ * active first; optional so older hosts without the multi-session view
157
+ * stay consumable. The single 'bubble' above mirrors the display session.
156
158
  */
157
159
  sessions?: PetSessionView[]
158
160
  /** Affinity ledger snapshot. */
@@ -177,12 +179,6 @@ export interface PetStateView {
177
179
  /** Stock cap. */
178
180
  max: number
179
181
  }
180
- /**
181
- * The display session's fresh inner whisper (碎碎念), when one is within
182
- * its TTL — short inner-voice copy woken by the model's own output,
183
- * rendered by the client as a distinct whisper bubble.
184
- */
185
- whisper?: string
186
182
  /**
187
183
  * The active status decoration (pet-center M5, #567), when the master
188
184
  * switch is on and the default decoration entry exists. Absent means the
@@ -338,8 +334,8 @@ export class PetService extends Service {
338
334
  }
339
335
 
340
336
  /** RPC: current pet state snapshot. */
341
- async state(): Promise<PetStateView> {
342
- return this.view()
337
+ async state(currentSessionId?: string): Promise<PetStateView> {
338
+ return this.view(currentSessionId)
343
339
  }
344
340
 
345
341
  /** Current persisted display config (read-only view). */
@@ -763,37 +759,45 @@ export class PetService extends Service {
763
759
  if (this.ledger.rewardLegacyTurn(Date.now())) this.flush()
764
760
  }
765
761
 
766
- private view(): PetStateView {
762
+ private view(currentSessionId?: string): PetStateView {
767
763
  const snapshot = this.machine.render()
768
764
  const entry = this.activeEntry()
769
- // One bubble per concurrently active TOP-LEVEL session, most recent
770
- // first. Subagent children render no bubble of their own (their activity
771
- // already shows through the spawning conversation's bubble/display, and
772
- // the bubble buttons navigate to GUI sessions, which subagents are not).
773
- // Sessions whose own machine has settled (no bubble copy) drop out, so a
774
- // finished turn does not leave a stale bubble behind.
765
+ // One bubble per concurrently active TOP-LEVEL session. The GUI's current
766
+ // session leads the stack when reported (the browser half passes its
767
+ // session list's 'current'); everything else keeps the most recent
768
+ // meaningful event order. Subagent children render no bubble of their own
769
+ // (their activity already shows through the spawning conversation's
770
+ // bubble/display, and the bubble buttons navigate to GUI sessions, which
771
+ // subagents are not). Sessions whose own machine has settled (no bubble
772
+ // copy) drop out, so a finished turn does not leave a stale bubble behind.
775
773
  const sessions: PetSessionView[] = []
776
774
  for (const [session, activity] of [...this.sessionActivity.entries()].reverse()) {
777
775
  if (sessions.length >= MAX_SESSION_BUBBLES) break
778
776
  if (session.header?.origin === 'subagent') continue
779
777
  const perSession = activity.machine.render()
780
778
  if (perSession.bubble === undefined) continue
779
+ // Each session's whisper rides its own bubble while fresh; an expired
780
+ // whisper simply stops appearing (the client's 2 s poll drops it).
781
+ const whisper = activity.whisper
782
+ const freshWhisper = whisper !== undefined && Date.now() - whisper.at < WHISPER_TTL_MS
783
+ ? whisper.text
784
+ : undefined
781
785
  sessions.push({
782
786
  sessionId: String(session.id),
783
787
  animation: perSession.animation,
784
788
  bubble: perSession.bubble,
785
789
  phase: perSession.phase,
790
+ ...(freshWhisper === undefined ? {} : { whisper: freshWhisper }),
786
791
  })
787
792
  }
788
- // The display session's inner whisper rides the global view while fresh;
789
- // an expired whisper simply stops appearing (the client's 2s poll drops it).
790
- const displayActivity = this.displaySession === undefined
791
- ? undefined
792
- : this.sessionActivity.get(this.displaySession)
793
- const whisper = displayActivity?.whisper
794
- const freshWhisper = whisper !== undefined && Date.now() - whisper.at < WHISPER_TTL_MS
795
- ? whisper.text
796
- : undefined
793
+ // The browser half reports the session the user is currently on; that
794
+ // session's bubble leads the stack (its whisper then speaks on the
795
+ // collapsed single bubble). An unreported or absent session keeps the
796
+ // legacy most-recent-first order.
797
+ if (currentSessionId !== undefined) {
798
+ const index = sessions.findIndex(session => session.sessionId === currentSessionId)
799
+ if (index > 0) sessions.unshift(sessions.splice(index, 1)[0]!)
800
+ }
797
801
  const decoration = this.activeDecoration()
798
802
  // Gameplay view: a read-only projection. The settle runs on a copy, so
799
803
  // polling never writes pet.json (verbs settle and persist).
@@ -812,7 +816,6 @@ export class PetService extends Service {
812
816
  phase: snapshot.phase,
813
817
  sessionActive: snapshot.sessionActive,
814
818
  sessions,
815
- ...(freshWhisper === undefined ? {} : { whisper: freshWhisper }),
816
819
  ...(decoration === undefined ? {} : { decoration }),
817
820
  affinity: this.ledger.affinityView(Date.now()),
818
821
  display: { ...this.ledger.snapshot.display },