codee-agent 0.6.4__tar.gz → 0.6.5__tar.gz

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 (92) hide show
  1. {codee_agent-0.6.4 → codee_agent-0.6.5}/PKG-INFO +1 -1
  2. {codee_agent-0.6.4 → codee_agent-0.6.5}/pyproject.toml +1 -1
  3. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/admin.py +143 -9
  4. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/admin_service.py +32 -5
  5. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/test_admin_service.py +60 -13
  6. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_agent_abstract/provider.py +15 -0
  7. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_agent_claude_code/provider.py +17 -2
  8. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_agent_claude_code/test.py +12 -2
  9. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_agent_codex/provider.py +30 -3
  10. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_agent_codex/test.py +9 -0
  11. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_agent_github_copilot/test.py +25 -8
  12. {codee_agent-0.6.4 → codee_agent-0.6.5}/LICENSE +0 -0
  13. {codee_agent-0.6.4 → codee_agent-0.6.5}/README.md +0 -0
  14. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/.gitignore +0 -0
  15. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/__init__.py +0 -0
  16. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/admin_api.py +0 -0
  17. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/admin_cli.py +0 -0
  18. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/agent_cli.py +0 -0
  19. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/coding_agents.py +0 -0
  20. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/executor.py +0 -0
  21. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/init_cli.py +0 -0
  22. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/lib/__init__.py +0 -0
  23. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/lib/claude_key_rotation.py +0 -0
  24. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/lib/cron_describe.py +0 -0
  25. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/lib/mcp_config.py +0 -0
  26. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/lib/runs_db.py +0 -0
  27. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/lib/test_claude_key_rotation.py +0 -0
  28. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/lib/test_mcp_config.py +0 -0
  29. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/lib/test_runs_db.py +0 -0
  30. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/lib/test_trigger_cron_skills.py +0 -0
  31. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/lib/test_trigger_issue_skills.py +0 -0
  32. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/lib/trigger_aws_sqs_skills.py +0 -0
  33. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/lib/trigger_cron_skills.py +0 -0
  34. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/lib/trigger_email_skills.py +0 -0
  35. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/lib/trigger_issue_skills.py +0 -0
  36. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/mail_server.py +0 -0
  37. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/setup_wizard.py +0 -0
  38. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/start_cli.py +0 -0
  39. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/tasks_providers.py +0 -0
  40. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/templates/AGENTS.md +0 -0
  41. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/templates/CLAUDE.md +0 -0
  42. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/templates/skills/aws-sqs-alarm-response/SKILL.md +0 -0
  43. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/templates/skills/cron-research-5xx-errors/SKILL.md +0 -0
  44. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/templates/skills/story-code-reviewer/SKILL.md +0 -0
  45. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/templates/skills/story-developer/SKILL.md +0 -0
  46. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/templates/skills/story-planner/SKILL.md +0 -0
  47. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/templates/skills/story-planner/assets/readme-template.md +0 -0
  48. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/templates/skills/story-qa/SKILL.md +0 -0
  49. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/templates/skills/story-security-reviewer/SKILL.md +0 -0
  50. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/templates/skills/task-code-reviewer/SKILL.md +0 -0
  51. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/templates/skills/task-developer/SKILL.md +0 -0
  52. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/templates/skills/task-qa/SKILL.md +0 -0
  53. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/templates/skills/task-security-reviewer/SKILL.md +0 -0
  54. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/test_admin_api.py +0 -0
  55. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/test_admin_cli.py +0 -0
  56. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/test_agent_cli.py +0 -0
  57. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/test_coding_agents.py +0 -0
  58. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/test_executor.py +0 -0
  59. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/test_init_cli.py +0 -0
  60. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/test_memory_index.py +0 -0
  61. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/test_setup_wizard.py +0 -0
  62. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/test_start_cli.py +0 -0
  63. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee/workflow_graph.py +0 -0
  64. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_admin/__init__.py +0 -0
  65. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_admin/codee_admin.py +0 -0
  66. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_agent_abstract/__init__.py +0 -0
  67. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_agent_claude_code/__init__.py +0 -0
  68. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_agent_claude_code/account.py +0 -0
  69. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_agent_claude_code/credentials.py +0 -0
  70. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_agent_claude_code/oauth.py +0 -0
  71. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_agent_claude_code/usage.py +0 -0
  72. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_agent_codex/__init__.py +0 -0
  73. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_agent_github_copilot/__init__.py +0 -0
  74. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_agent_github_copilot/provider.py +0 -0
  75. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_database/__init__.py +0 -0
  76. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_database/claude_code_accounts.py +0 -0
  77. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_database/database.py +0 -0
  78. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_database/oauth_tokens.py +0 -0
  79. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_main_context/__init__.py +0 -0
  80. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_main_context/context.py +0 -0
  81. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_main_context/logging.py +0 -0
  82. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_main_context/test_context.py +0 -0
  83. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_main_context/test_logging.py +0 -0
  84. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_tasks_abstract/__init__.py +0 -0
  85. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_tasks_abstract/provider.py +0 -0
  86. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_tasks_azure_devops/__init__.py +0 -0
  87. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_tasks_azure_devops/oauth.py +0 -0
  88. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_tasks_azure_devops/provider.py +0 -0
  89. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_tasks_azure_devops/test.py +0 -0
  90. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_tasks_jira/__init__.py +0 -0
  91. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_tasks_jira/provider.py +0 -0
  92. {codee_agent-0.6.4 → codee_agent-0.6.5}/src/codee_tasks_jira/test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codee-agent
