mycode-cli 0.18.0__py3-none-any.whl → 0.19.0__py3-none-any.whl

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.
mycode_cli/runtime.py CHANGED
@@ -5,26 +5,21 @@ from __future__ import annotations
5
5
  from mycode.agent import Agent
6
6
  from mycode_cli.config import ResolvedProvider, Settings
7
7
  from mycode_cli.permissions import ToolReviewCallback, build_permission_hooks
8
- from mycode_cli.sessions import SessionStore, sum_session_cost
8
+ from mycode_cli.sessions import SessionStore, SessionTotals, sum_session_totals
9
9
  from mycode_cli.system_prompt import build_system_prompt
10
10
  from mycode_cli.tools import DEFAULT_TOOLS
11
11
  from mycode_cli.web_tools import build_web_tools
12
12
  from mycode_cli.workspace import CliDeps
13
13
 
14
14
 
15
- def sum_known_costs(*costs: float | None) -> float | None:
16
- known = [cost for cost in costs if cost is not None]
17
- return sum(known) if known else None
15
+ async def load_session_totals(store: SessionStore, session_id: str) -> SessionTotals:
16
+ """Load the session's cumulative usage and USD cost from its raw JSONL timeline.
18
17
 
19
-
20
- async def load_session_cost(store: SessionStore, session_id: str) -> float | None:
21
- """Load the session's cumulative USD cost from its raw JSONL timeline.
22
-
23
- Counts persisted costs from tool loops, compact summaries, and turns
24
- discarded by rewind. Records without a cost are skipped.
18
+ Counts tool loops, compact summaries, and turns discarded by rewind.
19
+ Records without usage or cost contribute nothing to that total.
25
20
  """
26
21
 
27
- return sum_session_cost(await store.load_raw_messages(session_id))
22
+ return sum_session_totals(await store.load_raw_messages(session_id))
28
23
 
29
24
 
30
25
  def build_agent(
@@ -21,7 +21,7 @@ from mycode.attachments import (
21
21
  detect_document_mime_type,
22
22
  detect_image_mime_type,
23
23
  )
24
- from mycode.compact import has_compactable_history
24
+ from mycode.compact import DEFAULT_COMPACT_THRESHOLD, has_compactable_history
25
25
  from mycode.messages import (
26
26
  ConversationMessage,
27
27
  build_message,
@@ -30,6 +30,7 @@ from mycode.messages import (
30
30
  image_block,
31
31
  text_block,
32
32
  )
33
+ from mycode.models import ModelMetadata
33
34
  from mycode_cli.config import (
34
35
  ResolvedProvider,
35
36
  get_settings,
@@ -40,19 +41,21 @@ from mycode_cli.config import (
40
41
  resolve_provider_choices,
41
42
  )
42
43
  from mycode_cli.permissions import ToolReviewDecision, ToolReviewRequest
43
- from mycode_cli.runtime import build_agent, load_session_cost
44
+ from mycode_cli.runtime import build_agent, load_session_totals
44
45
  from mycode_cli.server.deps import RunManagerDep, StoreDep, resolve_workspace_cwd
45
- from mycode_cli.server.run_manager import ActiveRunError
46
+ from mycode_cli.server.run_manager import ActiveRunError, RunAgent
46
47
  from mycode_cli.server.schemas import (
47
48
  CancelRunResponse,
48
49
  ChatRequest,
49
50
  ChatResponse,
50
51
  CompactRequest,
51
- CompactResponse,
52
52
  DecideRequest,
53
+ PendingInputRequest,
53
54
  RunInfo,
55
+ RunResponse,
54
56
  StatusResponse,
55
57
  StreamEvent,
58
+ UserInputRequest,
56
59
  )
57
60
  from mycode_cli.system_prompt import build_skill_snapshot_blocks, discover_slash_skills
58
61
  from mycode_cli.workspace import resolve_path
@@ -86,7 +89,7 @@ def _read_workspace_text_attachment(rel_path: str, *, name: str | None, cwd: str
86
89
  raise HTTPException(status_code=400, detail=str(exc)) from exc
87
90
 
88
91
 
89
- async def _build_user_message(chat: ChatRequest, cwd: str) -> ConversationMessage:
92
+ async def _build_user_message(chat: UserInputRequest, cwd: str) -> ConversationMessage:
90
93
  if not chat.input:
91
94
  text = str(chat.message or "").strip()
92
95
  snapshots = await asyncio.to_thread(build_skill_snapshot_blocks, text, cwd)
@@ -143,6 +146,14 @@ async def _build_user_message(chat: ChatRequest, cwd: str) -> ConversationMessag
143
146
  return build_message("user", blocks)
144
147
 
145
148
 
149
+ def _check_input_support(message: ConversationMessage, model: ModelMetadata | RunAgent) -> None:
150
+ content_types = {b.get("type") for b in (message.get("content") or []) if isinstance(b, dict)}
151
+ if "image" in content_types and not model.supports_image_input:
152
+ raise HTTPException(status_code=400, detail="current model does not support image input")
153
+ if "document" in content_types and not model.supports_pdf_input:
154
+ raise HTTPException(status_code=400, detail="current model does not support PDF input")
155
+
156
+
146
157
  def _validate_rewind_request(
147
158
  *,
148
159
  session: dict[str, Any] | None,
@@ -192,11 +203,7 @@ async def chat(chat: ChatRequest, store: StoreDep, runs: RunManagerDep) -> ChatR
192
203
  model=resolved.model,
193
204
  model_config=model_config,
194
205
  )
195
- content_types = {b.get("type") for b in (user_message.get("content") or []) if isinstance(b, dict)}
196
- if "image" in content_types and not model_meta.supports_image_input:
197
- raise HTTPException(status_code=400, detail="current model does not support image input")
198
- if "document" in content_types and not model_meta.supports_pdf_input:
199
- raise HTTPException(status_code=400, detail="current model does not support PDF input")
206
+ _check_input_support(user_message, model_meta)
200
207
 
201
208
  reasoning_effort = resolved.reasoning_effort
202
209
  if "reasoning_effort" in chat.model_fields_set:
@@ -274,10 +281,11 @@ async def chat(chat: ChatRequest, store: StoreDep, runs: RunManagerDep) -> ChatR
274
281
  try:
275
282
  run = await runs.start_run(
276
283
  session_id=session_id,
284
+ cwd=cwd,
277
285
  user_message=user_message,
278
286
  base_messages=agent.messages,
279
287
  agent=agent,
280
- session_cost_base=await load_session_cost(store, session_id),
288
+ session_base=await load_session_totals(store, session_id),
281
289
  )
282
290
  except ActiveRunError as exc:
283
291
  existing = await runs.get_run(exc.run_id)
@@ -339,7 +347,7 @@ async def compact_session(
339
347
  body: CompactRequest,
340
348
  store: StoreDep,
341
349
  runs: RunManagerDep,
342
- ) -> CompactResponse:
350
+ ) -> RunResponse:
343
351
  """Start a compact run: summarize the session and append one compact marker."""
344
352
 
345
353
  async with runs.session_operation(session_id):
@@ -372,9 +380,10 @@ async def compact_session(
372
380
  try:
373
381
  run = await runs.start_compact(
374
382
  session_id=session_id,
383
+ cwd=cwd,
375
384
  base_messages=agent.messages,
376
385
  agent=agent,
377
- session_cost_base=data["session_cost"],
386
+ session_base=data["totals"],
378
387
  on_complete=store.touch,
379
388
  )
380
389
  except ActiveRunError as exc:
@@ -384,7 +393,86 @@ async def compact_session(
384
393
  detail["run"] = existing.info()
385
394
  raise HTTPException(status_code=409, detail=detail) from exc
386
395
 
387
- return CompactResponse(run=RunInfo.model_validate(run))
396
+ return RunResponse(run=RunInfo.model_validate(run))
397
+
398
+
399
+ @router.post("/runs/{run_id}/steer")
400
+ async def steer_run(
401
+ run_id: Annotated[str, PathParam(min_length=1)], body: PendingInputRequest, runs: RunManagerDep
402
+ ) -> RunResponse:
403
+ """Hand a user message to the running chat for its next step boundary."""
404
+
405
+ state = await runs.get_run(run_id)
406
+ if state is None:
407
+ raise HTTPException(status_code=404, detail="run not found")
408
+ message = await _build_user_message(body, state.cwd)
409
+ message["meta"] = {"input_id": body.input_id}
410
+ _check_input_support(message, state.agent)
411
+ if not state.agent.steer(message):
412
+ raise HTTPException(
413
+ status_code=409,
414
+ detail={"message": "run is not accepting steers", "run": state.info()},
415
+ )
416
+ return RunResponse(run=RunInfo.model_validate(state.info()))
417
+
418
+
419
+ @router.post("/sessions/{session_id}/queue")
420
+ async def queue_message(
421
+ session_id: Annotated[str, PathParam(min_length=1)],
422
+ body: PendingInputRequest,
423
+ store: StoreDep,
424
+ runs: RunManagerDep,
425
+ ) -> RunResponse:
426
+ """Queue a user message as the next turn of the session's running chat."""
427
+
428
+ state = await runs.get_active_run(session_id)
429
+ if state is None:
430
+ raise HTTPException(status_code=409, detail={"message": "session has no running chat to queue on"})
431
+ message = await _build_user_message(body, state.cwd)
432
+ message["meta"] = {"input_id": body.input_id}
433
+ _check_input_support(message, state.agent)
434
+ if not await runs.enqueue(state, message):
435
+ raise HTTPException(status_code=409, detail={"message": "session has no running chat to queue on"})
436
+ await store.record_user_turn(
437
+ session_id,
438
+ cwd=state.cwd,
439
+ text=flatten_message_text(message, include_thinking=False),
440
+ )
441
+ return RunResponse(run=RunInfo.model_validate(state.info()))
442
+
443
+
444
+ @router.delete("/sessions/{session_id}/queue/{input_id}")
445
+ async def remove_queued_message(
446
+ session_id: Annotated[str, PathParam(min_length=1)],
447
+ input_id: Annotated[str, PathParam(min_length=1)],
448
+ runs: RunManagerDep,
449
+ ) -> RunResponse:
450
+ """Remove a queued message that has not been delivered."""
451
+
452
+ run = await runs.remove_queued(session_id, input_id)
453
+ if run is None:
454
+ raise HTTPException(status_code=404, detail="queued message not found")
455
+ return RunResponse(run=RunInfo.model_validate(run))
456
+
457
+
458
+ @router.post("/sessions/{session_id}/queue/{input_id}/steer")
459
+ async def steer_queued_message(
460
+ session_id: Annotated[str, PathParam(min_length=1)],
461
+ input_id: Annotated[str, PathParam(min_length=1)],
462
+ runs: RunManagerDep,
463
+ ) -> RunResponse:
464
+ """Move a queued message into the current turn, keeping it as built."""
465
+
466
+ state = await runs.get_active_run(session_id)
467
+ moved = None if state is None else await runs.steer_queued(state, input_id)
468
+ if state is None or moved is None:
469
+ raise HTTPException(status_code=404, detail="queued message not found")
470
+ if not moved:
471
+ raise HTTPException(
472
+ status_code=409,
473
+ detail={"message": "run is not accepting steers", "run": state.info()},
474
+ )
475
+ return RunResponse(run=RunInfo.model_validate(state.info()))
388
476
 
