codee-agent 0.6.3__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.3 → codee_agent-0.6.5}/PKG-INFO +1 -1
  2. {codee_agent-0.6.3 → codee_agent-0.6.5}/pyproject.toml +1 -1
  3. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/admin.py +143 -9
  4. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/admin_service.py +32 -5
  5. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/executor.py +12 -8
  6. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/test_admin_service.py +60 -13
  7. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/test_executor.py +84 -0
  8. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_agent_abstract/provider.py +15 -0
  9. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_agent_claude_code/provider.py +18 -2
  10. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_agent_claude_code/test.py +17 -2
  11. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_agent_codex/provider.py +30 -3
  12. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_agent_codex/test.py +9 -0
  13. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_agent_github_copilot/test.py +25 -8
  14. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_tasks_abstract/provider.py +29 -8
  15. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_tasks_azure_devops/provider.py +13 -99
  16. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_tasks_azure_devops/test.py +38 -97
  17. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_tasks_jira/provider.py +24 -70
  18. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_tasks_jira/test.py +49 -24
  19. {codee_agent-0.6.3 → codee_agent-0.6.5}/LICENSE +0 -0
  20. {codee_agent-0.6.3 → codee_agent-0.6.5}/README.md +0 -0
  21. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/.gitignore +0 -0
  22. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/__init__.py +0 -0
  23. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/admin_api.py +0 -0
  24. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/admin_cli.py +0 -0
  25. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/agent_cli.py +0 -0
  26. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/coding_agents.py +0 -0
  27. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/init_cli.py +0 -0
  28. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/lib/__init__.py +0 -0
  29. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/lib/claude_key_rotation.py +0 -0
  30. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/lib/cron_describe.py +0 -0
  31. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/lib/mcp_config.py +0 -0
  32. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/lib/runs_db.py +0 -0
  33. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/lib/test_claude_key_rotation.py +0 -0
  34. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/lib/test_mcp_config.py +0 -0
  35. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/lib/test_runs_db.py +0 -0
  36. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/lib/test_trigger_cron_skills.py +0 -0
  37. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/lib/test_trigger_issue_skills.py +0 -0
  38. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/lib/trigger_aws_sqs_skills.py +0 -0
  39. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/lib/trigger_cron_skills.py +0 -0
  40. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/lib/trigger_email_skills.py +0 -0
  41. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/lib/trigger_issue_skills.py +0 -0
  42. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/mail_server.py +0 -0
  43. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/setup_wizard.py +0 -0
  44. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/start_cli.py +0 -0
  45. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/tasks_providers.py +0 -0
  46. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/templates/AGENTS.md +0 -0
  47. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/templates/CLAUDE.md +0 -0
  48. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/templates/skills/aws-sqs-alarm-response/SKILL.md +0 -0
  49. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/templates/skills/cron-research-5xx-errors/SKILL.md +0 -0
  50. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/templates/skills/story-code-reviewer/SKILL.md +0 -0
  51. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/templates/skills/story-developer/SKILL.md +0 -0
  52. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/templates/skills/story-planner/SKILL.md +0 -0
  53. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/templates/skills/story-planner/assets/readme-template.md +0 -0
  54. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/templates/skills/story-qa/SKILL.md +0 -0
  55. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/templates/skills/story-security-reviewer/SKILL.md +0 -0
  56. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/templates/skills/task-code-reviewer/SKILL.md +0 -0
  57. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/templates/skills/task-developer/SKILL.md +0 -0
  58. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/templates/skills/task-qa/SKILL.md +0 -0
  59. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/templates/skills/task-security-reviewer/SKILL.md +0 -0
  60. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/test_admin_api.py +0 -0
  61. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/test_admin_cli.py +0 -0
  62. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/test_agent_cli.py +0 -0
  63. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/test_coding_agents.py +0 -0
  64. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/test_init_cli.py +0 -0
  65. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/test_memory_index.py +0 -0
  66. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/test_setup_wizard.py +0 -0
  67. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/test_start_cli.py +0 -0
  68. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee/workflow_graph.py +0 -0
  69. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_admin/__init__.py +0 -0
  70. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_admin/codee_admin.py +0 -0
  71. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_agent_abstract/__init__.py +0 -0
  72. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_agent_claude_code/__init__.py +0 -0
  73. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_agent_claude_code/account.py +0 -0
  74. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_agent_claude_code/credentials.py +0 -0
  75. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_agent_claude_code/oauth.py +0 -0
  76. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_agent_claude_code/usage.py +0 -0
  77. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_agent_codex/__init__.py +0 -0
  78. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_agent_github_copilot/__init__.py +0 -0
  79. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_agent_github_copilot/provider.py +0 -0
  80. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_database/__init__.py +0 -0
  81. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_database/claude_code_accounts.py +0 -0
  82. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_database/database.py +0 -0
  83. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_database/oauth_tokens.py +0 -0
  84. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_main_context/__init__.py +0 -0
  85. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_main_context/context.py +0 -0
  86. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_main_context/logging.py +0 -0
  87. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_main_context/test_context.py +0 -0
  88. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_main_context/test_logging.py +0 -0
  89. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_tasks_abstract/__init__.py +0 -0
  90. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_tasks_azure_devops/__init__.py +0 -0
  91. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_tasks_azure_devops/oauth.py +0 -0
  92. {codee_agent-0.6.3 → codee_agent-0.6.5}/src/codee_tasks_jira/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codee-agent