3
- Version: 0.6.4
3
+ Version: 0.6.5
4
4
  Summary: A virtual co-worker that picks up Jira and Azure DevOps tasks and solves them with coding agents.
5
5
  Keywords: ai,agent,jira,azure-devops,claude-code,github-copilot,codex,automation
6
6
  Author: Denis Kibalko
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codee-agent"
3
- version = "0.6.4"
3
+ version = "0.6.5"
4
4
  description = "A virtual co-worker that picks up Jira and Azure DevOps tasks and solves them with coding agents."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -101,6 +101,11 @@ class ModelOption(BaseModel):
101
101
  name: str
102
102
 
103
103
 
104
+ class ConversationMessage(BaseModel):
105
+ role: str
106
+ content: str
107
+
108
+
104
109
  class ClaudeAccount(BaseModel):
105
110
  """One connected Claude account as the settings page lists it.
106
111
 
@@ -356,6 +361,12 @@ class AdminState(rx.State):
356
361
  tasks_provider: str = "jira"
357
362
  coding_agent: str = "claude_code"
358
363
  max_parallel_agents: str = "3"
364
+ agent_test_open: bool = False
365
+ agent_test_input: str = ""
366
+ agent_test_session_id: str = ""
367
+ agent_test_messages: list[ConversationMessage] = []
368
+ agent_test_sending: bool = False
369
+ agent_test_generation: int = 0
359
370
  # Whether the executor runs Claude Code on the connected accounts instead
360
371
  # of leaving ~/.claude/.credentials.json alone.
361
372
  claude_code_rotate_keys: bool = False
@@ -1516,6 +1527,61 @@ class AdminState(rx.State):
1516
1527
  error = self._persist_settings()
1517
1528
  return rx.toast.error(error) if error else rx.toast.success("Settings saved")
1518
1529
 
1530
+ def open_agent_test(self) -> None:
1531
+ self.agent_test_input = ""
1532
+ self.agent_test_session_id = ""
1533
+ self.agent_test_messages = []
1534
+ self.agent_test_sending = False
1535
+ self.agent_test_generation += 1
1536
+ self.agent_test_open = True
1537
+
1538
+ def set_agent_test_open(self, open_: bool) -> None:
1539
+ self.agent_test_open = open_
1540
+ if not open_:
1541
+ self.agent_test_generation += 1
1542
+ self.agent_test_sending = False
1543
+
1544
+ def set_agent_test_input(self, value: str) -> None:
1545
+ self.agent_test_input = value
1546
+
1547
+ @rx.event(background=True)
1548
+ async def send_agent_test_message(self) -> Any:
1549
+ async with self:
1550
+ message = self.agent_test_input.strip()
1551
+ if not message or self.agent_test_sending:
1552
+ return
1553
+ agent = self.coding_agent
1554
+ session_id = self.agent_test_session_id
1555
+ generation = self.agent_test_generation
1556
+ self.agent_test_input = ""
1557
+ self.agent_test_sending = True
1558
+ self.agent_test_messages = [
1559
+ *self.agent_test_messages,
1560
+ ConversationMessage(role="user", content=message),
1561
+ ]
1562
+ try:
1563
+ response, session_id = await asyncio.to_thread(
1564
+ SERVICE.test_agent_conversation,
1565
+ agent,
1566
+ message,
1567
+ session_id,
1568
+ )
1569
+ except Exception as error:
1570
+ async with self:
1571
+ if self.agent_test_generation == generation:
1572
+ self.agent_test_sending = False
1573
+ yield rx.toast.error(f"The coding agent failed: {error}")
1574
+ return
1575
+ async with self:
1576
+ if self.agent_test_generation != generation:
1577
+ return
1578
+ self.agent_test_session_id = session_id
1579
+ self.agent_test_messages = [
1580
+ *self.agent_test_messages,
1581
+ ConversationMessage(role="assistant", content=response),
1582
+ ]
1583
+ self.agent_test_sending = False
1584
+
1519
1585
 
1520
1586
  ACCENT = "var(--codee-accent)"
1521
1587
  ACCENT_DEEP = "var(--codee-accent-deep)"
@@ -2402,9 +2468,9 @@ def run_row(run: RunRecord) -> rx.Component:
2402
2468
  rx.vstack(rx.hstack(rx.text(run.skill_name, font_weight="600"),
2403
2469
  rx.badge(run.status, color_scheme=rx.cond(run.status == "succeeded", "green", "red"))),
2404
2470
  rx.text(local_datetime(run.started_at), color=MUTED,
2405
- font_size="0.8rem",
2471
+ font_size="0.8rem",
2406
2472
  font_family="IBM Plex Mono, monospace"),
2407
- rx.text("Thread ID: ", run.session_id, color=MUTED,
2473
+ rx.text("Thread ID: ", run.session_id, color=MUTED,
2408
2474
  font_size="0.8rem",
2409
2475
  font_family="IBM Plex Mono, monospace"),
2410
2476
  rx.text(run.preview, color=MUTED),
@@ -2418,9 +2484,10 @@ def run_row(run: RunRecord) -> rx.Component:
2418
2484
  rx.cond((run.user_message != "") | (run.response != ""), rx.accordion.root(rx.accordion.item(
2419
2485
  header="Run info", content=rx.vstack(
2420
2486
  rx.text("User message", font_weight="600"),
2421
- rx.text(run.user_message, white_space="pre-wrap"),
2487
+ rx.text(run.user_message, white_space="pre-wrap"),
2422
2488
  rx.cond(run.response != "", rx.fragment(
2423
- rx.text("LLM response", font_weight="600", margin_top="0.75rem"),
2489
+ rx.text("LLM response", font_weight="600",
2490
+ margin_top="0.75rem"),
2424
2491
  rx.text(run.response, white_space="pre-wrap"))),
2425
2492
  spacing="2", align="start", width="100%"), value=run.started_at),
2426
2493
  collapsible=True, width="100%")),
@@ -2644,7 +2711,8 @@ def workflow_page() -> rx.Component:
2644
2711
  rx.center(
2645
2712
  rx.vstack(
2646
2713
  rx.spinner(size="3"),
2647
- rx.foreach(AdminState.workflow_progress, workflow_progress_line),
2714
+ rx.foreach(AdminState.workflow_progress,
2715
+ workflow_progress_line),
2648
2716
  spacing="3",
2649
2717
  align="center",
2650
2718
  width="100%",
@@ -3221,6 +3289,64 @@ def claude_code_setting() -> rx.Component:
3221
3289
  padding="1.25rem", background=SURFACE, border=BORDER, width="100%")
3222
3290
 
3223
3291
 
3292
+ def conversation_message(message: ConversationMessage) -> rx.Component:
3293
+ is_user = message.role == "user"
3294
+ return rx.box(
3295
+ rx.text(message.content, white_space="pre-wrap"),
3296
+ align_self=rx.cond(is_user, "end", "start"),
3297
+ background=rx.cond(is_user, ACTIVE, SURFACE),
3298
+ border=rx.cond(is_user, "none", BORDER),
3299
+ border_radius="6px",
3300
+ padding="0.65rem 0.8rem",
3301
+ max_width="85%",
3302
+ )
3303
+
3304
+
3305
+ def agent_test_dialog() -> rx.Component:
3306
+ return rx.dialog.root(
3307
+ rx.dialog.content(
3308
+ rx.dialog.title("Test conversation"),
3309
+ rx.dialog.description(
3310
+ "Chat with the selected coding agent in the Codee project.",
3311
+ color=MUTED),
3312
+ rx.scroll_area(
3313
+ rx.vstack(
3314
+ rx.cond(
3315
+ AdminState.agent_test_messages.length() == 0,
3316
+ rx.text("Send a message to start the conversation.",
3317
+ color=MUTED, font_size="0.9rem",
3318
+ align_self="center", margin_top="3rem"),
3319
+ rx.foreach(AdminState.agent_test_messages,
3320
+ conversation_message)),
3321
+ width="100%", spacing="3"),
3322
+ type="auto", scrollbars="vertical", height="22rem",
3323
+ width="100%", margin_top="1rem"),
3324
+ rx.form(
3325
+ rx.hstack(
3326
+ rx.input(
3327
+ value=AdminState.agent_test_input,
3328
+ on_change=AdminState.set_agent_test_input,
3329
+ placeholder="Message the agent",
3330
+ disabled=AdminState.agent_test_sending,
3331
+ auto_focus=True,
3332
+ width="100%"),
3333
+ rx.button(rx.icon("send", size=16), type="submit",
3334
+ loading=AdminState.agent_test_sending,
3335
+ disabled=AdminState.agent_test_input == ""),
3336
+ spacing="2", width="100%"),
3337
+ on_submit=AdminState.send_agent_test_message,
3338
+ reset_on_submit=False,
3339
+ width="100%", margin_top="1rem"),
3340
+ rx.flex(
3341
+ rx.dialog.close(rx.button("Close", variant="soft",
3342
+ color_scheme="gray")),
3343
+ justify="end", margin_top="1rem"),
3344
+ max_width="38rem"),
3345
+ open=AdminState.agent_test_open,
3346
+ on_open_change=AdminState.set_agent_test_open,
3347
+ )
3348
+
3349
+
3224
3350
  def settings_page() -> rx.Component:
3225
3351
  jira = TasksProvider.JIRA
3226
3352
  jira_fields = rx.vstack(
@@ -3248,10 +3374,17 @@ def settings_page() -> rx.Component:
3248
3374
  rx.heading("Coding agent", size="4", margin_bottom="1rem"),
3249
3375
  rx.vstack(
3250
3376
  field("Default agent",
3251
- rx.select(["claude_code", "github_copilot", "codex"],
3252
- value=AdminState.coding_agent,
3253
- on_change=AdminState.set_coding_agent,
3254
- width="100%")),
3377
+ rx.hstack(
3378
+ rx.select(
3379
+ ["claude_code", "github_copilot", "codex"],
3380
+ value=AdminState.coding_agent,
3381
+ on_change=AdminState.set_coding_agent,
3382
+ width="100%"),
3383
+ rx.button(rx.icon("messages-square", size=16),
3384
+ "Test conversation", variant="outline",
3385
+ white_space="nowrap",
3386
+ on_click=AdminState.open_agent_test),
3387
+ width="100%", spacing="3")),
3255
3388
  field("Max parallel tasks",
3256
3389
  rx.input(value=AdminState.max_parallel_agents,
3257
3390
  on_change=AdminState.set_max_parallel_agents,
@@ -3277,6 +3410,7 @@ def settings_page() -> rx.Component:
3277
3410
  padding="1.25rem", background=SURFACE, border=BORDER, width="100%"),
3278
3411
  rx.button(rx.icon("save", size=16), "Save settings",
3279
3412
  on_click=AdminState.save_settings),
3413
+ agent_test_dialog(),
3280
3414
  spacing="5", align="start", width="100%"))
3281
3415
 
3282
3416
 
@@ -192,6 +192,7 @@ class WorkflowGeneration:
192
192
  workflow: dict[str, Any] | None = None
193
193
  error: str = ""
194
194
 
195
+
195
196
  # The checks the settings page runs against the tasks provider, in the order it
196
197
  # shows them: the second is only worth attempting once the first passes.
197
198
  TASKS_CHECK = "Tasks can be pulled"
@@ -893,7 +894,8 @@ class AdminService:
893
894
  # and how long it stands. Shared by every visitor to the dashboard,
894
895
  # because it is a property of the accounts rather than of whoever is
895
896
  # looking at them.
896
- self._usage_cache: dict[int, tuple[ConnectedAccount, float, float]] = {}
897
+ self._usage_cache: dict[int,
898
+ tuple[ConnectedAccount, float, float]] = {}
897
899
  self._usage_lock = threading.Lock()
898
900
 
899
901
  def _git_push(self, message: str) -> tuple[bool, str]:
@@ -1221,7 +1223,8 @@ class AdminService:
1221
1223
  name belongs on the other item's graph, so it is kept out of this one.
1222
1224
  """