389
477
 
390
478
  @router.get("/config")
@@ -454,6 +542,10 @@ async def get_config(cwd: Annotated[str | None, Query()] = None) -> dict[str, An
454
542
  "cwd_exists": os.path.isdir(resolved_cwd),
455
543
  "project": settings.project,
456
544
  "config_paths": settings.config_paths,
545
+ # Effective for this cwd; 0 disables automatic compaction.
546
+ "compact_threshold": (
547
+ settings.compact_threshold if settings.compact_threshold is not None else DEFAULT_COMPACT_THRESHOLD
548
+ ),
457
549
  "skills": [{"name": skill.name, "description": skill.description} for skill in skills],
458
550
  "setup_error": setup_error,
459
551
  }
@@ -11,6 +11,7 @@ from fastapi import Path as PathParam
11
11
  from mycode.messages import ConversationMessage
12
12
  from mycode_cli.server.deps import RunManagerDep, StoreDep, resolve_workspace_cwd
13
13
  from mycode_cli.server.schemas import SessionCreateRequest, StatusResponse
14
+ from mycode_cli.sessions import SessionTotals
14
15
 
15
16
  router = APIRouter(prefix="/sessions", tags=["sessions"])
16
17
 
@@ -50,6 +51,21 @@ async def list_sessions(
50
51
  return {"sessions": sessions}
51
52
 
52
53
 
54
+ @router.get("/search")
55
+ async def search_sessions(
56
+ store: StoreDep,
57
+ runs: RunManagerDep,
58
+ q: Annotated[str, Query(min_length=1)],
59
+ cwd: Annotated[str | None, Query()] = None,
60
+ limit: Annotated[int, Query(ge=1)] = 50,
61
+ ) -> dict[str, Any]:
62
+ results = await store.search_sessions(q, cwd=cwd, limit=limit)
63
+ for result in results:
64
+ session = result["session"]
65
+ session["is_running"] = await runs.has_active_run(str(session["id"]))
66
+ return {"results": results}
67
+
68
+
53
69
  @router.get("/{session_id}")
54
70
  async def load_session(
55
71
  session_id: Annotated[str, PathParam(min_length=1)], store: StoreDep, runs: RunManagerDep
@@ -62,22 +78,31 @@ async def load_session(
62
78
  return {
63
79
  "session": await store.load_metadata(session_id),
64
80
  "messages": _redact_document_data(active["messages"]),
65
- "session_cost": active["session_cost"],
81
+ **active["totals"].payload(),
66
82
  "active_run": active["run"],
67
83
  "pending_events": active["pending_events"],
84
+ "pending": {kind: _redact_document_data(messages) for kind, messages in active["pending"].items()},
68
85
  }
69
86
 
70
87
  data = await store.load_session(session_id)
71
88
 
72
89
  if data is None:
73
- return {"session": None, "messages": [], "session_cost": None, "active_run": None, "pending_events": []}
90
+ return {
91
+ "session": None,
92
+ "messages": [],
93
+ **SessionTotals().payload(),
94
+ "active_run": None,
95
+ "pending_events": [],
96
+ "pending": {"steers": [], "queue": []},
97
+ }
74
98
 
75
99
  return {
76
100
  "session": data["session"],
77
101
  "messages": _redact_document_data(data["messages"]),
78
- "session_cost": data["session_cost"],
102
+ **data["totals"].payload(),
79
103
  "active_run": None,
80
104
  "pending_events": [],
105
+ "pending": {"steers": [], "queue": []},
81
106
  }
82
107
 
83
108
 
@@ -90,14 +115,3 @@ async def delete_session(
90
115
  raise HTTPException(status_code=409, detail="session has a running task")
91
116
  await store.delete_session(session_id)
92
117
  return StatusResponse(status="ok")
93
-
94
-
95
- @router.post("/{session_id}/clear")
96
- async def clear_session(
97
- session_id: Annotated[str, PathParam(min_length=1)], store: StoreDep, runs: RunManagerDep
98
- ) -> StatusResponse:
99
- async with runs.session_operation(session_id):
100
- if await runs.has_active_run(session_id):
101
- raise HTTPException(status_code=409, detail="session has a running task")
102
- await store.clear_session(session_id)
103
- return StatusResponse(status="ok")
@@ -8,15 +8,15 @@ import time
8
8
  from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable
9
9
  from contextlib import aclosing, asynccontextmanager
10
10
  from dataclasses import dataclass, field
11
+ from functools import partial
11
12
  from typing import Any, Literal, Protocol
12
13
  from uuid import uuid4
13
14
  from weakref import WeakValueDictionary
14
15
 
15
- from mycode.agent import Event
16
- from mycode.messages import ConversationMessage
16
+ from mycode.agent import Event, PersistCallback
17
+ from mycode.messages import ConversationMessage, merge_user_messages
17
18
  from mycode_cli.permissions import ToolReviewDecision
18
- from mycode_cli.runtime import sum_known_costs
19
- from mycode_cli.sessions import sum_session_cost
19
+ from mycode_cli.sessions import SessionTotals, sum_session_totals
20
20
 
21
21
  RunStatus = Literal["running", "completed", "failed", "cancelled"]
22
22
  RunKind = Literal["chat", "compact"]
@@ -44,10 +44,18 @@ class ActiveRunError(RuntimeError):
44
44
  class RunAgent(Protocol):
45
45
  model: str
46
46
  context_window: int
47
+ supports_image_input: bool
48
+ supports_pdf_input: bool
47
49
 
48
50
  def cancel(self) -> None: ...
49
51
 
50
- def achat(self, user_input: str | ConversationMessage) -> AsyncGenerator[Event, None]: ...
52
+ def steer(self, message: ConversationMessage) -> bool: ...
53
+
54
+ def pending_steers(self) -> list[ConversationMessage]: ...
55
+
56
+ def achat(
57
+ self, user_input: str | ConversationMessage, *, on_persist: PersistCallback | None = None
58
+ ) -> AsyncGenerator[Event, None]: ...
51
59
 
52
60
  def acompact(self) -> Awaitable[ConversationMessage]: ...
53
61
 
@@ -58,13 +66,25 @@ class RunState:
58
66
 
59
67
  id: str
60
68
  session_id: str
69
+ cwd: str
61
70
  base_messages: list[ConversationMessage]
62
71
  agent: RunAgent
63
72
  kind: RunKind = "chat"
64
73
  user_message: ConversationMessage | None = None
65
- # Fixed history cost, combined with cumulative turn costs on each update.
66
- session_cost_base: float | None = None
67
- session_cost: float | None = None
74
+ # Queued messages the current turn continues from, until their merged
75
+ # message is announced; snapshots list them as still queued.
76
+ delivering: list[ConversationMessage] = field(default_factory=list)
77
+ # Messages for the next turn of a chat run, each carrying ``meta.input_id``.
78
+ queue: list[ConversationMessage] = field(default_factory=list)
79
+ # Messages the run committed, and how far snapshots return them as history:
80
+ # through the last committed user message, whose events end at
81
+ # ``history_seq``. The events after it rebuild the rest, as on the live stream.
82
+ committed: list[ConversationMessage] = field(default_factory=list)
83
+ history_len: int = 0
84
+ history_seq: int = 0
85
+ # Pre-run history totals, combined with the cumulative turn totals on each update.
86
+ session_base: SessionTotals = field(default_factory=SessionTotals)
87
+ session_totals: SessionTotals = field(default_factory=SessionTotals)
68
88
  on_complete: RunCompletionCallback | None = None
69
89
  status: RunStatus = "running"
70
90
  error: str | None = None
@@ -105,36 +125,40 @@ class RunManager:
105
125
  self,
106
126
  *,
107
127
  session_id: str,
128
+ cwd: str,
108
129
  user_message: ConversationMessage,
109
130
  base_messages: list[ConversationMessage],
110
131
  agent: RunAgent,
111
- session_cost_base: float | None = None,
132
+ session_base: SessionTotals | None = None,
112
133
  ) -> dict[str, Any]:
113
134
  return await self._start(
114
135
  session_id=session_id,
136
+ cwd=cwd,
115
137
  kind="chat",
116
138
  user_message=user_message,
117
139
  base_messages=base_messages,
118
140
  agent=agent,
119
- session_cost_base=session_cost_base,
141
+ session_base=session_base,
120
142
  )
121
143
 
122
144
  async def start_compact(
123
145
  self,
124
146
  *,
125
147
  session_id: str,
148
+ cwd: str,
126
149
  base_messages: list[ConversationMessage],
127
150
  agent: RunAgent,
128
- session_cost_base: float | None = None,
151
+ session_base: SessionTotals | None = None,
129
152
  on_complete: RunCompletionCallback | None = None,
130
153
  ) -> dict[str, Any]:
131
154
  return await self._start(
132
155
  session_id=session_id,
156
+ cwd=cwd,
133
157
  kind="compact",
134
158
  user_message=None,
135
159
  base_messages=base_messages,
136
160
  agent=agent,
137
- session_cost_base=session_cost_base,
161
+ session_base=session_base,
138
162
  on_complete=on_complete,
139
163
  )
140
164
 
@@ -142,14 +166,16 @@ class RunManager:
142
166
  self,
143
167
  *,
144
168
  session_id: str,
169
+ cwd: str,
145
170
  kind: RunKind,
146
171
  user_message: ConversationMessage | None,
147
172
  base_messages: list[ConversationMessage],
148
173
  agent: RunAgent,
149
- session_cost_base: float | None = None,
174
+ session_base: SessionTotals | None = None,
150
175
  on_complete: RunCompletionCallback | None = None,
151
176
  ) -> dict[str, Any]:
152
177
  await self._prune_finished_runs()
178
+ session_base = session_base or SessionTotals()
153
179
 
154
180
  async with self._lock:
155
181
  existing = self._active_by_session.get(session_id)
@@ -159,10 +185,11 @@ class RunManager:
159
185
  state = RunState(
160
186
  id=uuid4().hex,
161
187
  session_id=session_id,
188
+ cwd=cwd,
162
189
  kind=kind,
163
190
  user_message=copy.deepcopy(user_message),
164
- session_cost_base=session_cost_base,
165
- session_cost=session_cost_base,
191
+ session_base=session_base,
192
+ session_totals=session_base,
166
193
  base_messages=copy.deepcopy(base_messages),
167
194
  agent=agent,
168
195
  on_complete=on_complete,
@@ -177,6 +204,52 @@ class RunManager:
177
204
  async with self._lock:
178
205
  return self._runs_by_id.get(run_id)
179
206
 
207
+ async def get_active_run(self, session_id: str) -> RunState | None:
208
+ async with self._lock:
209
+ return self._active_by_session.get(session_id)
210
+
211
+ async def enqueue(self, state: RunState, message: ConversationMessage) -> bool:
212
+ """Queue a message for the next turn of an active chat run."""
213
+
214
+ async with self._lock:
215
+ if (
216
+ state.kind != "chat"
217
+ or state.cancel_requested
218
+ or self._active_by_session.get(state.session_id) is not state
219
+ ):
220
+ return False
221
+ state.queue.append(copy.deepcopy(message))
222
+ return True
223
+
224
+ async def remove_queued(self, session_id: str, input_id: str) -> dict[str, Any] | None:
225
+ """Remove one pending queued message; returns the run, or ``None`` when it is not pending."""
226
+
227
+ async with self._lock:
228
+ state = self._active_by_session.get(session_id)
229
+ if state is None:
230
+ return None
231
+ for index, message in enumerate(state.queue):
232
+ if (message.get("meta") or {}).get("input_id") == input_id:
233
+ del state.queue[index]
234
+ return state.info()
235
+ return None
236
+
237
+ async def steer_queued(self, state: RunState, input_id: str) -> bool | None:
238
+ """Move one queued message to the run's steers.
239
+
240
+ Returns ``None`` when it is not pending, ``False`` when the run is not
241
+ accepting steers (the message stays queued).
242
+ """
243
+
244
+ async with self._lock:
245
+ for index, message in enumerate(state.queue):
246
+ if (message.get("meta") or {}).get("input_id") == input_id:
247
+ if not state.agent.steer(message):
248
+ return False
249
+ del state.queue[index]
250
+ return True
251
+ return None
252
+
180
253
  async def snapshot_session(self, session_id: str) -> dict[str, Any] | None:
181
254
  """Return a reconnect snapshot for the active session."""
182
255
 
@@ -189,14 +262,19 @@ class RunManager:
189
262
  async with state.condition:
190
263
  if state.status != "running":
191
264
  return None
192
- messages = copy.deepcopy(state.base_messages)
193
- if state.user_message is not None:
194
- messages.append(copy.deepcopy(state.user_message))
265
+ history = state.committed[: state.history_len]
266
+ if not history and state.user_message is not None:
267
+ # Not committed yet: the input still leads the turn.
268
+ history = [state.user_message]
195
269
  return {
196
270
  "run": state.info(),
197
- "messages": messages,
198
- "pending_events": list(state.events),
199
- "session_cost": state.session_cost,
271
+ "messages": copy.deepcopy(state.base_messages + history),
272
+ "pending_events": [event for event in state.events if event["seq"] > state.history_seq],
273
+ "pending": {
274
+ "steers": copy.deepcopy(state.agent.pending_steers()),
275
+ "queue": copy.deepcopy(state.delivering + state.queue),
276
+ },
277
+ "totals": state.session_totals,
200
278
  }
201
279
 
202
280
  @asynccontextmanager
@@ -371,20 +449,49 @@ class RunManager:
371
449
  if state.on_complete is not None:
372
450
  await state.on_complete(state.session_id)
373
451
  await self._append_event(
374
- state, Event("compact", {"trigger": "manual"}), compact_cost=sum_session_cost([marker])
452
+ state, Event("compact", {"trigger": "manual"}), compact_totals=sum_session_totals([marker])
375
453
  )
376
454
  else:
377
455
  assert state.user_message is not None
378
- async with aclosing(state.agent.achat(state.user_message)) as stream:
379
- async for event in stream:
380
- if event.type == "retry":
381
- continue
382
- if event.type == "error":
383
- last_error = str(event.data.get("message") or "unknown error")
384
- elif event.type == "cancelled":
385
- cancelled = True
386
- continue
387
- await self._append_event(state, event)
456
+ message = state.user_message
457
+ on_persist = partial(self._record_commit, state)
458
+ while True:
459
+ async with aclosing(state.agent.achat(message, on_persist=on_persist)) as stream:
460
+ async for event in stream:
461
+ # achat() commits its user message before its first
462
+ # event, so a queued turn is announced only once it
463
+ # is in the session; a failed commit reaches the
464
+ # client as an error with nothing delivered.
465
+ if state.delivering:
466
+ await self._append_event(state, Event("user_message", {"message": message}))
467
+ state.delivering = []
468
+ if event.type == "retry":
469
+ continue
470
+ if event.type == "error":
471
+ last_error = str(event.data.get("message") or "unknown error")
472
+ elif event.type == "cancelled":
473
+ cancelled = True
474
+ continue
475
+ await self._append_event(state, event)
476
+ if last_error is not None or cancelled:
477
+ break
478
+ # Stop requests and enqueue() run on this loop, so they land
479
+ # either before this hold or once the next achat() has its
480
+ # run: a stop after the take still ends the queued turn.
481
+ async with self._lock:
482
+ queued, state.queue = state.queue, []
483
+ # A stop drops the queue; the client restores its items.
484
+ if state.cancel_requested:
485
+ break
486
+ if not queued:
487
+ # Leaving the active map with the final take means
488
+ # no later enqueue is accepted and then dropped.
489
+ if self._active_by_session.get(state.session_id) is state:
490
+ del self._active_by_session[state.session_id]
491
+ break
492
+ # The queued turn continues on the same agent and model.
493
+ state.delivering = queued
494
+ message = merge_user_messages(queued, steer=False)
388
495
  except asyncio.CancelledError:
389
496
  # Manual compaction and external task cancellation both end the run.
390
497
  cancelled = True
@@ -405,23 +512,37 @@ class RunManager:
405
512
 
406
513
  await self._finish_run(state, status="completed")
407
514
 
408
- async def _append_event(self, state: RunState, event: Event, *, compact_cost: float | None = None) -> None:
515
+ async def _record_commit(self, state: RunState, message: ConversationMessage) -> None:
516
+ async with state.condition:
517
+ state.committed.append(message)
518
+ # A steer or queued message enters history with its user_message
519
+ # event, which clients apply as the start of its segment.
520
+ if message.get("role") == "user" and "input_ids" not in (message.get("meta") or {}):
521
+ state.history_len = len(state.committed)
522
+ state.history_seq = state.next_seq - 1
523
+
524
+ async def _append_event(
525
+ self, state: RunState, event: Event, *, compact_totals: SessionTotals | None = None
526
+ ) -> None:
409
527
  async with state.condition:
410
- if event.type == "usage":
411
- turn_cost = event.data.get("turn_cost")
412
- turn_total = turn_cost.get("total") if isinstance(turn_cost, dict) else None
413
- state.session_cost = sum_known_costs(state.session_cost_base, turn_total)
528
+ if event.type == "user_message":
529
+ # A delivered steer or queued turn starts a new usage segment.
530
+ state.session_base = state.session_totals
531
+ state.history_len = len(state.committed)
532
+ state.history_seq = state.next_seq
533
+ elif event.type == "usage":
534
+ state.session_totals = state.session_base.add(event.data.get("turn_usage"), event.data.get("turn_cost"))
414
535
  event = Event(
415
536
  "usage",
416
537
  {
417
538
  **event.data,
418
539
  "context_window": state.agent.context_window,
419
540
  "model": state.agent.model,
420
- "session_cost": state.session_cost,
541
+ **state.session_totals.payload(),
421
542
  },
422
543
  )
423
- elif event.type == "compact" and state.kind == "compact":
424
- state.session_cost = sum_known_costs(state.session_cost_base, compact_cost)
544
+ elif event.type == "compact" and compact_totals is not None:
545
+ state.session_totals = state.session_base.add(compact_totals.usage, compact_totals.cost)
425
546
 
426
547
  if event.type == "tool_done":
427
548
  tool_use_id = event.data.get("tool_use_id")