3
- Version: 0.6.3
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.3"
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:
@@ -11,8 +11,8 @@ from codee_agent_claude_code.provider import ClaudeCodeAgent
11
11
  from codee_agent_codex.provider import CodexAgent
12
12
  from codee_agent_github_copilot.provider import GitHubCopilotAgent
13
13
  from codee_main_context.context import (
14
- CodeeMainContext, CodingAgent, STORY_ISSUE_TYPE, Settings,
15
- codee_issue_types, data_dir, load_settings, project_root)
14
+ CodeeMainContext, CodingAgent, Settings, codee_issue_types, data_dir,
15
+ load_settings, project_root)
16
16
  from codee_main_context.logging import configure_logging, get_logger
17
17
  from codee_tasks_abstract.provider import AbstractTasksProvider
18
18
 
@@ -424,12 +424,16 @@ def run_once() -> None:
424
424
  log.info("Incoming %s (%s, %s, %s): %s",
425
425
  task_id, status, issue_type, priority, summary)
426
426
 
427
- # Children of a Codee-owned story are driven by that story's own agent
428
- # run. What marks a story as Codee-owned is the provider's business.
429
- if (issue_type.casefold() != STORY_ISSUE_TYPE
430
- and task.is_parent_codee_story):
431
- log.debug("Skipping %s: parent %s is a Codee story",
432
- task_id, task.parent.key)
427
+ # An item whose parent is one Codee polls in its own right is left
428
+ # alone: the parent's own run is what decides what its children need,
429
+ # and picking the child up here as well is two agents on one change.
430
+ # Its type is what says so — a work item selected by a query of the
431
+ # user's own shields nothing, since nothing in a parent says whether
432
+ # that condition would have claimed it.
433
+ if task.is_parent_codee_work_item:
434
+ log.debug("Skipping %s: its parent %s is a %s, which Codee polls "
435
+ "in its own right",
436
+ task_id, task.parent.key, task.parent.work_item_type)
433
437
  continue
434
438
 
435
439
  skill = match_issue_skill(issue_skills, status, issue_type)
