Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -1020,7 +1020,7 @@ Completion conditions:
- Targeted-protocol turn completion signal -> success
- Targeted-protocol turn failure signal -> failure
- Targeted-protocol turn cancellation signal -> failure
- turn timeout (`turn_timeout_ms`) -> failure
- turn stream silence timeout (`turn_timeout_ms`) -> failure
- subprocess exit -> failure

Continuation processing:
Expand Down Expand Up @@ -1143,7 +1143,8 @@ User-input-required policy:
Timeouts:

- `codex.read_timeout_ms`: request/response timeout during startup and sync requests
- `codex.turn_timeout_ms`: total turn stream timeout
- `codex.turn_timeout_ms`: maximum silence interval while a turn stream is active; each
app-server output resets it, so it is not a total turn runtime cap
- `codex.stall_timeout_ms`: enforced by orchestrator based on event inactivity

Error mapping (RECOMMENDED normalized categories):
Expand Down
2 changes: 2 additions & 0 deletions elixir/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ Notes:
- `codex.approval_policy` defaults to `{"reject":{"sandbox_approval":true,"rules":true,"mcp_elicitations":true}}`
- `codex.thread_sandbox` defaults to `workspace-write`
- `codex.turn_sandbox_policy` defaults to a `workspaceWrite` policy rooted at the current issue workspace
- `codex.turn_timeout_ms` is the maximum silence interval while a turn is streaming. Each
app-server update resets it; it is not a total turn runtime cap.
- Supported `codex.approval_policy` values depend on the targeted Codex app-server version. In the current local Codex schema, string values include `untrusted`, `on-failure`, `on-request`, and `never`, and object-form `reject` is also supported.
- Supported `codex.thread_sandbox` values: `read-only`, `workspace-write`, `danger-full-access`.
- When `codex.turn_sandbox_policy` is set explicitly, Symphony passes the map through to Codex
Expand Down
102 changes: 17 additions & 85 deletions elixir/lib/symphony_elixir/codex/app_server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ defmodule SymphonyElixir.Codex.AppServer do
@turn_start_id 3
@port_line_bytes 1_048_576
@max_stream_log_bytes 1_000
@non_interactive_tool_input_answer "This is a non-interactive session. Operator input is unavailable."

@type session :: %{
port: port(),
metadata: map(),
Expand Down Expand Up @@ -819,38 +817,21 @@ defmodule SymphonyElixir.Codex.AppServer do
:approved

:error ->
reply_with_non_interactive_tool_input_answer(
port,
id,
params,
payload,
payload_string,
on_message,
metadata
)
:input_required
end
end

defp maybe_auto_answer_tool_request_user_input(
port,
id,
params,
payload,
payload_string,
on_message,
metadata,
_port,
_id,
_params,
_payload,
_payload_string,
_on_message,
_metadata,
false
) do
reply_with_non_interactive_tool_input_answer(
port,
id,
params,
payload,
payload_string,
on_message,
metadata
)
end
),
do: :input_required

defp tool_request_user_input_approval_answers(%{"questions" => questions}) when is_list(questions) do
answers =
Expand All @@ -873,64 +854,15 @@ defmodule SymphonyElixir.Codex.AppServer do

defp tool_request_user_input_approval_answers(_params), do: :error

defp reply_with_non_interactive_tool_input_answer(
port,
id,
params,
payload,
payload_string,
on_message,
metadata
) do
case tool_request_user_input_unavailable_answers(params) do
{:ok, answers} ->
send_message(port, %{"id" => id, "result" => %{"answers" => answers}})

emit_message(
on_message,
:tool_input_auto_answered,
%{payload: payload, raw: payload_string, answer: @non_interactive_tool_input_answer},
metadata
)

:approved

:error ->
:input_required
end
end

defp tool_request_user_input_unavailable_answers(%{"questions" => questions}) when is_list(questions) do
answers =
Enum.reduce_while(questions, %{}, fn question, acc ->
case tool_request_user_input_question_id(question) do
{:ok, question_id} ->
{:cont, Map.put(acc, question_id, %{"answers" => [@non_interactive_tool_input_answer]})}

:error ->
{:halt, :error}
end
end)

case answers do
:error -> :error
answer_map when map_size(answer_map) > 0 -> {:ok, answer_map}
_ -> :error
end
end

defp tool_request_user_input_unavailable_answers(_params), do: :error

defp tool_request_user_input_question_id(%{"id" => question_id}) when is_binary(question_id),
do: {:ok, question_id}

defp tool_request_user_input_question_id(_question), do: :error

