Skip to content

fix(ai): resolve workspaceId for ticket URLs and dispatch sendMessage via SSE#263

Open
amaechiabuah wants to merge 6 commits into
mainfrom
fix/ai-helpdesk-create-ticket-payload
Open

fix(ai): resolve workspaceId for ticket URLs and dispatch sendMessage via SSE#263
amaechiabuah wants to merge 6 commits into
mainfrom
fix/ai-helpdesk-create-ticket-payload

Conversation

@amaechiabuah

@amaechiabuah amaechiabuah commented May 21, 2026

Copy link
Copy Markdown
Collaborator

Describe Changes

Restructures the AI HelpDesk surface in response to review feedback: instead of one ai resource hiding endpoints behind private helpers, each endpoint is now a first-class resource whose find resolves by name or --id.

  • New workspace resourceworkspace list, workspace find <name> / --id <id> (case-insensitive name match against GET /aiservicedesk/admin/data/workspaces).
  • New agent resourceagent find <name> / --id <id>, plus agent supports_streaming which reads the authoritative metaData.STREAMING_ENABLED flag (the top-level doesSupportStreaming is unreliable on some portals).
  • New ticket resource — absorbs the old ai create_ticket / ai send_message:
    • ticket create_ticket --workspace <name>|--workspace-id <id> (--agent_id|--agent_name) [--content ...] — delegates workspace/agent resolution to the workspace/agent resources (loaded once in __init__).
    • ticket send_message identifies the ticket by name or --id and takes --streaming to force the SSE endpoint. The message is passed via --content (inline) or stdin with -f - (echo "msg" | duploctl ticket send_message … -f -); message text is taken verbatim (no YAML/JSON parsing) so characters like : are preserved.
    • ticket find looks a ticket up within a workspace.
    • Streaming dispatch: used when --streaming is set or the assigned agent advertises metaData.STREAMING_ENABLED; SSE goes to POST /sendMessageStreaming (concatenating text_delta chunks), falling back to POST /sendMessage. Avoids the helpdesk's non-streaming deserializer choking on NDJSON from streaming agents.
  • DuploClient.post() refactor — now takes an optional headers arg (merged over auth headers) and forwards **kwargs (e.g. stream=True), replacing the dedicated stream_post(). Streaming now flows through the same request/exception-handling path as every other verb.
  • Removed the ai resource and its entry point — workspace/agent/ticket fully replace it.

Live-validated against the test24 portal; confirmed the list and find --id payloads are identical for both workspaces and agents (so agent find returns the matching list entry directly — no second fetch).

Follow-up

The full CRUD + lifecycle operations the backend already exposes (delete, create/update via -f, ticket close, ticket reassign, workspace add_agent, ticket list, etc.) are scoped into a separate ticket for its own PR to keep this one focused: DUPLO-43189https://app.clickup.com/t/8655600/DUPLO-43189

Link to Issues

https://app.clickup.com/t/8655600/DUPLO-43089

PR Review Checklist

  • Thoroughly reviewed on local machine.
  • Have you added any tests
  • Make sure to note changes in Changelog

@zafarabbas

Copy link
Copy Markdown
Contributor

@qodo-code-review

Copy link
Copy Markdown
Contributor

Review Summary by Qodo

Resolve workspace ID and dispatch sendMessage via SSE for streaming agents

🐞 Bug fix ✨ Enhancement

Grey Divider

Walkthroughs

Description
• Replace TenantId with workspace ID resolution for ticket URLs and payloads
• Add agent streaming detection via metaData.STREAMING_ENABLED flag
  - Dispatch to SSE /sendMessageStreaming endpoint for streaming agents
  - Fall back to unary /sendMessage for non-streaming agents
  - Concatenate SSE text_delta chunks into response content
