Skip to content

Commit f6b16d0

Browse files
authored
feat: make StopReason a typed enum per ACP spec (#575)
1 parent b3bbef7 commit f6b16d0

File tree

7 files changed

+66
-10
lines changed

7 files changed

+66
-10
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@frontman/frontman-protocol": minor
3+
"@frontman/client": patch
4+
---
5+
6+
Make StopReason a typed enum per ACP spec instead of a raw string. Defines the 5 ACP-specified values (end_turn, max_tokens, max_turn_requests, refusal, cancelled) as a closed variant type in the protocol layer, with corresponding Elixir module attributes and guard clauses on the server side.

apps/frontman_server/lib/agent_client_protocol.ex

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,32 @@ defmodule AgentClientProtocol do
3939

4040
@plan_statuses [@plan_status_pending, @plan_status_in_progress, @plan_status_completed]
4141

42+
# Stop reason constants — the single source of truth for ACP wire values.
43+
@stop_reason_end_turn "end_turn"
44+
@stop_reason_max_tokens "max_tokens"
45+
@stop_reason_max_turn_requests "max_turn_requests"
46+
@stop_reason_refusal "refusal"
47+
@stop_reason_cancelled "cancelled"
48+
49+
@stop_reasons [
50+
@stop_reason_end_turn,
51+
@stop_reason_max_tokens,
52+
@stop_reason_max_turn_requests,
53+
@stop_reason_refusal,
54+
@stop_reason_cancelled
55+
]
56+
4257
def tool_call_status_pending, do: @tool_call_status_pending
4358
def tool_call_status_in_progress, do: @tool_call_status_in_progress
4459
def tool_call_status_completed, do: @tool_call_status_completed
4560
def tool_call_status_failed, do: @tool_call_status_failed
4661

62+
def stop_reason_end_turn, do: @stop_reason_end_turn
63+
def stop_reason_max_tokens, do: @stop_reason_max_tokens
64+
def stop_reason_max_turn_requests, do: @stop_reason_max_turn_requests
65+
def stop_reason_refusal, do: @stop_reason_refusal
66+
def stop_reason_cancelled, do: @stop_reason_cancelled
67+
4768
def protocol_version, do: @protocol_version
4869

4970
def agent_info do
@@ -115,7 +136,7 @@ defmodule AgentClientProtocol do
115136
@doc """
116137
Builds a session/prompt response with stop reason.
117138
"""
118-
def build_prompt_result(stop_reason) do
139+
def build_prompt_result(stop_reason) when stop_reason in @stop_reasons do
119140
%{"stopReason" => stop_reason}
120141
end
121142

apps/frontman_server/lib/frontman_server_web/channels/task_channel.ex

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -491,7 +491,9 @@ defmodule FrontmanServerWeb.TaskChannel do
491491
{:noreply, socket}
492492

493493
id ->
494-
response = JsonRpc.success_response(id, ACP.build_prompt_result("end_turn"))
494+
response =
495+
JsonRpc.success_response(id, ACP.build_prompt_result(ACP.stop_reason_end_turn()))
496+
495497
Logger.info("Pushing prompt response with id=#{id}")
496498
push(socket, "acp:message", response)
497499

@@ -510,7 +512,9 @@ defmodule FrontmanServerWeb.TaskChannel do
510512
{:noreply, socket}
511513

512514
id ->
513-
response = JsonRpc.success_response(id, ACP.build_prompt_result("cancelled"))
515+
response =
516+
JsonRpc.success_response(id, ACP.build_prompt_result(ACP.stop_reason_cancelled()))
517+
514518
push(socket, "acp:message", response)
515519
socket = assign(socket, :pending_prompt_id, nil)
516520
{:noreply, socket}

apps/frontman_server/test/protocols/acp_contract_test.exs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ defmodule FrontmanServer.Protocols.AcpContractTest do
1919

2020
describe "AgentClientProtocol.build_prompt_result/1" do
2121
test "validates against acp/promptResult schema" do
22-
payload = AgentClientProtocol.build_prompt_result("completed")
22+
payload =
23+
AgentClientProtocol.build_prompt_result(AgentClientProtocol.stop_reason_end_turn())
24+
2325
ProtocolSchema.validate!(payload, "acp/promptResult")
2426
end
2527
end

libs/client/src/state/Client__State__StateReducer.res

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -581,8 +581,7 @@ let sendMessageToAPIImpl = (
581581
// reopening a Completed message as Streaming permanently.
582582
Client__TextDeltaBuffer.flush()
583583
switch result {
584-
| Ok({stopReason})
585-
if stopReason == "cancelled" => // CancelTurn already cleaned up state - don't dispatch TurnCompleted
584+
| Ok({stopReason: Cancelled}) => // CancelTurn already cleaned up state - don't dispatch TurnCompleted
586585
()
587586
| Ok(_) => dispatch(TaskAction({target: ForTask(taskId), action: TurnCompleted}))
588587
| Error(_) => dispatch(TaskAction({target: ForTask(taskId), action: TurnCompleted}))

libs/frontman-protocol/schemas/acp/promptResult.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@
22
"type": "object",
33
"properties": {
44
"stopReason": {
5-
"type": "string"
5+
"enum": [
6+
"end_turn",
7+
"max_tokens",
8+
"max_turn_requests",
9+
"refusal",
10+
"cancelled"
11+
]
612
}
713
},
814
"additionalProperties": true,

libs/frontman-protocol/src/FrontmanProtocol__ACP.res

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -296,13 +296,31 @@ let toolCallStatusSchema = S.union([
296296
S.literal(Failed),
297297
])
298298

299+
// Stop reason (per ACP spec)
300+
type stopReason =
301+
| @as("end_turn") EndTurn
302+
| @as("max_tokens") MaxTokens
303+
| @as("max_turn_requests") MaxTurnRequests
304+
| @as("refusal") Refusal
305+
| @as("cancelled") Cancelled
306+
307+
let stopReasonSchema = S.union([
308+
S.literal(EndTurn),
309+
S.literal(MaxTokens),
310+
S.literal(MaxTurnRequests),
311+
S.literal(Refusal),
312+
S.literal(Cancelled),
313+
])
314+
299315
// session/prompt result
300-
@schema
301316
type promptResult = {
302-
@as("stopReason")
303-
stopReason: string,
317+
stopReason: stopReason,
304318
}
305319

320+
let promptResultSchema = S.object(s => {
321+
stopReason: s.field("stopReason", stopReasonSchema),
322+
})
323+
306324
// Plan entry priority (per ACP spec)
307325
type planEntryPriority =
308326
| @as("high") High

0 commit comments

Comments
 (0)