defp tool_request_user_input_approval_answer(%{"id" => question_id, "options" => options})
when is_binary(question_id) and is_list(options) do
case tool_request_user_input_approval_option_label(options) do
nil -> :error
answer_label -> {:ok, question_id, answer_label}
if String.starts_with?(question_id, "mcp_tool_call_approval_") do
case tool_request_user_input_approval_option_label(options) do
nil -> :error
answer_label -> {:ok, question_id, answer_label}
end
else
:error
end
end

Expand Down
12 changes: 0 additions & 12 deletions elixir/lib/symphony_elixir/status_dashboard.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1125,18 +1125,6 @@ defmodule SymphonyElixir.StatusDashboard do
if is_binary(decision), do: "#{base}: #{decision}", else: base
end

defp humanize_codex_event(:tool_input_auto_answered, message, payload) do
answer = map_value(message, ["answer", :answer])

base =
case humanize_codex_method("item/tool/requestUserInput", payload) do
nil -> "tool input auto-answered"
text -> "#{text} (auto-answered)"
end

if is_binary(answer), do: "#{base}: #{inline_text(answer)}", else: base
end

defp humanize_codex_event(:tool_call_completed, _message, payload),
do: humanize_dynamic_tool_event("dynamic tool call completed", payload)

Expand Down
151 changes: 102 additions & 49 deletions elixir/test/symphony_elixir/app_server_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,95 @@ defmodule SymphonyElixir.AppServerTest do
end
end

test "turn timeout resets on stream updates and fires after silence" do
test_root =
Path.join(
System.tmp_dir!(),
"symphony-elixir-app-server-turn-timeout-#{System.unique_integer([:positive])}"
)

try do
workspace_root = Path.join(test_root, "workspaces")
workspace = Path.join(workspace_root, "MT-TIMEOUT")
codex_binary = Path.join(test_root, "fake-codex")
File.mkdir_p!(workspace)

File.write!(codex_binary, """
#!/bin/sh
count=0
while IFS= read -r _line; do
count=$((count + 1))
case "$count" in
1) printf '%s\n' '{"id":1,"result":{}}' ;;
2) ;;
3) printf '%s\n' '{"id":2,"result":{"thread":{"id":"thread-timeout"}}}' ;;
4)
printf '%s\n' '{"id":3,"result":{"turn":{"id":"turn-timeout"}}}'
sleep 0.15
printf '%s\n' '{"method":"item/updated","params":{"item":{"id":"one"}}}'
sleep 0.15
printf '%s\n' '{"method":"item/updated","params":{"item":{"id":"two"}}}'
sleep 0.15
printf '%s\n' '{"method":"turn/completed"}'
exit 0
;;
*) exit 0 ;;
esac
done
""")

File.chmod!(codex_binary, 0o755)

write_workflow_file!(Workflow.workflow_file_path(),
workspace_root: workspace_root,
codex_command: "#{codex_binary} app-server",
codex_turn_timeout_ms: 250
)

issue = %Issue{
id: "issue-turn-timeout",
identifier: "MT-TIMEOUT",
title: "Stream timeout",
description: "Keep active streams alive",
state: "In Progress",
url: "https://example.org/issues/MT-TIMEOUT",
labels: ["backend"]
}

assert {:ok, _result} = AppServer.run(workspace, "stream updates", issue)

File.write!(codex_binary, """
#!/bin/sh
count=0
while IFS= read -r _line; do
count=$((count + 1))
case "$count" in
1) printf '%s\n' '{"id":1,"result":{}}' ;;
2) ;;
3) printf '%s\n' '{"id":2,"result":{"thread":{"id":"thread-silent"}}}' ;;
4)
printf '%s\n' '{"id":3,"result":{"turn":{"id":"turn-silent"}}}'
sleep 0.4
printf '%s\n' '{"method":"turn/completed"}'
exit 0
;;
*) exit 0 ;;
esac
done
""")

write_workflow_file!(Workflow.workflow_file_path(),
workspace_root: workspace_root,
codex_command: "#{codex_binary} app-server",
codex_turn_timeout_ms: 100
)

assert {:error, :turn_timeout} = AppServer.run(workspace, "silent turn", issue)
after
File.rm_rf(test_root)
end
end