1223
1225
  if not skills:
1224
- report(f"No issue-trigger skills for work item {issue_type.capitalize()}.")
1226
+ report(
1227
+ f"No issue-trigger skills for work item {issue_type.capitalize()}.")
1225
1228
  return {"nodes": [], "edges": [], "warnings": []}
1226
1229
 
1227
1230
  documents = []
@@ -1414,7 +1417,8 @@ class AdminService:
1414
1417
  for status in (transition["source"], transition["target"])}
1415
1418
  statuses, foreign_statuses = _own_work_item_statuses(
1416
1419
  statuses, scope, triggered | moved,
1417
- dict.fromkeys(document for _, document in skill_documents.values()),
1420
+ dict.fromkeys(document for _,
1421
+ document in skill_documents.values()),
1418
1422
  )
1419
1423
  if foreign_statuses:
1420
1424
  report(
@@ -1831,7 +1835,7 @@ class AdminService:
1831
1835
  existing, _ = parse_skill(current_path.read_text())
1832
1836
  extra = {key: value for key, value in existing.items()
1833
1837
  if key not in MANAGED}
1834
- action =f"rename {old_slug} -> {name}" if name != old_slug else f"update {name}"
1838
+ action = f"rename {old_slug} -> {name}" if name != old_slug else f"update {name}"
1835
1839
  saved, pushed, message = self._write_and_push(
1836
1840
  current_path,
1837
1841
  build_skill(frontmatter, extra, skill["body"]),
@@ -2439,6 +2443,28 @@ class AdminService:
2439
2443
  except ValueError as exc:
2440
2444
  raise RuntimeError(str(exc)) from exc
2441
2445
 
2446
+ def test_agent_conversation(
2447
+ self, agent_code: str, user_message: str, session_id: str = ""
2448
+ ) -> tuple[str, str]:
2449
+ """Run one turn with the selected agent and return reply plus session id."""
2450
+ selected = resolve_agent_code(agent_code)
2451
+ if selected is None:
2452
+ raise RuntimeError(f"Unsupported coding agent: {agent_code}")
2453
+ agent = build_coding_agent(self.context.settings, self.root, selected)
2454
+ actual_session_id = session_id or str(uuid.uuid4())
2455
+
2456
+ def opened(opened_session_id: str) -> None:
2457
+ nonlocal actual_session_id
2458
+ actual_session_id = opened_session_id
2459
+
2460
+ if session_id:
2461
+ response = agent.continue_conversation(
2462
+ user_message, session_id, on_session_id=opened)
2463
+ else:
2464
+ response = agent.run(
2465
+ user_message, actual_session_id, on_session_id=opened)
2466
+ return response, actual_session_id
2467
+
2442
2468
  def setup_tasks_mcp(
2443
2469
  self,
2444
2470
  tasks_provider: str,
@@ -2600,7 +2626,8 @@ def _format_token_expiry(expires_at: str | None) -> str:
2600
2626
  return "refreshes on next check"
2601
2627
  if deadline.tzinfo is None:
2602
2628
  deadline = deadline.replace(tzinfo=timezone.utc)
2603
- minutes = int((deadline - datetime.now(timezone.utc)).total_seconds() // 60)
2629
+ minutes = int(
2630
+ (deadline - datetime.now(timezone.utc)).total_seconds() // 60)
2604
2631
  if minutes < 1:
2605
2632
  return "refreshes on next check"
2606
2633
  if minutes < 60:
@@ -80,7 +80,8 @@ class NormalizeWorkItemsTest(unittest.TestCase):
80
80
  [("story", ["Story"], ""), ("task", ["Task", "Bug"], "")])
81
81
 
82
82
  self.assertEqual(error, "")
83
- self.assertEqual(mapping, {"story": ["Story"], "task": ["Task", "Bug"]})
83
+ self.assertEqual(
84
+ mapping, {"story": ["Story"], "task": ["Task", "Bug"]})
84
85
 
85
86
  def test_a_type_listed_twice_in_one_row_is_kept_once(self) -> None:
86
87
  mapping, _, error = normalize_work_items(
@@ -164,6 +165,42 @@ class NormalizeWorkItemsTest(unittest.TestCase):
164
165
  self.assertEqual(error, "'bug' is listed twice")
165
166
 
166
167
 
168
+ class TestAgentConversationTest(unittest.TestCase):
169
+ def setUp(self) -> None:
170
+ self.service = AdminService.__new__(AdminService)
171
+ self.service.root = Path("/repo")
172
+ self.service.context = Mock(settings=Settings())
173
+
174
+ @patch("codee.admin_service.build_coding_agent")
175
+ def test_first_turn_returns_the_agent_session_id(self, build_agent) -> None:
176
+ agent = build_agent.return_value
177
+
178
+ def run(message, session_id, model="", on_session_id=None):
179
+ on_session_id("agent-thread")
180
+ return "Final response"
181
+
182
+ agent.run.side_effect = run
183
+
184
+ response, session_id = self.service.test_agent_conversation(
185
+ "codex", "Hello")
186
+
187
+ self.assertEqual(response, "Final response")
188
+ self.assertEqual(session_id, "agent-thread")
189
+ build_agent.assert_called_once_with(
190
+ self.service.context.settings, Path("/repo"), CodingAgent.CODEX)
191
+
192
+ @patch("codee.admin_service.build_coding_agent")
193
+ def test_later_turn_resumes_the_same_session(self, build_agent) -> None:
194
+ build_agent.return_value.continue_conversation.return_value = "Still here"
195
+
196
+ response, session_id = self.service.test_agent_conversation(
197
+ "github_copilot", "What did I ask?", "same-thread")
198
+
199
+ self.assertEqual((response, session_id), ("Still here", "same-thread"))
200
+ call = build_agent.return_value.continue_conversation.call_args
201
+ self.assertEqual(call.args[:2], ("What did I ask?", "same-thread"))
202
+
203
+
167
204
  class AdminServiceWorkItemsTest(unittest.TestCase):
168
205
  def _service(self, directory: Path) -> AdminService:
169
206
  service = AdminService.__new__(AdminService)
@@ -408,7 +445,8 @@ class AdminServiceClaudeCodeAccountsTest(unittest.TestCase):
408
445
  self._connect("one@example.com")
409
446
  self._connect("two@example.com")
410
447
  second = self.service.claude_code_accounts()[1]
411
- claude_code_accounts.set_current_account(second.id, self.service.context)
448
+ claude_code_accounts.set_current_account(
449
+ second.id, self.service.context)
412
450
 
413
451
  self.service.disconnect_claude_code_account(second.id)
414
452
 
@@ -423,7 +461,8 @@ class AdminServiceClaudeCodeAccountsTest(unittest.TestCase):
423
461
 
424
462
  self.service.disconnect_claude_code_account(account.id)
425
463
 
426
- self.assertEqual(claude_code_accounts.accounts(self.service.context), [])
464
+ self.assertEqual(claude_code_accounts.accounts(
465
+ self.service.context), [])
427
466
 
428
467
  def test_switching_rotation_off_forgets_which_account_is_in_use(self) -> None:
429
468
  # So switching it back on later starts from the top rather than from
@@ -431,7 +470,8 @@ class AdminServiceClaudeCodeAccountsTest(unittest.TestCase):
431
470
  self._connect("one@example.com")
432
471
  self._connect("two@example.com")
433
472
  second = self.service.claude_code_accounts()[1]
434
- claude_code_accounts.set_current_account(second.id, self.service.context)
473
+ claude_code_accounts.set_current_account(
474
+ second.id, self.service.context)
435
475
 
436
476
  self.service.save_settings("jira", "claude_code", 3, {}, None, None,
437
477
  "", False)
@@ -465,14 +505,16 @@ class AdminServiceClaudeCodeAccountsTest(unittest.TestCase):
465
505
  "access-1", "refresh-1", expires_at=far_future,
466
506
  refresh_expires_at=far_future))
467
507
 
468
- self.assertFalse(self.service.claude_code_accounts()[0].needs_reconnect)
508
+ self.assertFalse(self.service.claude_code_accounts()
509
+ [0].needs_reconnect)
469
510
 
470
511
  def test_an_account_with_no_known_window_is_not_flagged(self) -> None:
471
512
  # Zero means the API never said, which is not the same as expired.
472
513
  self._connect("one@example.com", tokens=claude_oauth.Tokens(
473
514
  "access-1", "refresh-1", expires_at=1, refresh_expires_at=0))
474
515
 
475
- self.assertFalse(self.service.claude_code_accounts()[0].needs_reconnect)
516
+ self.assertFalse(self.service.claude_code_accounts()
517
+ [0].needs_reconnect)
476
518
 
477
519
  def test_each_account_reports_both_of_its_windows(self) -> None:
478
520
  self._connect("one@example.com")
@@ -487,7 +529,8 @@ class AdminServiceClaudeCodeAccountsTest(unittest.TestCase):
487
529
 
488
530
  self.assertEqual(measured[0].session_percent, 12.0)
489
531
  self.assertEqual(measured[0].weekly_percent, 70.0)
490
- self.assertEqual(measured[0].weekly_resets, "2026-09-16T09:00:00+00:00")
532
+ self.assertEqual(measured[0].weekly_resets,
533
+ "2026-09-16T09:00:00+00:00")
491
534
  self.assertEqual(measured[0].usage_error, "")
492
535
 
493
536
  def test_the_reading_is_cached_rather_than_taken_every_redraw(self) -> None:
@@ -555,7 +598,8 @@ class AdminServiceClaudeCodeAccountsTest(unittest.TestCase):
555
598
  measured[1].id, self.service.context)
556
599
  again = self.service.claude_code_account_usage()
557
600
 
558
- self.assertEqual([account.in_use for account in measured], [True, False])
601
+ self.assertEqual(
602
+ [account.in_use for account in measured], [True, False])
559
603
  self.assertEqual([account.in_use for account in again], [False, True])
560
604
  self.assertEqual(again[1].session_percent, 3.0)
561
605
 
@@ -594,7 +638,8 @@ class AdminServiceClaudeCodeAccountsTest(unittest.TestCase):
594
638
 
595
639
  self.assertEqual(measured[0].session_percent, 12.0)
596
640
  self.assertEqual(measured[0].weekly_percent, 70.0)
597
- self.assertEqual(measured[0].session_resets, "2026-09-13T14:10:00+00:00")
641
+ self.assertEqual(measured[0].session_resets,
642
+ "2026-09-13T14:10:00+00:00")
598
643
  self.assertEqual(measured[0].usage_error, "")
599
644
 
600
645
  def test_a_rate_limit_with_nothing_to_fall_back_on_says_so(self) -> None:
@@ -662,7 +707,8 @@ class AdminServiceClaudeCodeAccountsTest(unittest.TestCase):
662
707
  return_value=Usage(limited=False, windows={}, resets_at={})):
663
708
  measured = self.service.claude_code_account_usage()
664
709
 
665
- self.assertEqual([account.in_use for account in measured], [True, False])
710
+ self.assertEqual(
711
+ [account.in_use for account in measured], [True, False])
666
712
 
667
713
  def test_no_accounts_means_no_requests(self) -> None:
668
714
  with patch("codee.admin_service.fetch_usage") as fetch:
@@ -1927,7 +1973,6 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
1927
1973
 
1928
1974
  self.assertEqual(generate.call_count, 1)
1929
1975
 
1930
-
1931
1976
  def test_generate_workflow_regenerates_a_graph_from_an_older_version(self) -> None:
1932
1977
  with tempfile.TemporaryDirectory() as temporary_directory:
1933
1978
  root = Path(temporary_directory)
@@ -2046,7 +2091,8 @@ class AdminServiceWorkflowAgentTest(unittest.TestCase):
2046
2091
 
2047
2092
  nodes = self._story_nodes(service)
2048
2093
 
2049
- self.assertNotIn("workflow-node--agent", nodes["Review"]["className"])
2094
+ self.assertNotIn("workflow-node--agent",
2095
+ nodes["Review"]["className"])
2050
2096
  self.assertNotIn("style", nodes["Review"])
2051
2097
 
2052
2098
  def test_changing_the_agent_needs_no_second_inference(self) -> None:
@@ -2491,7 +2537,8 @@ class AdminServiceAgentModelsTest(unittest.TestCase):
2491
2537
  patch.object(ClaudeCodeAgent, "list_models") as claude_models:
2492
2538
  models = service.list_agent_models("codex")
2493
2539
 
2494
- self.assertEqual(models, [{"id": "gpt-6-astra", "name": "GPT-6 Astra"}])
2540
+ self.assertEqual(
2541
+ models, [{"id": "gpt-6-astra", "name": "GPT-6 Astra"}])
2495
2542
  claude_models.assert_not_called()
2496
2543
 
2497
2544
  def test_an_agent_codee_cannot_run_falls_back_to_the_default(self) -> None:
@@ -69,6 +69,21 @@ class AbstractCodingAgent(ABC):
69
69
  """
70
70
  ...
71
71
 
72
+ def continue_conversation(
73
+ self,
74
+ user_message: str,
75
+ session_id: str,
76
+ model: str = "",
77
+ on_session_id: Callable[[str], None] | None = None,
78
+ ) -> str:
79
+ """Continue an existing agent conversation and return its final reply.
80
+
81
+ Agents whose normal run command resumes when given an existing session
82
+ need no special implementation. Agents with a distinct resume command
83
+ override this method.
84
+ """
85
+ return self.run(user_message, session_id, model, on_session_id)
86
+
72
87
  def skill_prompt(self, slug: str, path: Path, argument: str = "",
73
88
  argument_name: str = "") -> str:
74
89
  """The message that makes this agent run the skill stored at ``path``.
@@ -58,10 +58,25 @@ class ClaudeCodeAgent(AbstractCodingAgent):
58
58
  # answer is known before the run starts.
59
59
  if on_session_id:
60
60
  on_session_id(session_id)
61
+ return self._run(user_message, session_id, model, False)
62
+
63
+ def continue_conversation(
64
+ self,
65
+ user_message: str,
66
+ session_id: str,
67
+ model: str = "",
68
+ on_session_id: Callable[[str], None] | None = None,
69
+ ) -> str:
70
+ if on_session_id:
71
+ on_session_id(session_id)
72
+ return self._run(user_message, session_id, model, True)
73
+
74
+ def _run(self, user_message: str, session_id: str, model: str,
75
+ resume: bool) -> str:
61
76
  cmd = [
62
77
  self.CLI_COMMAND,
63
78
  "-p", user_message,
64
- "--session-id", session_id,
79
+ "--resume" if resume else "--session-id", session_id,
65
80
  "--max-budget-usd", self.MAX_BUDGET_USD,
66
81
  "--output-format", "json",
67
82
  "--permission-mode", "bypassPermissions",
@@ -91,7 +106,7 @@ class ClaudeCodeAgent(AbstractCodingAgent):
91
106
  stdout = result.stdout or ""
92
107
  stderr = result.stderr or ""
93
108
  log.debug("claude exited %d (%d bytes stdout, %d bytes stderr)",
94
- result.returncode, len(stdout), len(stderr))
109
+ result.returncode, len(stdout), len(stderr))
95
110
 
96
111
  # Raise on any non-success so callers retry. Over-limit exits non-zero;
97
112
  # a completed-but-errored run sets is_error in the JSON.
@@ -34,7 +34,8 @@ def _response(status_code: int, text: str = "", json_body=None):
34
34
  response = Mock(spec=["status_code", "text", "json"])
35
35
  response.status_code = status_code
36
36
  response.text = text
37
- response.json = Mock(return_value=json_body if json_body is not None else {})
37
+ response.json = Mock(
38
+ return_value=json_body if json_body is not None else {})
38
39
  return response
39
40
 
40
41
 
@@ -56,6 +57,14 @@ class ClaudeCodeRunTest(unittest.TestCase):
56
57
 
57
58
  self.assertEqual(cmd[cmd.index("--model") + 1], "opus")
58
59
 
60
+ def test_continuing_uses_resume_instead_of_starting_a_session(self) -> None:
61
+ with patch("subprocess.run", return_value=_completed()) as run:
62
+ self.agent.continue_conversation("And now?", SESSION)
63
+
64
+ cmd = run.call_args.args[0]
65
+ self.assertEqual(cmd[cmd.index("--resume") + 1], SESSION)
66
+ self.assertNotIn("--session-id", cmd)
67
+
59
68
  def test_cli_output_is_decoded_as_utf8(self) -> None:
60
69
  with patch("subprocess.run", return_value=_completed()) as run:
61
70
  self.agent.run("/do-it CORE-1", SESSION)
@@ -129,7 +138,8 @@ class ClaudeCodeUsageTest(unittest.TestCase):
129
138
  # the executor to a key by looking like an error.
130
139
  self.assertFalse(read_usage({}).limited)
131
140
  self.assertFalse(read_usage({"five_hour": None}).limited)
132
- self.assertFalse(read_usage({"five_hour": {"utilization": None}}).limited)
141
+ self.assertFalse(read_usage(
142
+ {"five_hour": {"utilization": None}}).limited)
133
143
 
134
144
  def test_a_rejected_key_counts_as_spent(self) -> None:
135
145
  # Expired or revoked. As unusable as an exhausted one, and the same
@@ -101,12 +101,36 @@ class CodexAgent(AbstractCodingAgent):
101
101
  f"Codex run produced no response: {_detail(errors, result.stderr)}")
