fix(ai): resolve workspaceId for ticket URLs and dispatch sendMessage via SSE#263
fix(ai): resolve workspaceId for ticket URLs and dispatch sendMessage via SSE#263amaechiabuah wants to merge 6 commits into
Conversation
… via SSE for streaming agents
Review Summary by QodoResolve workspace ID and dispatch sendMessage via SSE for streaming agents
WalkthroughsDescription• 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 Diagramflowchart 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"]
File Changes1. src/duplo_resource/ai.py
|
Code Review by Qodo
1.
|
☂️ Python Coverage
Overall Coverage
New Files
Modified Files
|
| Raises: | ||
| DuploError: If zero or multiple workspaces match the given name. | ||
| """ | ||
| workspaces_data = self.client.get( |
There was a problem hiding this comment.
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.
| workspace resolver's strict behavior so a duplicate-name backend state | ||
| never silently picks the wrong agent. | ||
| """ | ||
| agents_data = self.client.get( |
There was a problem hiding this comment.
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
| authoritative flag is the metadata entry. | ||
| """ | ||
| resp = self.client.get( | ||
| f"{api_version}/aiservicedesk/admin/data/aiagents/{agent_id}" |
There was a problem hiding this comment.
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.
| 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}" |
There was a problem hiding this comment.
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.
|
|
||
| @Command() | ||
| def send_message(self, | ||
| workspace_name: args.WORKSPACENAME, |
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
Should ultimately be like:
agent = agent_svc.find(name)
agent_id = agent[id]
| 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" |
There was a problem hiding this comment.
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.
| branch on transport. | ||
| """ | ||
| path = ( | ||
| f"{api_version}/aiservicedesk/tickets/" |
There was a problem hiding this comment.
Would also move to ticket resource as the send_streaming_messsage
Maybe we just add --streaming boolean flag to the tickets.send_message
| Returns: | ||
| The streaming response object. | ||
| """ | ||
| return self._request("POST", path, json=data, stream=True, extra_headers=extra_headers) |
There was a problem hiding this comment.
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.
…ources with name-or-id find
Describe Changes
Restructures the AI HelpDesk surface in response to review feedback: instead of one
airesource hiding endpoints behind private helpers, each endpoint is now a first-class resource whosefindresolves by name or--id.workspaceresource —workspace list,workspace find <name>/--id <id>(case-insensitive name match againstGET /aiservicedesk/admin/data/workspaces).agentresource —agent find <name>/--id <id>, plusagent supports_streamingwhich reads the authoritativemetaData.STREAMING_ENABLEDflag (the top-leveldoesSupportStreamingis unreliable on some portals).ticketresource — absorbs the oldai create_ticket/ai send_message:ticket create_ticket --workspace <name>|--workspace-id <id> (--agent_id|--agent_name) [--content ...]— delegates workspace/agent resolution to theworkspace/agentresources (loaded once in__init__).ticket send_messageidentifies the ticket by name or--idand takes--streamingto 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 findlooks a ticket up within a workspace.--streamingis set or the assigned agent advertisesmetaData.STREAMING_ENABLED; SSE goes toPOST /sendMessageStreaming(concatenatingtext_deltachunks), falling back toPOST /sendMessage. Avoids the helpdesk's non-streaming deserializer choking on NDJSON from streaming agents.DuploClient.post()refactor — now takes an optionalheadersarg (merged over auth headers) and forwards**kwargs(e.g.stream=True), replacing the dedicatedstream_post(). Streaming now flows through the same request/exception-handling path as every other verb.airesource and its entry point —workspace/agent/ticketfully replace it.Live-validated against the test24 portal; confirmed the
listandfind --idpayloads are identical for both workspaces and agents (soagent findreturns 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-43189 — https://app.clickup.com/t/8655600/DUPLO-43189Link to Issues
https://app.clickup.com/t/8655600/DUPLO-43089
PR Review Checklist