agentpool 2.1.9__py3-none-any.whl → 2.2.3__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.
Files changed (174) hide show
  1. acp/__init__.py +13 -0
  2. acp/bridge/README.md +15 -2
  3. acp/bridge/__init__.py +3 -2
  4. acp/bridge/__main__.py +60 -19
  5. acp/bridge/ws_server.py +173 -0
  6. acp/bridge/ws_server_cli.py +89 -0
  7. acp/notifications.py +2 -1
  8. acp/stdio.py +39 -9
  9. acp/transports.py +362 -2
  10. acp/utils.py +15 -2
  11. agentpool/__init__.py +4 -1
  12. agentpool/agents/__init__.py +2 -0
  13. agentpool/agents/acp_agent/acp_agent.py +203 -88
  14. agentpool/agents/acp_agent/acp_converters.py +46 -21
  15. agentpool/agents/acp_agent/client_handler.py +157 -3
  16. agentpool/agents/acp_agent/session_state.py +4 -1
  17. agentpool/agents/agent.py +314 -107
  18. agentpool/agents/agui_agent/__init__.py +0 -2
  19. agentpool/agents/agui_agent/agui_agent.py +90 -21
  20. agentpool/agents/agui_agent/agui_converters.py +0 -131
  21. agentpool/agents/base_agent.py +163 -1
  22. agentpool/agents/claude_code_agent/claude_code_agent.py +626 -179
  23. agentpool/agents/claude_code_agent/converters.py +71 -3
  24. agentpool/agents/claude_code_agent/history.py +474 -0
  25. agentpool/agents/context.py +40 -0
  26. agentpool/agents/events/__init__.py +2 -0
  27. agentpool/agents/events/builtin_handlers.py +2 -1
  28. agentpool/agents/events/event_emitter.py +29 -2
  29. agentpool/agents/events/events.py +20 -0
  30. agentpool/agents/modes.py +54 -0
  31. agentpool/agents/tool_call_accumulator.py +213 -0
  32. agentpool/common_types.py +21 -0
  33. agentpool/config_resources/__init__.py +38 -1
  34. agentpool/config_resources/claude_code_agent.yml +3 -0
  35. agentpool/delegation/pool.py +37 -29
  36. agentpool/delegation/team.py +1 -0
  37. agentpool/delegation/teamrun.py +1 -0
  38. agentpool/diagnostics/__init__.py +53 -0
  39. agentpool/diagnostics/lsp_manager.py +1593 -0
  40. agentpool/diagnostics/lsp_proxy.py +41 -0
  41. agentpool/diagnostics/lsp_proxy_script.py +229 -0
  42. agentpool/diagnostics/models.py +398 -0
  43. agentpool/mcp_server/__init__.py +0 -2
  44. agentpool/mcp_server/client.py +12 -3
  45. agentpool/mcp_server/manager.py +25 -31
  46. agentpool/mcp_server/registries/official_registry_client.py +25 -0
  47. agentpool/mcp_server/tool_bridge.py +78 -66
  48. agentpool/messaging/__init__.py +0 -2
  49. agentpool/messaging/compaction.py +72 -197
  50. agentpool/messaging/message_history.py +12 -0
  51. agentpool/messaging/messages.py +52 -9
  52. agentpool/messaging/processing.py +3 -1
  53. agentpool/models/acp_agents/base.py +0 -22
  54. agentpool/models/acp_agents/mcp_capable.py +8 -148
  55. agentpool/models/acp_agents/non_mcp.py +129 -72
  56. agentpool/models/agents.py +35 -13
  57. agentpool/models/claude_code_agents.py +33 -2
  58. agentpool/models/manifest.py +43 -0
  59. agentpool/repomap.py +1 -1
  60. agentpool/resource_providers/__init__.py +9 -1
  61. agentpool/resource_providers/aggregating.py +52 -3
  62. agentpool/resource_providers/base.py +57 -1
  63. agentpool/resource_providers/mcp_provider.py +23 -0
  64. agentpool/resource_providers/plan_provider.py +130 -41
  65. agentpool/resource_providers/pool.py +2 -0
  66. agentpool/resource_providers/static.py +2 -0
  67. agentpool/sessions/__init__.py +2 -1
  68. agentpool/sessions/manager.py +31 -2
  69. agentpool/sessions/models.py +50 -0
  70. agentpool/skills/registry.py +13 -8
  71. agentpool/storage/manager.py +217 -1
  72. agentpool/testing.py +537 -19
  73. agentpool/utils/file_watcher.py +269 -0
  74. agentpool/utils/identifiers.py +121 -0
  75. agentpool/utils/pydantic_ai_helpers.py +46 -0
  76. agentpool/utils/streams.py +690 -1
  77. agentpool/utils/subprocess_utils.py +155 -0
  78. agentpool/utils/token_breakdown.py +461 -0
  79. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/METADATA +27 -7
  80. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/RECORD +170 -112
  81. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/WHEEL +1 -1
  82. agentpool_cli/__main__.py +4 -0
  83. agentpool_cli/serve_acp.py +41 -20
  84. agentpool_cli/serve_agui.py +87 -0
  85. agentpool_cli/serve_opencode.py +119 -0
  86. agentpool_commands/__init__.py +30 -0
  87. agentpool_commands/agents.py +74 -1
  88. agentpool_commands/history.py +62 -0
  89. agentpool_commands/mcp.py +176 -0
  90. agentpool_commands/models.py +56 -3
  91. agentpool_commands/tools.py +57 -0
  92. agentpool_commands/utils.py +51 -0
  93. agentpool_config/builtin_tools.py +77 -22
  94. agentpool_config/commands.py +24 -1
  95. agentpool_config/compaction.py +258 -0
  96. agentpool_config/mcp_server.py +131 -1
  97. agentpool_config/storage.py +46 -1
  98. agentpool_config/tools.py +7 -1
  99. agentpool_config/toolsets.py +92 -148
  100. agentpool_server/acp_server/acp_agent.py +134 -150
  101. agentpool_server/acp_server/commands/acp_commands.py +216 -51
  102. agentpool_server/acp_server/commands/docs_commands/fetch_repo.py +10 -10
  103. agentpool_server/acp_server/server.py +23 -79
  104. agentpool_server/acp_server/session.py +181 -19
  105. agentpool_server/opencode_server/.rules +95 -0
  106. agentpool_server/opencode_server/ENDPOINTS.md +362 -0
  107. agentpool_server/opencode_server/__init__.py +27 -0
  108. agentpool_server/opencode_server/command_validation.py +172 -0
  109. agentpool_server/opencode_server/converters.py +869 -0
  110. agentpool_server/opencode_server/dependencies.py +24 -0
  111. agentpool_server/opencode_server/input_provider.py +269 -0
  112. agentpool_server/opencode_server/models/__init__.py +228 -0
  113. agentpool_server/opencode_server/models/agent.py +53 -0
  114. agentpool_server/opencode_server/models/app.py +60 -0
  115. agentpool_server/opencode_server/models/base.py +26 -0
  116. agentpool_server/opencode_server/models/common.py +23 -0
  117. agentpool_server/opencode_server/models/config.py +37 -0
  118. agentpool_server/opencode_server/models/events.py +647 -0
  119. agentpool_server/opencode_server/models/file.py +88 -0
  120. agentpool_server/opencode_server/models/mcp.py +25 -0
  121. agentpool_server/opencode_server/models/message.py +162 -0
  122. agentpool_server/opencode_server/models/parts.py +190 -0
  123. agentpool_server/opencode_server/models/provider.py +81 -0
  124. agentpool_server/opencode_server/models/pty.py +43 -0
  125. agentpool_server/opencode_server/models/session.py +99 -0
  126. agentpool_server/opencode_server/routes/__init__.py +25 -0
  127. agentpool_server/opencode_server/routes/agent_routes.py +442 -0
  128. agentpool_server/opencode_server/routes/app_routes.py +139 -0
  129. agentpool_server/opencode_server/routes/config_routes.py +241 -0
  130. agentpool_server/opencode_server/routes/file_routes.py +392 -0
  131. agentpool_server/opencode_server/routes/global_routes.py +94 -0
  132. agentpool_server/opencode_server/routes/lsp_routes.py +319 -0
  133. agentpool_server/opencode_server/routes/message_routes.py +705 -0
  134. agentpool_server/opencode_server/routes/pty_routes.py +299 -0
  135. agentpool_server/opencode_server/routes/session_routes.py +1205 -0
  136. agentpool_server/opencode_server/routes/tui_routes.py +139 -0
  137. agentpool_server/opencode_server/server.py +430 -0
  138. agentpool_server/opencode_server/state.py +121 -0
  139. agentpool_server/opencode_server/time_utils.py +8 -0
  140. agentpool_storage/__init__.py +16 -0
  141. agentpool_storage/base.py +103 -0
  142. agentpool_storage/claude_provider.py +907 -0
  143. agentpool_storage/file_provider.py +129 -0
  144. agentpool_storage/memory_provider.py +61 -0
  145. agentpool_storage/models.py +3 -0
  146. agentpool_storage/opencode_provider.py +730 -0
  147. agentpool_storage/project_store.py +325 -0
  148. agentpool_storage/session_store.py +6 -0
  149. agentpool_storage/sql_provider/__init__.py +4 -2
  150. agentpool_storage/sql_provider/models.py +48 -0
  151. agentpool_storage/sql_provider/sql_provider.py +134 -1
  152. agentpool_storage/sql_provider/utils.py +10 -1
  153. agentpool_storage/text_log_provider.py +1 -0
  154. agentpool_toolsets/builtin/__init__.py +0 -8
  155. agentpool_toolsets/builtin/code.py +95 -56
  156. agentpool_toolsets/builtin/debug.py +16 -21
  157. agentpool_toolsets/builtin/execution_environment.py +99 -103
  158. agentpool_toolsets/builtin/file_edit/file_edit.py +115 -7
  159. agentpool_toolsets/builtin/skills.py +86 -4
  160. agentpool_toolsets/fsspec_toolset/__init__.py +13 -1
  161. agentpool_toolsets/fsspec_toolset/diagnostics.py +860 -73
  162. agentpool_toolsets/fsspec_toolset/grep.py +74 -2
  163. agentpool_toolsets/fsspec_toolset/image_utils.py +161 -0
  164. agentpool_toolsets/fsspec_toolset/toolset.py +159 -38
  165. agentpool_toolsets/mcp_discovery/__init__.py +5 -0
  166. agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet +0 -0
  167. agentpool_toolsets/mcp_discovery/toolset.py +454 -0
  168. agentpool_toolsets/mcp_run_toolset.py +84 -6
  169. agentpool_toolsets/builtin/agent_management.py +0 -239
  170. agentpool_toolsets/builtin/history.py +0 -36
  171. agentpool_toolsets/builtin/integration.py +0 -85
  172. agentpool_toolsets/builtin/tool_management.py +0 -90
  173. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/entry_points.txt +0 -0
  174. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,705 @@