102
102
  return reply
103
103
 
104
+ def continue_conversation(
105
+ self,
106
+ user_message: str,
107
+ session_id: str,
108
+ model: str = "",
109
+ on_session_id: Callable[[str], None] | None = None,
110
+ ) -> str:
111
+ result = self._exec(user_message, model, on_session_id, session_id)
112
+ reply, completed, errors = _parse_events(result.stdout)
113
+ if result.returncode != 0:
114
+ raise RuntimeError(
115
+ f"Codex CLI exited {result.returncode}: "
116
+ f"{_detail(errors, result.stderr)}"
117
+ )
118
+ if not completed:
119
+ raise RuntimeError(
120
+ f"Codex run errored: {_detail(errors, result.stderr)}")
121
+ if not reply:
122
+ raise RuntimeError(
123
+ f"Codex run produced no response: {_detail(errors, result.stderr)}")
124
+ return reply
125
+
104
126
  def _exec(self, user_message: str, model: str,
105
- on_session_id: Callable[[str], None] | None = None
127
+ on_session_id: Callable[[str], None] | None = None,
128
+ resume_session_id: str = "",
106
129
  ) -> subprocess.CompletedProcess:
107
130
  """One headless ``codex exec`` in a thread of its own."""
108
131
  cmd = [
109
- self.CLI_COMMAND, "exec",
132
+ self.CLI_COMMAND, "exec", *
133
+ (["resume"] if resume_session_id else []),
110
134
  "--json",
111
135
  # Codee's project root is a repository in the normal case but need
112
136
  # not be one, and `codex exec` refuses to start outside git.
@@ -124,7 +148,10 @@ class CodexAgent(AbstractCodingAgent):
124
148
  cmd += _mcp_overrides(self._cwd)
125
149
  # The prompt goes last, behind `--`, so a skill whose slug collides with
126
150
  # a subcommand (`codex exec review`) still reaches the model.
127
- cmd += ["--", user_message]
151
+ cmd += ["--"]
152
+ if resume_session_id:
153
+ cmd += [resume_session_id]
154
+ cmd += [user_message]
128
155
 
129
156
  log.debug("cwd=%s cmd=%s", self._cwd, " ".join(cmd))
130
157
  # Streamed rather than collected at the end: the thread id is on the
@@ -105,6 +105,15 @@ class CodexRunTest(unittest.TestCase):
105
105
  self.assertNotIn(SESSION, self._cmd())
106
106
  self.assertNotIn("resume", self._cmd())
107
107
 
108
+ def test_continuing_resumes_the_thread(self) -> None:
109
+ with patch("subprocess.Popen", return_value=_completed(_turn())) as popen:
110
+ response = self.agent.continue_conversation("And now?", THREAD)
111
+
112
+ self.assertEqual(response, "Done, PR is up.")
113
+ cmd = popen.call_args.args[0]
114
+ self.assertEqual(cmd[:3], ["codex", "exec", "resume"])
115
+ self.assertEqual(cmd[-3:], ["--", THREAD, "And now?"])
116
+
108
117
  def test_the_thread_codex_opened_is_reported_back(self) -> None:
109
118
  # What the dashboard links its session viewer to while the run is live.
110
119
  self._run(_completed(_turn()))
@@ -65,7 +65,8 @@ class CopilotSkillPromptTest(unittest.TestCase):
65
65
  )