test "app server passes explicit turn sandbox policies through unchanged" do
test_root =
Path.join(
Expand Down Expand Up @@ -626,7 +715,7 @@ defmodule SymphonyElixir.AppServerTest do
end
end

test "app server sends a generic non-interactive answer for freeform tool input prompts" do
test "app server blocks freeform tool input prompts" do
test_root =
Path.join(
System.tmp_dir!(),
Expand Down Expand Up @@ -687,22 +776,16 @@ defmodule SymphonyElixir.AppServerTest do
labels: ["backend"]
}

on_message = fn message -> send(self(), {:app_server_message, message}) end

assert {:ok, _result} =
AppServer.run(workspace, "Handle generic tool input", issue, on_message: on_message)
assert {:error, {:turn_input_required, payload}} =
AppServer.run(workspace, "Handle generic tool input", issue)

assert_received {:app_server_message,
%{
event: :tool_input_auto_answered,
answer: "This is a non-interactive session. Operator input is unavailable."
}}
assert payload["method"] == "item/tool/requestUserInput"
after
File.rm_rf(test_root)
end
end

test "app server sends a generic non-interactive answer for option-based tool input prompts" do
test "app server blocks option-based tool input prompts" do
test_root =
Path.join(
System.tmp_dir!(),
Expand All @@ -713,27 +796,13 @@ defmodule SymphonyElixir.AppServerTest do
workspace_root = Path.join(test_root, "workspaces")
workspace = Path.join(workspace_root, "MT-719")
codex_binary = Path.join(test_root, "fake-codex")
trace_file = Path.join(test_root, "codex-tool-user-input-options.trace")
previous_trace = System.get_env("SYMP_TEST_CODEx_TRACE")

on_exit(fn ->
if is_binary(previous_trace) do
System.put_env("SYMP_TEST_CODEx_TRACE", previous_trace)
else
System.delete_env("SYMP_TEST_CODEx_TRACE")
end
end)

System.put_env("SYMP_TEST_CODEx_TRACE", trace_file)
File.mkdir_p!(workspace)

File.write!(codex_binary, """
#!/bin/sh
trace_file="${SYMP_TEST_CODEx_TRACE:-/tmp/codex-tool-user-input-options.trace}"
count=0
while IFS= read -r line; do
while IFS= read -r _line; do
count=$((count + 1))
printf 'JSON:%s\\n' \"$line\" >> \"$trace_file\"

case \"$count\" in
1)
Expand All @@ -746,7 +815,7 @@ defmodule SymphonyElixir.AppServerTest do
;;
4)
printf '%s\\n' '{\"id\":3,\"result\":{\"turn\":{\"id\":\"turn-719\"}}}'
printf '%s\\n' '{\"id\":112,\"method\":\"item/tool/requestUserInput\",\"params\":{\"itemId\":\"call-719\",\"questions\":[{\"header\":\"Choose an action\",\"id\":\"options-719\",\"isOther\":false,\"isSecret\":false,\"options\":[{\"description\":\"Use the default behavior.\",\"label\":\"Use default\"},{\"description\":\"Skip this step.\",\"label\":\"Skip\"}],\"question\":\"How should I proceed?\"}],\"threadId\":\"thread-719\",\"turnId\":\"turn-719\"}}'
printf '%s\\n' '{\"id\":112,\"method\":\"item/tool/requestUserInput\",\"params\":{\"itemId\":\"call-719\",\"questions\":[{\"header\":\"Choose an action\",\"id\":\"options-719\",\"isOther\":false,\"isSecret\":false,\"options\":[{\"description\":\"Proceed with the requested action.\",\"label\":\"Allow\"},{\"description\":\"Do not proceed.\",\"label\":\"Deny\"}],\"question\":\"How should I proceed?\"}],\"threadId\":\"thread-719\",\"turnId\":\"turn-719\"}}'
;;
5)
printf '%s\\n' '{\"method\":\"turn/completed\"}'
Expand All @@ -763,40 +832,24 @@ defmodule SymphonyElixir.AppServerTest do

write_workflow_file!(Workflow.workflow_file_path(),
workspace_root: workspace_root,
codex_command: "#{codex_binary} app-server"
codex_command: "#{codex_binary} app-server",
codex_approval_policy: "never"
)

issue = %Issue{
id: "issue-tool-user-input-options",
identifier: "MT-719",
title: "Option based tool input answer",
description: "Ensure option prompts receive a generic non-interactive answer",
title: "Option based tool input block",
description: "Ensure option prompts require operator input",
state: "In Progress",
url: "https://example.org/issues/MT-719",
labels: ["backend"]
}

assert {:ok, _result} =
assert {:error, {:turn_input_required, payload}} =
AppServer.run(workspace, "Handle option based tool input", issue)

trace = File.read!(trace_file)
lines = String.split(trace, "\n", trim: true)

assert Enum.any?(lines, fn line ->
if String.starts_with?(line, "JSON:") do
payload =
line
|> String.trim_leading("JSON:")
|> Jason.decode!()

payload["id"] == 112 and
get_in(payload, ["result", "answers", "options-719", "answers"]) == [
"This is a non-interactive session. Operator input is unavailable."
]
else
false
end
end)
assert payload["method"] == "item/tool/requestUserInput"
after
File.rm_rf(test_root)
end
Expand Down
Loading
Loading