1
+ """Message routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from fastapi import APIRouter, HTTPException, Query
8
+ from pydantic_ai import FunctionToolCallEvent
9
+ from pydantic_ai.messages import (
10
+ PartDeltaEvent,
11
+ PartStartEvent,
12
+ TextPart as PydanticTextPart,
13
+ TextPartDelta,
14
+ ToolCallPart as PydanticToolCallPart,
15
+ )
16
+
17
+ from agentpool.agents.claude_code_agent.converters import derive_rich_tool_info
18
+ from agentpool.agents.events import (
19
+ CompactionEvent,
20
+ FileContentItem,
21
+ LocationContentItem,
22
+ StreamCompleteEvent,
23
+ TextContentItem,
24
+ ToolCallCompleteEvent,
25
+ ToolCallProgressEvent,
26
+ ToolCallStartEvent,
27
+ )
28
+ from agentpool.messaging.messages import ChatMessage
29
+ from agentpool.utils import identifiers as identifier
30
+ from agentpool.utils.pydantic_ai_helpers import safe_args_as_dict
31
+ from agentpool_server.opencode_server.converters import (
32
+ _convert_params_for_ui,
33
+ extract_user_prompt_from_parts,
34
+ opencode_to_chat_message,
35
+ )
36
+ from agentpool_server.opencode_server.dependencies import StateDep # noqa: TC001
37
+ from agentpool_server.opencode_server.models import ( # noqa: TC001
38
+ AssistantMessage,
39
+ MessagePath,
40
+ MessageRequest,
41
+ MessageTime,
42
+ MessageUpdatedEvent,
43
+ MessageWithParts,
44
+ Part,
45
+ PartUpdatedEvent,
46
+ SessionCompactedEvent,
47
+ SessionErrorEvent,
48
+ SessionIdleEvent,
49
+ SessionStatus,
50
+ SessionStatusEvent,
51
+ SessionUpdatedEvent,
52
+ StepFinishPart,
53
+ StepStartPart,
54
+ TextPart,
55
+ TimeCreated,
56
+ TimeCreatedUpdated,
57
+ TimeStartEnd,
58
+ Tokens,
59
+ TokensCache,
60
+ ToolPart,
61
+ ToolStateCompleted,
62
+ ToolStateError,
63
+ ToolStateRunning,
64
+ UserMessage,
65
+ )
66
+ from agentpool_server.opencode_server.models.message import UserMessageModel
67
+ from agentpool_server.opencode_server.models.parts import (
68
+ StepFinishTokens,
69
+ TimeStart,
70
+ TimeStartEndCompacted,
71
+ TimeStartEndOptional,
72
+ TokenCache,
73
+ )
74
+ from agentpool_server.opencode_server.routes.session_routes import (
75
+ get_or_load_session,
76
+ opencode_to_session_data,
77
+ )
78
+ from agentpool_server.opencode_server.time_utils import now_ms
79
+
80
+
81
+ if TYPE_CHECKING:
82
+ from agentpool_server.opencode_server.state import ServerState
83
+
84
+
85
+ def _warmup_lsp_for_files(state: ServerState, file_paths: list[str]) -> None:
86
+ """Warm up LSP servers for the given file paths.
87
+
88
+ This starts LSP servers asynchronously based on file extensions.
89
+ Like OpenCode's LSP.touchFile(), this triggers server startup without waiting.
90
+
91
+ Args:
92
+ state: Server state with LSP manager
93
+ file_paths: List of file paths that were accessed
94
+ """
95
+ import logging
96
+
97
+ logging.getLogger(__name__)
98
+ print(f"[LSP] _warmup_lsp_for_files called with: {file_paths}")
99
+
100
+ try:
101
+ lsp_manager = state.get_or_create_lsp_manager()
102
+ print("[LSP] Got LSP manager successfully")
103
+ except RuntimeError as e:
104
+ # No execution environment available for LSP
105
+ print(f"[LSP] No LSP manager: {e}")
106
+ return
107
+
108
+ async def warmup_files() -> None:
109
+ """Start LSP servers for each file path."""
110
+ print("[LSP] warmup_files task started")
111
+ from agentpool_server.opencode_server.models.events import LspUpdatedEvent
112
+
113
+ servers_started = False
114
+ for path in file_paths:
115
+ # Find appropriate server for this file
116
+ server_info = lsp_manager.get_server_for_file(path)
117
+ print(f"[LSP] Server for {path}: {server_info.id if server_info else None}")
118
+ if server_info is None:
119
+ continue
120
+
121
+ server_id = server_info.id
122
+ if lsp_manager.is_running(server_id):
123
+ print(f"[LSP] Server {server_id} already running")
124
+ continue
125
+
126
+ # Start server for workspace root
127
+ root_uri = f"file://{state.working_dir}"
128
+ try:
129
+ print(f"[LSP] Starting server {server_id}...")
130
+ await lsp_manager.start_server(server_id, root_uri)
131
+ servers_started = True
132
+ print(f"[LSP] Server {server_id} started successfully")
133
+ except Exception as e: # noqa: BLE001
134
+ # Don't fail on LSP startup errors
135
+ print(f"[LSP] Failed to start server {server_id}: {e}")
136
+
137
+ # Emit lsp.updated event if any servers started
138
+ if servers_started:
139
+ print("[LSP] Broadcasting LspUpdatedEvent")
140
+ await state.broadcast_event(LspUpdatedEvent.create())
141
+ print("[LSP] warmup_files task completed")
142
+
143
+ # Run warmup in background (don't block the event handler)
144
+ print("[LSP] Creating background task for warmup")
145
+ state.create_background_task(warmup_files(), name="lsp-warmup")
146
+
147
+
148
+ async def persist_message_to_storage(
149
+ state: ServerState,
150
+ msg: MessageWithParts,
151
+ session_id: str,
152
+ ) -> None:
153
+ """Persist an OpenCode message to storage.
154
+
155
+ Converts the OpenCode MessageWithParts to ChatMessage and saves it.
156
+
157
+ Args:
158
+ state: Server state with pool reference
159
+ msg: OpenCode message to persist
160
+ session_id: Session/conversation ID
161
+ """
162
+ if state.pool.storage is None:
163
+ return
164
+
165
+ try:
166
+ # Convert to ChatMessage
167
+ chat_msg = opencode_to_chat_message(msg, conversation_id=session_id)
168
+ # Persist via storage manager
169
+ await state.pool.storage.log_message(chat_msg)
170
+ except Exception: # noqa: BLE001
171
+ # Don't fail the request if storage fails
172
+ pass
173
+
174
+
175
+ async def _generate_session_title(
176
+ state: ServerState,
177
+ session_id: str,
178
+ user_prompt: str,
179
+ assistant_response: str,
180
+ ) -> None:
181
+ """Generate a title for the session in the background."""
182
+ try:
183
+ if not state.pool.storage:
184
+ return
185
+
186
+ # Create ChatMessage objects for the title generator
187
+ messages = [
188
+ ChatMessage[str](role="user", content=user_prompt),
189
+ ChatMessage[str](role="assistant", content=assistant_response),
190
+ ]
191
+
192
+ # Generate title using storage manager
193
+ title = await state.pool.storage.generate_conversation_title(
194
+ messages=messages,
195
+ conversation_id=session_id,
196
+ )
197
+
198
+ if title and session_id in state.sessions:
199
+ # Update session with new title
200
+ session = state.sessions[session_id]
201
+ updated_session = session.model_copy(update={"title": title})
202
+ state.sessions[session_id] = updated_session
203
+ session_data = opencode_to_session_data(
204
+ updated_session,
205
+ agent_name=state.agent.name,
206
+ pool_id=state.pool.manifest.config_file_path,
207
+ )
208
+ await state.pool.sessions.store.save(session_data)
209
+
210
+ # Broadcast session update
211
+ await state.broadcast_event(SessionUpdatedEvent.create(updated_session))
212
+ except Exception: # noqa: BLE001
213
+ # Don't fail if title generation fails
214
+ pass
215
+
216
+
217
+ router = APIRouter(prefix="/session/{session_id}", tags=["message"])
218
+
219
+
220
+ @router.get("/message")
221
+ async def list_messages(
222
+ session_id: str,
223
+ state: StateDep,
224
+ limit: int | None = Query(default=None),
225
+ ) -> list[MessageWithParts]:
226
+ """List messages in a session."""
227
+ session = await get_or_load_session(state, session_id)
228
+ if session is None:
229
+ raise HTTPException(status_code=404, detail="Session not found")
230
+
231
+ messages = state.messages.get(session_id, [])
232
+ if limit:
233
+ messages = messages[-limit:]
234
+ return messages
235
+
236
+
237
+ @router.post("/message")
238
+ async def send_message( # noqa: PLR0915
239
+ session_id: str,
240
+ request: MessageRequest,
241
+ state: StateDep,
242
+ ) -> MessageWithParts:
243
+ """Send a message and get response from the agent."""
244
+ session = await get_or_load_session(state, session_id)
245
+ if session is None:
246
+ raise HTTPException(status_code=404, detail="Session not found")
247
+
248
+ now = now_ms()
249
+ # Create user message with sortable ID
250
+ user_msg_id = identifier.ascending("message", request.message_id)
251
+ user_message = UserMessage(
252
+ id=user_msg_id,
253
+ session_id=session_id,
254
+ time=TimeCreated(created=now),
255
+ agent=request.agent or "default",
256
+ model=UserMessageModel(
257
+ provider_id=request.model.provider_id if request.model else "agentpool",
258
+ model_id=request.model.model_id if request.model else "default",
259
+ ),
260
+ )
261
+
262
+ # Create parts from request
263
+ user_parts: list[Part] = [
264
+ TextPart(
265
+ id=identifier.ascending("part"),
266
+ message_id=user_msg_id,
267
+ session_id=session_id,
268
+ text=part.text,
269
+ )
270
+ for part in request.parts
271
+ if part.type == "text"
272
+ ]
273
+ user_msg_with_parts = MessageWithParts(info=user_message, parts=user_parts)
274
+ state.messages[session_id].append(user_msg_with_parts)
275
+ # Persist user message to storage
276
+ await persist_message_to_storage(state, user_msg_with_parts, session_id)
277
+ # Broadcast user message created event
278
+ await state.broadcast_event(MessageUpdatedEvent.create(user_message))
279
+ # Broadcast user message parts so they appear in UI
280
+ for part in user_parts:
281
+ await state.broadcast_event(PartUpdatedEvent.create(part))
282
+ state.session_status[session_id] = SessionStatus(type="busy")
283
+ await state.broadcast_event(SessionStatusEvent.create(session_id, SessionStatus(type="busy")))
284
+ # Extract user prompt text
285
+ user_prompt = extract_user_prompt_from_parts([p.model_dump() for p in request.parts])
286
+ # Create assistant message with sortable ID (must come after user message)
287
+ assistant_msg_id = identifier.ascending("message")
288
+ tokens = Tokens(cache=TokensCache(read=0, write=0))
289
+ assistant_message = AssistantMessage(
290
+ id=assistant_msg_id,
291
+ session_id=session_id,
292
+ parent_id=user_msg_id, # Link to user message
293
+ model_id=request.model.model_id if request.model else "default",
294
+ provider_id=request.model.provider_id if request.model else "agentpool",
295
+ mode=request.agent or "default",
296
+ agent=request.agent or "default",
297
+ path=MessagePath(cwd=state.working_dir, root=state.working_dir),
298
+ time=MessageTime(created=now, completed=None),
299
+ tokens=tokens,
300
+ cost=0.0,
301
+ )
302
+ # Initialize assistant message with empty parts
303
+ assistant_msg_with_parts = MessageWithParts(info=assistant_message, parts=[])
304
+ state.messages[session_id].append(assistant_msg_with_parts)
305
+ # Broadcast assistant message created
306
+ await state.broadcast_event(MessageUpdatedEvent.create(assistant_message))
307
+ # Add step-start part
308
+ step_start = StepStartPart(
309
+ id=identifier.ascending("part"),
310
+ message_id=assistant_msg_id,
311
+ session_id=session_id,
312
+ )
313
+ assistant_msg_with_parts.parts.append(step_start)
314
+ await state.broadcast_event(PartUpdatedEvent.create(step_start))
315
+ # Call the agent
316
+ response_text = ""
317
+ input_tokens = 0
318
+ output_tokens = 0
319
+ total_cost = 0.0 # Cost in dollars
320
+ tool_parts: dict[str, ToolPart] = {} # Track tool parts by call_id
321
+ tool_outputs: dict[str, str] = {} # Track accumulated output per tool call
322
+ tool_inputs: dict[str, dict[str, Any]] = {} # Track inputs per tool call
323
+ # Track streaming text part for incremental updates
324
+ text_part: TextPart | None = None
325
+ text_part_id: str | None = None
326
+
327
+ try:
328
+ # Get the specified agent from the pool, or fall back to default
329
+ agent = state.agent
330
+ if request.agent and state.agent.agent_pool is not None:
331
+ agent = state.agent.agent_pool.all_agents.get(request.agent, state.agent)
332
+
333
+ # Stream events from the agent
334
+ async for event in agent.run_stream(user_prompt):
335
+ match event:
336
+ # Text streaming start
337
+ case PartStartEvent(part=PydanticTextPart(content=delta)):
338
+ response_text = delta
339
+ text_part_id = identifier.ascending("part")
340
+ text_part = TextPart(
341
+ id=text_part_id,
342
+ message_id=assistant_msg_id,
343
+ session_id=session_id,
344
+ text=delta,
345
+ )
346
+ assistant_msg_with_parts.parts.append(text_part)
347
+ await state.broadcast_event(PartUpdatedEvent.create(text_part, delta=delta))
348
+
349
+ # Text streaming delta
350
+ case PartDeltaEvent(delta=TextPartDelta(content_delta=delta)) if delta:
351
+ response_text += delta
352
+ if text_part is not None:
353
+ text_part = TextPart(
354
+ id=text_part.id,
355
+ message_id=assistant_msg_id,
356
+ session_id=session_id,
357
+ text=response_text,
358
+ )
359
+ # Update in parts list
360
+ for i, p in enumerate(assistant_msg_with_parts.parts):
361
+ if isinstance(p, TextPart) and p.id == text_part.id:
362
+ assistant_msg_with_parts.parts[i] = text_part
363
+ break
364
+ await state.broadcast_event(PartUpdatedEvent.create(text_part, delta=delta))
365
+
366
+ # Tool call start - from Claude Code agent or toolsets
367
+ case ToolCallStartEvent(
368
+ tool_name=tool_name,
369
+ tool_call_id=tool_call_id,
370
+ raw_input=raw_input,
371
+ title=title,
372
+ ):
373
+ # Convert param names for OpenCode TUI compatibility
374
+ ui_input = _convert_params_for_ui(raw_input) if raw_input else {}
375
+ if tool_call_id in tool_parts:
376
+ # Update existing part with the custom title
377
+ existing = tool_parts[tool_call_id]
378
+ tool_inputs[tool_call_id] = ui_input or tool_inputs.get(tool_call_id, {})
379
+
380
+ updated = ToolPart(
381
+ id=existing.id,
382
+ message_id=existing.message_id,
383
+ session_id=existing.session_id,
384
+ tool=existing.tool,
385
+ call_id=existing.call_id,
386
+ state=ToolStateRunning(
387
+ status="running",
388
+ time=TimeStart(start=now_ms()),
389
+ input=tool_inputs[tool_call_id],
390
+ title=title,
391
+ ),
392
+ )
393
+ tool_parts[tool_call_id] = updated
394
+ for i, p in enumerate(assistant_msg_with_parts.parts):
395
+ if isinstance(p, ToolPart) and p.id == existing.id:
396
+ assistant_msg_with_parts.parts[i] = updated
397
+ break
398
+ await state.broadcast_event(PartUpdatedEvent.create(updated))
399
+ else:
400
+ # Create new tool part with the title
401
+ tool_inputs[tool_call_id] = ui_input
402
+ tool_outputs[tool_call_id] = ""
403
+ tool_state = ToolStateRunning(
404
+ status="running",
405
+ time=TimeStart(start=now_ms()),
406
+ input=ui_input,
407
+ title=title,
408
+ )
409
+ tool_part = ToolPart(
410
+ id=identifier.ascending("part"),
411
+ message_id=assistant_msg_id,
412
+ session_id=session_id,
413
+ tool=tool_name,
414
+ call_id=tool_call_id,
415
+ state=tool_state,
416
+ )
417
+ tool_parts[tool_call_id] = tool_part
418
+ assistant_msg_with_parts.parts.append(tool_part)
419
+ await state.broadcast_event(PartUpdatedEvent.create(tool_part))
420
+
421
+ # Pydantic-ai tool call events (fallback for pydantic-ai agents)
422
+ case (
423
+ FunctionToolCallEvent(part=tc_part)
424
+ | PartStartEvent(part=PydanticToolCallPart() as tc_part)
425
+ ) if tc_part.tool_call_id not in tool_parts:
426
+ tool_call_id = tc_part.tool_call_id
427
+ tool_name = tc_part.tool_name
428
+ raw_input = safe_args_as_dict(tc_part)
429
+ # Convert param names for OpenCode TUI compatibility
430
+ ui_input = _convert_params_for_ui(raw_input)
431
+ # Store input and initialize output accumulator
432
+ tool_inputs[tool_call_id] = ui_input
433
+ tool_outputs[tool_call_id] = ""
434
+ # Derive initial title; toolset events may update it later
435
+ rich_info = derive_rich_tool_info(tool_name, raw_input)
436
+ tool_state = ToolStateRunning(
437
+ status="running",
438
+ time=TimeStart(start=now_ms()),
439
+ input=ui_input,
440
+ title=rich_info.title,
441
+ )
442
+ tool_part = ToolPart(
443
+ id=identifier.ascending("part"),
444
+ message_id=assistant_msg_id,
445
+ session_id=session_id,
446
+ tool=tool_name,
447
+ call_id=tool_call_id,
448
+ state=tool_state,
449
+ )
450
+ tool_parts[tool_call_id] = tool_part
451
+ assistant_msg_with_parts.parts.append(tool_part)
452
+ await state.broadcast_event(PartUpdatedEvent.create(tool_part))
453
+
454
+ # Tool call progress
455
+ case ToolCallProgressEvent(
456
+ tool_call_id=tool_call_id,
457
+ title=title,
458
+ items=items,
459
+ tool_name=tool_name,
460
+ tool_input=event_tool_input,
461
+ ) if tool_call_id:
462
+ # Extract text content from items and accumulate
463
+ new_output = ""
464
+ file_paths: list[str] = []
465
+ for item in items:
466
+ if isinstance(item, TextContentItem):
467
+ new_output += item.text
468
+ elif isinstance(item, FileContentItem):
469
+ new_output += item.content
470
+ file_paths.append(item.path)
471
+ elif isinstance(item, LocationContentItem):
472
+ file_paths.append(item.path)
473
+
474
+ # Warm up LSP servers for accessed files (async, don't wait)
475
+ if file_paths:
476
+ _warmup_lsp_for_files(state, file_paths)
477
+
478
+ # Accumulate output (OpenCode streams via metadata.output)
479
+ if new_output:
480
+ tool_outputs[tool_call_id] = tool_outputs.get(tool_call_id, "") + new_output
481
+
482
+ if tool_call_id in tool_parts:
483
+ # Update existing part
484
+ existing = tool_parts[tool_call_id]
485
+ existing_title = getattr(existing.state, "title", "")
486
+ tool_input = tool_inputs.get(tool_call_id, {})
487
+ accumulated_output = tool_outputs.get(tool_call_id, "")
488
+ tool_state = ToolStateRunning(
489
+ status="running",
490
+ time=TimeStart(start=now_ms()),
491
+ title=title or existing_title,
492
+ input=tool_input,
493
+ metadata={"output": accumulated_output} if accumulated_output else None,
494
+ )
495
+ updated = ToolPart(
496
+ id=existing.id,
497
+ message_id=existing.message_id,
498
+ session_id=existing.session_id,
499
+ tool=existing.tool,
500
+ call_id=existing.call_id,
501
+ state=tool_state,
502
+ )
503
+ tool_parts[tool_call_id] = updated
504
+ for i, p in enumerate(assistant_msg_with_parts.parts):
505
+ if isinstance(p, ToolPart) and p.id == existing.id:
506
+ assistant_msg_with_parts.parts[i] = updated
507
+ break
508
+ await state.broadcast_event(PartUpdatedEvent.create(updated))
509
+ else:
510
+ # Create new tool part from progress event
511
+ ui_input = (
512
+ _convert_params_for_ui(event_tool_input) if event_tool_input else {}
513
+ )
514
+ tool_inputs[tool_call_id] = ui_input
515
+ accumulated_output = tool_outputs.get(tool_call_id, "")
516
+ tool_state = ToolStateRunning(
517
+ status="running",
518
+ time=TimeStart(start=now_ms()),
519
+ input=ui_input,
520
+ title=title or tool_name or "Running...",
521
+ metadata={"output": accumulated_output} if accumulated_output else None,
522
+ )
523
+ tool_part = ToolPart(
524
+ id=identifier.ascending("part"),
525
+ message_id=assistant_msg_id,
526
+ session_id=session_id,
527
+ tool=tool_name or "unknown",
528
+ call_id=tool_call_id,
529
+ state=tool_state,
530
+ )
531
+ tool_parts[tool_call_id] = tool_part
532
+ assistant_msg_with_parts.parts.append(tool_part)
533
+ await state.broadcast_event(PartUpdatedEvent.create(tool_part))
534
+
535
+ # Tool call complete
536
+ case ToolCallCompleteEvent(
537
+ tool_call_id=tool_call_id,
538
+ tool_result=result,
539
+ ) if tool_call_id in tool_parts:
540
+ existing = tool_parts[tool_call_id]
541
+ result_str = str(result) if result else ""
542
+ tool_input = tool_inputs.get(tool_call_id, {})
543
+ is_error = isinstance(result, dict) and result.get("error")
544
+
545
+ if is_error:
546
+ new_state: ToolStateCompleted | ToolStateError = ToolStateError(
547
+ status="error",
548
+ error=str(result.get("error", "Unknown error")),
549
+ input=tool_input,
550
+ time=TimeStartEnd(start=now, end=now_ms()),
551
+ )
552
+ else:
553
+ new_state = ToolStateCompleted(
554
+ status="completed",
555
+ title=f"Completed {existing.tool}",
556
+ input=tool_input,
557
+ output=result_str,
558
+ time=TimeStartEndCompacted(start=now, end=now_ms()),
559
+ )
560
+
561
+ updated = ToolPart(
562
+ id=existing.id,
563
+ message_id=existing.message_id,
564
+ session_id=existing.session_id,
565
+ tool=existing.tool,
566
+ call_id=existing.call_id,
567
+ state=new_state,
568
+ )
569
+ tool_parts[tool_call_id] = updated
570
+ for i, p in enumerate(assistant_msg_with_parts.parts):
571
+ if isinstance(p, ToolPart) and p.id == existing.id:
572
+ assistant_msg_with_parts.parts[i] = updated
573
+ break
574
+ await state.broadcast_event(PartUpdatedEvent.create(updated))
575
+
576
+ # Stream complete - extract token usage and cost
577
+ case StreamCompleteEvent(message=msg) if msg:
578
+ if msg.usage:
579
+ input_tokens = msg.usage.input_tokens or 0
580
+ output_tokens = msg.usage.output_tokens or 0
581
+ if msg.cost_info and msg.cost_info.total_cost:
582
+ # Cost is in Decimal dollars, OpenCode expects float dollars
583
+ total_cost = float(msg.cost_info.total_cost)
584
+
585
+ # Compaction event - emit session.compacted SSE event
586
+ case CompactionEvent(session_id=compact_session_id, phase=phase):
587
+ if phase == "completed":
588
+ await state.broadcast_event(
589
+ SessionCompactedEvent.create(session_id=compact_session_id)
590
+ )
591
+
592
+ except Exception as e: # noqa: BLE001
593
+ response_text = f"Error calling agent: {e}"
594
+ # Emit session error event
595
+ await state.broadcast_event(
596
+ SessionErrorEvent.create(
597
+ session_id=session_id,
598
+ error_name=type(e).__name__,
599
+ error_message=str(e),
600
+ )
601
+ )
602
+
603
+ response_time = now_ms()
604
+
605
+ # Create text part with response (only if we didn't stream it already)
606
+ if response_text and text_part is None:
607
+ text_part = TextPart(
608
+ id=identifier.ascending("part"),
609
+ message_id=assistant_msg_id,
610
+ session_id=session_id,
611
+ text=response_text,
612
+ time=TimeStartEndOptional(start=now, end=response_time),
613
+ )
614
+ assistant_msg_with_parts.parts.append(text_part)
615
+
616
+ # Broadcast text part update
617
+ await state.broadcast_event(PartUpdatedEvent.create(text_part))
618
+ elif text_part is not None:
619
+ # Update the streamed text part with final timing
620
+ final_text_part = TextPart(
621
+ id=text_part.id,
622
+ message_id=assistant_msg_id,
623
+ session_id=session_id,
624
+ text=response_text,
625
+ time=TimeStartEndOptional(start=now, end=response_time),
626
+ )
627
+ # Update in parts list
628
+ for i, p in enumerate(assistant_msg_with_parts.parts):
629
+ if isinstance(p, TextPart) and p.id == text_part.id:
630
+ assistant_msg_with_parts.parts[i] = final_text_part
631
+ break
632
+
633
+ step_finish = StepFinishPart(
634
+ id=identifier.ascending("part"),
635
+ message_id=assistant_msg_id,
636
+ session_id=session_id,
637
+ tokens=StepFinishTokens(
638
+ cache=TokenCache(read=0, write=0),
639
+ input=input_tokens,
640
+ output=output_tokens,
641
+ reasoning=0,
642
+ ),
643
+ cost=total_cost,
644
+ )
645
+ assistant_msg_with_parts.parts.append(step_finish)
646
+ await state.broadcast_event(PartUpdatedEvent.create(step_finish))
647
+
648
+ print(f"Response text: {response_text[:100] if response_text else 'EMPTY'}...")
649
+
650
+ # Update assistant message with final timing and tokens
651
+ updated_assistant = assistant_message.model_copy(
652
+ update={
653
+ "time": MessageTime(created=now, completed=response_time),
654
+ "tokens": Tokens(
655
+ cache=TokensCache(read=0, write=0),
656
+ input=input_tokens,
657
+ output=output_tokens,
658
+ reasoning=0,
659
+ ),
660
+ "cost": total_cost,
661
+ }
662
+ )
663
+ assistant_msg_with_parts.info = updated_assistant
664
+
665
+ # Broadcast final message update
666
+ await state.broadcast_event(MessageUpdatedEvent.create(updated_assistant))
667
+ # Persist assistant message to storage
668
+ await persist_message_to_storage(state, assistant_msg_with_parts, session_id)
669
+ # Mark session as not running
670
+ state.session_status[session_id] = SessionStatus(type="idle")
671
+ await state.broadcast_event(SessionStatusEvent.create(session_id, SessionStatus(type="idle")))
672
+ await state.broadcast_event(SessionIdleEvent.create(session_id))
673
+
674
+ # Update session timestamp
675
+ session = state.sessions[session_id]
676
+ state.sessions[session_id] = session.model_copy(
677
+ update={"time": TimeCreatedUpdated(created=session.time.created, updated=response_time)}
678
+ )
679
+ # Trigger title generation if session has default title
680
+ if session.title == "New Session" and state.pool.storage:
681
+ # Convert user_prompt to string if it's a list
682
+ prompt_str = user_prompt if isinstance(user_prompt, str) else str(user_prompt)
683
+ state.create_background_task(
684
+ _generate_session_title(state, session_id, prompt_str, response_text),
685
+ name=f"generate_title_{session_id}",
686
+ )
687
+ return assistant_msg_with_parts
688
+
689
+
690
+ @router.get("/message/{message_id}")
691
+ async def get_message(
692
+ session_id: str,
693
+ message_id: str,
694
+ state: StateDep,
695
+ ) -> MessageWithParts:
696
+ """Get a specific message."""
697
+ session = await get_or_load_session(state, session_id)
698
+ if session is None:
699
+ raise HTTPException(status_code=404, detail="Session not found")
700
+
701
+ for msg in state.messages.get(session_id, []):
702
+ if msg.info.id == message_id:
703
+ return msg
704
+
705
+ raise HTTPException(status_code=404, detail="Message not found")