66
66
 
67
67
  def test_a_skill_outside_the_working_directory_keeps_its_full_path(self) -> None:
68
- prompt = self._prompt(Path("/elsewhere/skills/reviewer/SKILL.md"), "7", "ID")
68
+ prompt = self._prompt(
69
+ Path("/elsewhere/skills/reviewer/SKILL.md"), "7", "ID")
69
70
 
70
71
  self.assertEqual(
71
72
  prompt,
@@ -85,7 +86,8 @@ class CopilotRunTest(unittest.TestCase):
85
86
 
86
87
  def test_returns_the_last_assistant_message(self) -> None:
87
88
  stdout = _stream(
88
- _event("assistant.message", {"content": "Looking at it", "toolRequests": [{}]}),
89
+ _event("assistant.message", {
90
+ "content": "Looking at it", "toolRequests": [{}]}),
89
91
  _event("tool.execution_complete", {}),
90
92
  _event("assistant.message", {"content": "Done, PR is up.\n"}),
91
93
  _event("result", exitCode=0, sessionId=SESSION),
@@ -119,6 +121,15 @@ class CopilotRunTest(unittest.TestCase):
119
121
  self.assertEqual(self.captured.call_args.kwargs["encoding"], "utf-8")
120
122
  self.assertEqual(self.captured.call_args.kwargs["errors"], "replace")
121
123
 
124
+ def test_continuing_reuses_the_same_session_id(self) -> None:
125
+ stdout = _stream(_event("assistant.message", {"content": "ok"}),
126
+ _event("result", exitCode=0))
127
+ with patch("subprocess.run", return_value=_completed(stdout)) as run:
128
+ self.agent.continue_conversation("And now?", SESSION)
129
+
130
+ cmd = run.call_args.args[0]
131
+ self.assertEqual(cmd[cmd.index("--session-id") + 1], SESSION)
132
+
122
133
  def test_missing_captured_streams_do_not_raise_type_error(self) -> None:
123
134
  completed = subprocess.CompletedProcess(
124
135
  args=["copilot"], returncode=0, stdout=None, stderr=None)
@@ -159,7 +170,8 @@ class CopilotRunTest(unittest.TestCase):
159
170
 
160
171
  def test_a_failed_run_raises_with_the_session_error(self) -> None:
161
172
  stdout = _stream(
162
- _event("session.error", {"errorType": "quota", "message": "quota exceeded"}),
173
+ _event("session.error", {
174
+ "errorType": "quota", "message": "quota exceeded"}),
163
175
  _event("result", exitCode=1),
164
176
  )
165
177
 
@@ -193,7 +205,8 @@ class CopilotCreditCapTest(unittest.TestCase):
193
205
  else:
194
206
  os.environ[MAX_AI_CREDITS_ENV_VAR] = value
195
207
  with patch("subprocess.run", return_value=_completed(stdout)) as run:
196
- GitHubCopilotAgent(Settings(), Path("/repo")).run("/do-it", SESSION)
208
+ GitHubCopilotAgent(Settings(), Path(
209
+ "/repo")).run("/do-it", SESSION)
197
210
  cmd = run.call_args.args[0]
198
211
  return cmd[cmd.index("--max-ai-credits") + 1]
199
212
 
@@ -222,9 +235,12 @@ class CopilotModelCatalogTest(unittest.TestCase):
222
235
 
223
236
  def test_reads_the_session_new_result_past_other_traffic(self) -> None:
224
237
  lines = self._queue(
225
- json.dumps({"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": 1}}),
226
- json.dumps({"jsonrpc": "2.0", "method": "session/update", "params": {}}),
227
- json.dumps({"jsonrpc": "2.0", "id": 2, "result": {"sessionId": "s1"}}),
238
+ json.dumps({"jsonrpc": "2.0", "id": 1,
239
+ "result": {"protocolVersion": 1}}),
240
+ json.dumps(
241
+ {"jsonrpc": "2.0", "method": "session/update", "params": {}}),
242
+ json.dumps({"jsonrpc": "2.0", "id": 2,
243
+ "result": {"sessionId": "s1"}}),
228
244
  )
229
245
 
230
246
  result = _await_result(Mock(poll=Mock(return_value=None)), lines, 2)
@@ -243,7 +259,8 @@ class CopilotModelCatalogTest(unittest.TestCase):
243
259
  def test_a_catalog_becomes_id_and_name_pairs(self) -> None:
244
260
  result = {"models": {"availableModels": [
245
261
  {"modelId": "claude-opus-5", "name": "Claude Opus 5"},
246
- {"modelId": "gpt-5.4"}, # no display name: falls back to the id
262
+ # no display name: falls back to the id
263
+ {"modelId": "gpt-5.4"},
247
264
  {"name": "nameless"}, # no id at all: unusable, skipped
248
265
  ]}}
249
266
 
File without changes
File without changes