• Replace --instance_id with --agent_id (preferred) or --agent_name (resolved via API)
• Add --workspace_name parameter (required) resolved to workspace ID via workspaces lookup
• Implement case-insensitive workspace and agent name matching
Diagram
flowchart LR
  A["User Input<br/>workspace_name<br/>agent_id/agent_name"] -->|resolve| B["Workspace ID<br/>via GET /workspaces"]
  A -->|resolve| C["Agent ID<br/>via GET /aiagents"]
  B --> D["POST /tickets<br/>with workspace_id"]
  D --> E["Ticket Created"]
  E -->|if content| F["Check Agent<br/>STREAMING_ENABLED"]
  F -->|true| G["POST /sendMessageStreaming<br/>SSE stream"]
  F -->|false| H["POST /sendMessage<br/>unary call"]
  G -->|concatenate| I["AI Response"]
  H --> I
  I --> J["Return ticket_name<br/>chat_url<br/>ai_response"]

Loading

File Changes

1. src/duplo_resource/ai.py ✨ Enhancement +254/-45

Workspace ID resolution and SSE streaming support

• Add workspace ID resolution via _resolve_workspace_id() with case-insensitive matching
• Add agent ID resolution via _resolve_agent_id() with case-insensitive matching
• Add agent streaming capability detection via _agent_supports_streaming() checking
 metaData.STREAMING_ENABLED
• Refactor create_ticket() to accept --workspace_name and --agent_id/--agent_name instead of
 --instance_id
• Refactor send_message() to accept --workspace_name and resolve workspace/agent IDs
• Add _send_message_to_workspace() dispatcher that routes to streaming or non-streaming endpoint
• Add _send_message_streaming() to consume SSE events and concatenate text_delta chunks
• Add _send_message_non_streaming() for unary sendMessage calls
• Add _agent_id_from_ticket() to fetch ticket and extract assigned agent ID
• Add _chat_url() helper to build chat URLs with workspace ID
• Import json and requests for SSE parsing and direct HTTP streaming
• Update error handling to use DuploConnectionError for streaming timeouts

src/duplo_resource/ai.py


2. src/duplocloud/args.py ⚙️ Configuration changes +11/-5

Add workspace and agent ID arguments

• Change AGENTNAME from required to optional with default None
• Add new AGENTID argument for direct agent ID specification (preferred over name lookup)
• Replace INSTANCEID argument with WORKSPACENAME argument (required)
• Update help text to reflect new workspace and agent resolution behavior

src/duplocloud/args.py


3. src/tests/test_ai.py 🧪 Tests +425/-3

Add comprehensive unit and integration tests

• Add comprehensive unit tests for workspace ID resolution with case-insensitive matching
• Add unit tests for agent ID resolution and agent preference over agent name
• Add unit tests for streaming vs non-streaming agent detection
• Add unit tests for SSE event parsing and text_delta concatenation
• Add unit tests for error handling (HTTP errors, SSE errors, timeouts)
• Add unit tests for origin parameter defaults and custom values
• Update integration tests to use workspace_name instead of instance_id
• Add mock fixtures for workspace/agent/ticket responses

src/tests/test_ai.py


View more (2)
4. CHANGELOG.md 📝 Documentation +3/-0

Document workspace ID and streaming changes

• Document removal of --instance_id and addition of --agent_id/--agent_name parameters
• Document requirement for --workspace_name and workspace ID resolution behavior
• Document SSE streaming support with agent metadata flag detection
• Document fallback to non-streaming endpoint for non-streaming agents

CHANGELOG.md


5. src/tests/data/ticket.yaml ⚙️ Configuration changes +1/-1

Update test data for workspace name

• Replace instance_id: "pytest-instance" with workspace_name: "pytest-workspace"

src/tests/data/ticket.yaml


Grey Divider

Qodo Logo

@qodo-code-review

qodo-code-review Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. _send_message_streaming uses requests.post ✓ Resolved 📘 Rule violation ☼ Reliability
Description
The new streaming sendMessage path calls requests.post(..., stream=True) directly and only
translates Timeout/ConnectionError, so other requests failures can leak as raw exceptions and
bypass the shared client’s unified response validation. This breaks the requirement to route HTTP
verbs/transports through the common request/exception-handling layer and makes streaming behavior
inconsistent with non-streaming calls.
Code