@@ -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:
@@ -12,8 +12,11 @@ from codee_main_context.context import (
12
12
  from codee_tasks_azure_devops.provider import AzureDevOpsTasksProvider
13
13
  from codee_tasks_jira.provider import JiraTasksProvider
14
14
 
15
+ from codee_tasks_abstract.provider import Task
16
+
15
17
  from codee import executor, tasks_providers
16
18
  from codee.lib import runs_db
19
+ from codee.lib.trigger_issue_skills import IssueTriggeredSkill
17
20
 
18
21
 
19
22
  class RefreshConfigTest(unittest.TestCase):
@@ -414,3 +417,84 @@ class _Exploding:
414
417
 
415
418
  if __name__ == "__main__":
416
419
  unittest.main()
420
+
421
+
422
+ def _polled_task(key: str, issue_type: str = "task",
423
+ parent_type: str | None = None) -> Task:
424
+ """One task as a provider hands it over, optionally under a parent.
425
+
426
+ ``codee_work_item_types`` is what every provider fills in from the type
427
+ rows in Settings; here it stands for the default mapping, `story` pointed
428
+ at "Story" and `task` at "Task".
429
+ """
430
+ parent = None
431
+ if parent_type is not None:
432
+ parent = Task(key=f"{key}-parent", summary="The parent",
433
+ status="Active", issue_type=parent_type.casefold(),
434
+ priority="High", work_item_type=parent_type)
435
+ return Task(key=key, summary="Some work", status="Ready",
436
+ issue_type=issue_type, priority="High",
437
+ work_item_type=issue_type.capitalize(), parent=parent,
438
+ codee_work_item_types=frozenset({"story", "task"}))
439
+
440
+
441
+ class ParentWorkItemSkipTest(unittest.TestCase):
442
+ """A poll leaves an item whose parent Codee polls to that parent's run."""
443
+
444
+ def setUp(self) -> None:
445
+ self.provider = Mock()
446
+ self.provider.is_configured.return_value = True
447
+ self.skills = [
448
+ IssueTriggeredSkill(name="Task developer", slug="task-developer",
449
+ path=Path("/repo/.claude/skills/task/SKILL.md"),
450
+ statuses=("Ready",), issue_type="task"),
451
+ IssueTriggeredSkill(name="Story developer", slug="story-developer",
452
+ path=Path("/repo/.claude/skills/story/SKILL.md"),
453
+ statuses=("Ready",), issue_type="story"),
454
+ ]
455
+ for name in ("trigger_cron_skills", "trigger_aws_sqs_skills",
456
+ "trigger_email_skills", "_refresh_config"):
457
+ patcher = patch.object(executor, name)
458
+ self.addCleanup(patcher.stop)
459
+ patcher.start()
460
+ for name, result in (("_pull_latest_code", True),
461
+ ("_load_sessions", {}),
462
+ ("find_issue_triggered_skills", self.skills)):
463
+ patcher = patch.object(executor, name, return_value=result)
464
+ self.addCleanup(patcher.stop)
465
+ patcher.start()
466
+ provider = patch.object(executor, "tasks_provider", self.provider)
467
+ self.addCleanup(provider.stop)
468
+ provider.start()
469
+
470
+ def _submitted(self, *tasks: Task) -> list[str]:
471
+ """The task ids a poll over ``tasks`` actually launched an agent for."""
472
+ self.provider.get_tasks.return_value = list(tasks)
473
+ with patch.object(executor, "_submit_task") as submit:
474
+ executor.run_once()
475
+ return [call.args[0] for call in submit.call_args_list]
476
+
477
+ def test_an_item_under_a_mapped_parent_is_left_to_that_parents_run(self) -> None:
478
+ self.assertEqual(
479
+ self._submitted(_polled_task("NIM-1", parent_type="Story")), [])
480
+
481
+ def test_a_parent_mapped_to_any_work_item_counts(self) -> None:
482
+ # Not only the story: a task under a task is worked by the parent's
483
+ # own run just the same.
484
+ self.assertEqual(
485
+ self._submitted(_polled_task("NIM-2", parent_type="Task")), [])
486
+
487
+ def test_a_story_under_a_mapped_parent_is_skipped_too(self) -> None:
488
+ # The rule is about the parent, not about what the child is.
489
+ self.assertEqual(
490
+ self._submitted(_polled_task("NIM-3", issue_type="story",
491
+ parent_type="Story")), [])
492
+
493
+ def test_an_item_under_an_unmapped_parent_is_worked(self) -> None:
494
+ # An Epic is nothing Codee polls, so nothing else will do this work.
495
+ self.assertEqual(
496
+ self._submitted(_polled_task("NIM-4", parent_type="Epic")),
497
+ ["NIM-4"])
498
+
499
+ def test_an_item_with_no_parent_is_worked(self) -> None:
500
+ self.assertEqual(self._submitted(_polled_task("NIM-5")), ["NIM-5"])
@@ -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``.