src/duplo_resource/ai.py[R316-348]

Evidence
Compliance ID 4 requires HTTP handling to use shared request handling across verbs/transports and to
translate network exceptions consistently, particularly for streaming. The added
_send_message_streaming() issues a raw requests.post(..., stream=True) and only catches
Timeout and ConnectionError, meaning other requests.exceptions.RequestException cases (e.g.,
SSL errors, invalid URL, chunked encoding errors) can surface unwrapped; in contrast, the shared
DuploAPI._request path catches Timeout, ConnectionError, and any other RequestException,
maps them to DuploConnectionError, and centralizes response status validation via
_validate_response(), demonstrating the inconsistency.

src/duplo_resource/ai.py[316-348]
src/duplo_resource/ai.py[284-348]
src/duplocloud/client.py[75-90]
src/duplocloud/client.py[148-166]
Best Practice: Learned patterns

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`_send_message_streaming()` bypasses the shared `DuploAPI` HTTP client wrapper by calling `requests.post(..., stream=True)` directly, and it only converts `Timeout`/`ConnectionError` into `DuploConnectionError`; as a result, other `requests` network failures can leak as raw exceptions and streaming responses can bypass the centralized response validation/mapping used elsewhere.
## Issue Context
Compliance requires consistent network exception translation across HTTP verbs and transports, with streaming requests still following the project’s unified request/exception handling pattern. The existing client wrapper (`DuploAPI._request`) centrally builds the portal URL, injects auth headers, applies timeout, converts any `requests.exceptions.RequestException` into `DuploConnectionError`, and normalizes error handling via `_validate_response()`; the streaming path should preserve these guarantees while still supporting `stream=True` and `Accept: text/event-stream`.
## Fix Focus Areas
- src/duplo_resource/ai.py[284-348]
- src/duplocloud/client.py[75-90]
- src/duplocloud/client.py[148-166]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. _resolve_workspace_id may return None ✓ Resolved 📘 Rule violation ≡ Correctness
Description
_resolve_workspace_id() returns matches[0].get("id") without validating presence/type, so a
malformed/partial admin-data response can propagate None into the ticket URL/payload and fail
later with unclear errors. This violates the requirement to validate optional/variable-shaped inputs
early and raise a clear DuploError.
Code

src/duplo_resource/ai.py[R35-45]

Evidence
PR Compliance ID 2 requires early validation of optional/variable-shaped inputs and clear
DuploErrors for invalid shapes. The new code returns matches[0].get("id") without checking it
exists/is valid, so missing id will silently become None and cause later errors.

src/duplo_resource/ai.py[35-45]
Best Practice: Learned patterns

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`_resolve_workspace_id()` (and similarly `_resolve_agent_id()`) returns the `id` field without validating that it exists and is a valid string, which can lead to downstream malformed URLs/payloads and non-obvious failures.
## Issue Context
Admin data endpoints may return unexpected/malformed shapes (missing `id`, `items` not a list, etc.). Per compliance, the code should validate shapes early and raise a clear `DuploError`.
## Fix Focus Areas
- src/duplo_resource/ai.py[19-57]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Unconditional .json() on responses ✓ Resolved 📘 Rule violation ☼ Reliability
Description
Multiple new calls unconditionally invoke .json() on HTTP responses without guarding for empty/204
bodies, which can raise JSON parsing exceptions at runtime. This violates the requirement to safely
handle JSON parsing for empty/204 responses.
Code

src/duplo_resource/ai.py[R32-35]

Evidence
PR Compliance ID 4 requires guarding JSON parsing for 204/empty responses. The newly added code
calls .json() directly on multiple GET/POST responses without checking status code or body
content, risking runtime failures when the body is empty.

src/duplo_resource/ai.py[32-35]
src/duplo_resource/ai.py[153-156]
src/duplo_resource/ai.py[282-282]
Best Practice: Learned patterns

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
New helpdesk/workspace/agent/ticket requests call `.json()` unconditionally, which can fail on HTTP 204 or empty response bodies.
## Issue Context
Compliance requires safe JSON parsing behavior for empty/204 responses (returning a safe empty object or handling it explicitly) rather than always calling `.json()`.
## Fix Focus Areas
- src/duplo_resource/ai.py[32-35]
- src/duplo_resource/ai.py[49-52]
- src/duplo_resource/ai.py[153-156]
- src/duplo_resource/ai.py[240-243]
- src/duplo_resource/ai.py[282-282]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Unescaped lookup parameters ✓ Resolved 🐞 Bug ≡ Correctness
Description
Workspace/agent name resolution interpolates user input directly into the query string, so names
containing spaces or characters like '&' can break the request or alter query parameters. This can
make lookups fail or resolve unintended resources.
Code

src/duplo_resource/ai.py[R32-51]

Evidence
The lookup helpers embed raw names into the URL. The shared HTTP client then concatenates that path
into a URL string for requests.request without encoding, so special characters are not safely
handled unless encoded beforehand.

src/duplo_resource/ai.py[19-57]
src/duplocloud/client.py[75-83]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`_resolve_workspace_id()` and `_resolve_agent_id()` build URLs like `...?filters[name]={workspace_name}` / `...?filters[name]={agent_name}` without URL-encoding the value. Because the Duplo client passes the constructed string directly into `requests.request(url=f"{host}/{path}")`, special characters in names can produce malformed URLs or unintended query parameters.
## Issue Context
These values are user-controlled CLI inputs (`--workspace_name`, `--agent_name`). Encoding should happen client-side to ensure correct behavior for names with spaces and to prevent query-string parameter injection.
## Fix Focus Areas
- src/duplo_resource/ai.py[19-57]
- src/duplocloud/client.py[75-83]
## Suggested fix
- Use `urllib.parse.quote_plus()` (or similar) on `workspace_name` / `agent_name` when embedding into the URL string, OR refactor the client to accept a `params` dict and let `requests` encode it.
- (Optional hardening) Validate the resolved `id` is non-empty before returning it, and raise `DuploError` if missing.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
5. Agent name ambiguity ✓ Resolved 🐞 Bug ≡ Correctness
Description
_resolve_agent_id() returns the first case-insensitive match and does not error when multiple
agents have the same name, potentially selecting the wrong agent ID. _resolve_workspace_id()
already guards against this ambiguity, but agent resolution does not.
Code

src/duplo_resource/ai.py[R52-57]

Evidence
Workspace lookup explicitly errors on multiple matches, but agent lookup does not—showing an
inconsistency that can lead to silent wrong selection if duplicates are returned.

src/duplo_resource/ai.py[19-57]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`_resolve_agent_id()` uses `next(...)` to pick the first matching agent by name and does not detect duplicate exact (case-insensitive) matches. If duplicates exist, ticket creation may silently assign to an unintended agent.
## Issue Context
Workspace name resolution already implements a safer pattern: collect matches, error on 0, error on >1. Agent resolution should mirror that to avoid nondeterministic behavior.
## Fix Focus Areas
- src/duplo_resource/ai.py[35-57]
## Suggested fix
- Replace the `next(...)` selection with a `matches = [...]` list.
- Raise `DuploError` when `len(matches) == 0` or `len(matches) > 1`.
- Return `matches[0]["id"]` (with a defensive check that `id` exists).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

@duploctl

duploctl Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

☂️ Python Coverage

current status: ✅

Overall Coverage

Lines Covered Coverage Threshold Status
3931 1663 42% 0% 🟢

New Files

File Coverage Status
src/duplo_resource/agent.py 91% 🟢
src/duplo_resource/ticket.py 90% 🟢
src/duplo_resource/workspace.py 97% 🟢
TOTAL 93% 🟢

Modified Files

File Coverage Status
src/duplocloud/args.py 100% 🟢
src/duplocloud/argtype.py 75% 🟢
src/duplocloud/client.py 96% 🟢
TOTAL 90% 🟢

updated for commit: 9cb8df3 by action🐍

Comment thread src/duplo_resource/ai.py Outdated
Comment thread src/duplo_resource/ai.py Outdated
Raises:
DuploError: If zero or multiple workspaces match the given name.
"""
workspaces_data = self.client.get(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should actually be a command because we cdon't want to hide endpoints. The cli purpoise is to expose them and then build on them.

Comment thread src/duplo_resource/ai.py Outdated
workspace resolver's strict behavior so a duplicate-name backend state
never silently picks the wrong agent.
"""
agents_data = self.client.get(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, make an agent resource for this endpoint.

wksp_svc = duplo.load("workspace")
agent_svc = duplo.load("agent")

wksp wksp_svc.find(my_workspace)
id = wksp.id

agent = agent_svc.find(my_workspace)
id = agent.id

bash usage

duploctl workspace find my_workspace 
duploctl agent find my_agent

Comment thread src/duplo_resource/ai.py Outdated
authoritative flag is the metadata entry.
"""
resp = self.client.get(
f"{api_version}/aiservicedesk/admin/data/aiagents/{agent_id}"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For agents we can have a way to optionally send in id or name.

when finding one:

  • if id is given hit the endpoint directly
  • if name is given find it in the list
duploctl agent find my_agent
or
duploctl agent find --id 1234

Ensure the agent object returned in a list is the same object as when you request a single one.

Comment thread src/duplo_resource/ai.py Outdated
api_version: str) -> str:
"""Fetch the ticket and return its assigned ``aiAgentId``."""
resp = self.client.get(
f"{api_version}/aiservicedesk/tickets/{workspace_id}/{ticket_id}"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We would need a ticket resource so we could do:

duploctl ticket find <name> --id <id> --workspace <name> --workspace-id <id>

when finding workspace in ticket find:

wksp.find(wksp_name, wksp_id)

Find would handle it's own logic for if name find by name or else id find by id.
Each find handles this on their own so other callers can pass through the same args.

Comment thread src/duplo_resource/ai.py Outdated

@Command()
def send_message(self,
workspace_name: args.WORKSPACENAME,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should just use args.NAME
The NAME arg is very special as it is the only optional positional so we can optionally pass in --id without requiring name first nor needing to make a --name flag.

Comment thread src/duplo_resource/ai.py Outdated
api_version = api_version.strip().lower()
tenant_id = self.tenant_id
workspace_id = self._resolve_workspace_id(workspace_name, api_version)
agent_id = self._agent_id_from_ticket(workspace_id, ticket_id, api_version)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should ultimately be like:

agent = agent_svc.find(name)
agent_id = agent[id]

Comment thread src/duplo_resource/ai.py Outdated
content: str,
api_version: str) -> dict:
"""POST to the unary sendMessage endpoint and return the JSON reply."""
path = f"{api_version}/aiservicedesk/tickets/{workspace_id}/{ticket_id}/sendMessage"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this would just go in the ticket resource and be like:

echo "the message" | duploctl ticket send_message --id 123 --wksp foospace -f -

The content param can simply be args.BODY and we will get the -f - out of the box.

Comment thread src/duplo_resource/ai.py Outdated
branch on transport.
"""
path = (
f"{api_version}/aiservicedesk/tickets/"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would also move to ticket resource as the send_streaming_messsage

Maybe we just add --streaming boolean flag to the tickets.send_message

Comment thread src/duplocloud/client.py Outdated
Returns:
The streaming response object.
"""
return self._request("POST", path, json=data, stream=True, extra_headers=extra_headers)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead the base POST should simply accept an optional headers argument that would be merged in with it's own. Then we can pass kwargs** to collect anything extra we would want to add to the base request method.

@zafarabbas

Copy link
Copy Markdown
Contributor

@amaechiabuah amaechiabuah requested a review from kferrone June 18, 2026 15:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants