diff --git a/lib/ourocode/cli.ex b/lib/ourocode/cli.ex index f5974b1..59f7070 100644 --- a/lib/ourocode/cli.ex +++ b/lib/ourocode/cli.ex @@ -37,7 +37,7 @@ defmodule Ourocode.CLI do alias Ourocode.Runtime.LoopBindingInterviewSessionIO alias Ourocode.Runtime.LoopBindings - @version "0.1.13" + @version "0.1.14" @doc """ Escript entry point. @@ -3238,6 +3238,13 @@ defmodule Ourocode.CLI do defp workflow_help_prompt?(_task_request), do: false + defp workflow_mode(%{routing_decision: %{adapter_route: :pm}}), do: :pm + defp workflow_mode(%{routing_decision: %{"adapter_route" => "pm"}}), do: :pm + defp workflow_mode(%{routing_decision: %{adapter_route: :interview}}), do: :interview + defp workflow_mode(%{routing_decision: %{"adapter_route" => "interview"}}), do: :interview + defp workflow_mode(%{routing_decision: %{adapter_route: :auto}}), do: :auto + defp workflow_mode(%{routing_decision: %{"adapter_route" => "auto"}}), do: :auto + defp workflow_mode(%{task_input: input}) when is_binary(input) do normalized = input |> String.trim() |> String.downcase() diff --git a/lib/ourocode/model/profile.ex b/lib/ourocode/model/profile.ex index 5d754a9..7bf404d 100644 --- a/lib/ourocode/model/profile.ex +++ b/lib/ourocode/model/profile.ex @@ -136,13 +136,20 @@ defmodule Ourocode.Model.Profile do defp runtime_name(_label, model_label), do: short_model_label(model_label) defp pick_model(models, candidates, active_model, opts) do - ready_candidate(models, candidates) || + ready_active_candidate(active_model, candidates) || + ready_candidate(models, candidates) || ready_active(active_model) || selectable_candidate(models, candidates) || active_model(active_model) || Keyword.get_lazy(opts, :default_model, &Catalog.default/0) end + defp ready_active_candidate(%Model{id: id} = model, candidates) do + if id in candidates and Model.ready?(model), do: model + end + + defp ready_active_candidate(_model, _candidates), do: nil + defp ready_candidate(models, candidates) do Enum.find_value(candidates, fn id -> case Catalog.fetch(models, id) do diff --git a/lib/ourocode/runtime/interview_events.ex b/lib/ourocode/runtime/interview_events.ex index 9cca274..8b033d1 100644 --- a/lib/ourocode/runtime/interview_events.ex +++ b/lib/ourocode/runtime/interview_events.ex @@ -140,6 +140,11 @@ defmodule Ourocode.Runtime.InterviewEvents do |> Map.put(:seed_ready, true) |> Map.put(:complete, reason) |> Map.put(:waiting, false) + |> Map.put(:status, "interview complete: #{reason}") + |> Map.put(:question, "") + |> Map.delete(:question_options) + |> Map.delete(:answered) + |> Map.delete(:waiting_started_monotonic_ms) %{state | interview: interview, interview_session: nil, interview_waiter: nil} end diff --git a/lib/ourocode/runtime/interview_progress.ex b/lib/ourocode/runtime/interview_progress.ex index 1695e5b..6be57f7 100644 --- a/lib/ourocode/runtime/interview_progress.ex +++ b/lib/ourocode/runtime/interview_progress.ex @@ -22,13 +22,14 @@ defmodule Ourocode.Runtime.InterviewProgress do prev |> Map.merge(%{ parent_call_id: parent_call_id, - question: Map.get(prev, :question, ""), + question: "", waiting_started_monotonic_ms: monotonic_ms(), waiting: true, status: "starting interview session", dialogue: dialogue }) |> Map.delete(:answered) + |> Map.delete(:question_options) session = %{ parent_call_id: parent_call_id, @@ -38,7 +39,7 @@ defmodule Ourocode.Runtime.InterviewProgress do } state - |> Map.merge(%{interview: iv, interview_session: session, paused: false}) + |> Map.merge(%{interview: iv, interview_session: session, paused: false, wonder: nil}) end) end diff --git a/lib/ourocode/runtime/interview_router/directive.ex b/lib/ourocode/runtime/interview_router/directive.ex index 558c4c6..b7ace6a 100644 --- a/lib/ourocode/runtime/interview_router/directive.ex +++ b/lib/ourocode/runtime/interview_router/directive.ex @@ -10,7 +10,7 @@ defmodule Ourocode.Runtime.InterviewRouter.Directive do | {:tool, atom(), String.t()} | :unparseable - @option_re ~r/\A[-*]\s*(.+?)\s*[||]\s*(.+)\z/ + @option_re ~r/\A[-*]\s*(.+?)\s*[||]\s*(.+)\z/u @directive_re ~r/\A(?:TOOL\s+(?:READ|GLOB|GREP)\b|ANSWER\b|ASK_USER\b)/ @spec parse(String.t()) :: t() @@ -81,7 +81,7 @@ defmodule Ourocode.Runtime.InterviewRouter.Directive do end defp expand_inline_ask_user_option_line(body) do - case Regex.run(~r/\s+-\s+[^|\n]+?\s*[||]/u, body, return: :index) do + case Regex.run(~r/\s+-\s+[^||\n]+?\s*[||]/u, body, return: :index) do [{start, _length}] -> {question, rest} = String.split_at(body, start) option_lines = inline_option_lines(rest) @@ -100,7 +100,7 @@ defmodule Ourocode.Runtime.InterviewRouter.Directive do end defp inline_option_lines(rest) do - ~r/(?:^\s*-\s*|\s+-\s*)([^|\n]+?)\s*[||]\s*(.*?)(?=\s+-\s*[^|\n]+?\s*[||]|$)/u + ~r/(?:^\s*-\s*|\s+-\s*)([^||\n]+?)\s*[||]\s*(.*?)(?=\s+-\s*[^||\n]+?\s*[||]|$)/u |> Regex.scan(rest) |> Enum.map(fn [_, label, desc] -> "- #{String.trim(label)} | #{String.trim(desc)}" diff --git a/lib/ourocode/runtime/interview_router/tool_sandbox.ex b/lib/ourocode/runtime/interview_router/tool_sandbox.ex index 848b8ad..53a7642 100644 --- a/lib/ourocode/runtime/interview_router/tool_sandbox.ex +++ b/lib/ourocode/runtime/interview_router/tool_sandbox.ex @@ -25,9 +25,9 @@ defmodule Ourocode.Runtime.InterviewRouter.ToolSandbox do :ok -> hits = root - |> Path.join(pat) + |> wildcard_path(pat) |> Path.wildcard() - |> Enum.map(&Path.relative_to(&1, root)) + |> Enum.map(&relative_path(&1, root)) |> Enum.take(@max_glob_hits) body = if hits == [], do: "(no matches)", else: Enum.join(hits, "\n") @@ -56,28 +56,65 @@ defmodule Ourocode.Runtime.InterviewRouter.ToolSandbox do def run(_tool, arg, _root), do: {"UNKNOWN #{inspect(arg)}", "rejected: unknown tool"} defp bounded_grep(pattern, glob, root) do - args = - ["-rnI", "--"] - |> then(fn base -> if glob, do: ["--include=" <> glob | base], else: base end) - |> Kernel.++([pattern, "."]) - - task = - Task.async(fn -> - System.cmd("grep", args, cd: root, stderr_to_stdout: true) - end) + task = Task.async(fn -> elixir_grep(pattern, glob, root) end) case Task.yield(task, @grep_timeout_ms) || Task.shutdown(task, :brutal_kill) do - {:ok, {out, status}} when status in [0, 1] -> + {:ok, out} -> if String.trim(out) == "", do: "(no matches)", else: cap_bytes(out, @max_grep_bytes) - {:ok, {out, _status}} -> - "error: " <> cap_bytes(out, 256) - _timeout_or_crash -> "error: grep timed out" end - rescue - _exception -> "error: grep unavailable" + end + + defp elixir_grep(pattern, glob, root) do + matcher = grep_matcher(pattern) + + root + |> grep_files(glob) + |> Enum.flat_map(&grep_file(&1, root, matcher)) + |> Enum.join("\n") + end + + defp grep_matcher(pattern) do + case Regex.compile(pattern) do + {:ok, regex} -> &Regex.match?(regex, &1) + {:error, _reason} -> &String.contains?(&1, pattern) + end + end + + defp grep_files(root, nil) do + root + |> wildcard_path("**/*") + |> Path.wildcard() + |> Enum.filter(&File.regular?/1) + end + + defp grep_files(root, glob) do + if String.contains?(glob, "/") do + root + |> wildcard_path(glob) + |> Path.wildcard() + else + root + |> wildcard_path("**/" <> glob) + |> Path.wildcard() + end + |> Enum.filter(&File.regular?/1) + end + + defp grep_file(path, root, matcher) do + case File.read(path) do + {:ok, content} -> + content + |> String.split("\n") + |> Enum.with_index(1) + |> Enum.filter(fn {line, _index} -> matcher.(line) end) + |> Enum.map(fn {line, index} -> "#{relative_path(path, root)}:#{index}:#{line}" end) + + {:error, _reason} -> + [] + end end defp split_grep_arg(arg) do @@ -141,6 +178,18 @@ defmodule Ourocode.Runtime.InterviewRouter.ToolSandbox do defp safe_relative?(_path), do: {:error, "invalid path"} + defp wildcard_path(root, pattern) do + root + |> Path.join(pattern) + |> String.replace("\\", "/") + end + + defp relative_path(path, root) do + path + |> Path.relative_to(root) + |> String.replace("\\", "/") + end + defp cap_bytes(bin, limit) when byte_size(bin) <= limit, do: bin defp cap_bytes(bin, limit) do diff --git a/lib/ourocode/runtime/local_interview_fallback.ex b/lib/ourocode/runtime/local_interview_fallback.ex new file mode 100644 index 0000000..a761db7 --- /dev/null +++ b/lib/ourocode/runtime/local_interview_fallback.ex @@ -0,0 +1,226 @@ +defmodule Ourocode.Runtime.LocalInterviewFallback do + alias Ourocode.Model + + alias Ourocode.Runtime.{ + InterviewEvents, + InterviewRouter, + InterviewState, + InterviewWonderPrompt, + LoopBindingEventFlow, + LoopBindingInterviewAwaiter, + WorkflowHarness + } + + import Ourocode.Runtime.LocalInterviewFallback.Support, + only: [ + clean_text: 1, + continue_interview?: 1, + local_error_message: 1, + normalize_options: 1, + preview_profile: 1, + router_question: 3, + seed_ready_summary: 1, + usable?: 1 + ] + + @spec start(pid(), map(), String.t(), String.t(), Model.t(), String.t()) :: :ok + def start(agent, task_request, parent_call_id, workflow_run_id, %Model{} = model, project_dir) + when is_pid(agent) and is_map(task_request) do + spawn(fn -> run(agent, task_request, parent_call_id, workflow_run_id, model, project_dir) end) + :ok + end + + @spec unavailable?(map() | nil) :: boolean() + def unavailable?(%{mode: mode}) when mode in [:disabled, :unavailable], do: true + def unavailable?(_handle), do: false + + defp run(agent, task_request, parent_call_id, workflow_run_id, model, project_dir) do + profile = preview_profile(task_request) + + if Model.ready?(model) do + push_trace( + agent, + "MCP daemon unavailable; asking #{model.label} to run the #{profile.workflow} interview" + ) + + local_round(agent, profile, parent_call_id, workflow_run_id, model, project_dir, [], 1) + else + fail(agent, parent_call_id, workflow_run_id, {:model_not_ready, model.status}) + end + end + + defp local_round( + agent, + profile, + parent_call_id, + workflow_run_id, + model, + project_dir, + turns, + round + ) do + if round > profile.max_rounds do + continuation_gate( + agent, + profile, + parent_call_id, + workflow_run_id, + model, + project_dir, + turns + ) + else + case generate_turn(agent, model, profile, project_dir, turns, round) do + {:ok, {:question, question, options}} -> + ask_round(agent, parent_call_id, round, question, options) + + case LoopBindingInterviewAwaiter.await(agent, parent_call_id, question, options) do + {:done, text} -> + complete(agent, parent_call_id, workflow_run_id, text) + + {:answer, answer} -> + answer = clean_text(answer) + push_dialogue(agent, :user, answer) + + local_round( + agent, + profile, + parent_call_id, + workflow_run_id, + model, + project_dir, + turns ++ [%{question: question, answer: answer}], + round + 1 + ) + end + + {:error, reason} -> + fail(agent, parent_call_id, workflow_run_id, reason) + end + end + end + + defp continuation_gate( + agent, + profile, + parent_call_id, + workflow_run_id, + model, + project_dir, + turns + ) do + question = "Do you want to keep interviewing or generate the seed now?" + + options = [ + %{label: "Continue interview", description: "ask more Codex-generated PM questions"}, + %{label: "Generate seed now", description: "finish the interview and move to ooo seed"}, + %{label: "Stop for now", description: "close the interview without adding more answers"} + ] + + ask_round(agent, parent_call_id, profile.max_rounds + 1, question, options) + + case LoopBindingInterviewAwaiter.await(agent, parent_call_id, question, options) do + {:done, text} -> + complete(agent, parent_call_id, workflow_run_id, text) + + {:answer, answer} -> + push_dialogue(agent, :user, answer) + + if continue_interview?(answer) do + local_round( + agent, + profile, + parent_call_id, + workflow_run_id, + model, + project_dir, + turns, + 1 + ) + else + complete(agent, parent_call_id, workflow_run_id, seed_ready_summary(answer)) + end + end + end + + defp generate_turn(agent, model, profile, project_dir, turns, round) do + question = router_question(profile, turns, round) + push_trace(agent, "Codex router generating local #{profile.workflow} question #{round}") + + case InterviewRouter.decide(question, %{project_dir: project_dir, streak: 0}, model, + on_trace: fn line -> push_trace(agent, line) end, + on_reason: fn chunk -> push_reasoning(agent, chunk) end + ) do + {:ask_user, question, options} -> + question = clean_text(question) + options = normalize_options(options) + + if usable?(question) and length(options) >= 2 do + {:ok, {:question, question, options}} + else + {:error, :router_question_or_options_missing} + end + + {:answer, payload, source} -> + {:error, {:unexpected_router_answer, source, payload}} + + {:error, reason} -> + {:error, reason} + end + end + + defp ask_round(agent, parent_call_id, round, question, options) do + push_dialogue(agent, :main, "-> asking you: " <> question) + + LoopBindingEventFlow.enqueue( + agent, + InterviewWonderPrompt.event(parent_call_id, round, question, options) + ) + end + + defp complete(agent, parent_call_id, workflow_run_id, summary) do + if usable?(summary) do + push_dialogue(agent, :main, summary) + end + + LoopBindingEventFlow.enqueue( + agent, + WorkflowHarness.completed_event(parent_call_id, :local_preview, run_id: workflow_run_id) + ) + + LoopBindingEventFlow.enqueue(agent, InterviewEvents.complete(parent_call_id, :local_preview)) + Agent.update(agent, &InterviewEvents.complete_state(&1, :local_preview)) + push_dialogue(agent, :main, "AI interview fallback complete - run ooo seed when ready") + end + + defp fail(agent, parent_call_id, workflow_run_id, reason) do + message = local_error_message(reason) + + LoopBindingEventFlow.enqueue( + agent, + WorkflowHarness.failure_event(parent_call_id, {:local_ai_question_failed, reason}, + run_id: workflow_run_id + ) + ) + + LoopBindingEventFlow.enqueue( + agent, + InterviewEvents.server_error(parent_call_id, message, nil) + ) + + Agent.update(agent, &InterviewEvents.server_error_state(&1, message, nil)) + push_dialogue(agent, :main, "AI question generation failed: " <> message) + end + + defp push_trace(agent, line) do + Agent.update(agent, fn state -> InterviewState.add_router_trace(state, line) end) + end + + defp push_reasoning(agent, chunk) do + Agent.update(agent, fn state -> InterviewState.add_reasoning(state, chunk) end) + end + + defp push_dialogue(agent, role, text) do + Agent.update(agent, fn state -> InterviewState.add_dialogue(state, role, text) end) + end +end diff --git a/lib/ourocode/runtime/local_interview_fallback/support.ex b/lib/ourocode/runtime/local_interview_fallback/support.ex new file mode 100644 index 0000000..2cccfed --- /dev/null +++ b/lib/ourocode/runtime/local_interview_fallback/support.ex @@ -0,0 +1,181 @@ +defmodule Ourocode.Runtime.LocalInterviewFallback.Support do + @moduledoc false + + @spec preview_profile(map()) :: map() + def preview_profile(task_request) do + route = + task_request + |> Map.get(:routing_decision, %{}) + |> Map.get(:adapter_route) + + input = Map.get(task_request, :task_input, "") + + case route do + :pm -> pm_profile(input) + _route -> interview_profile(input) + end + end + + @spec router_question(map(), [map()], pos_integer()) :: String.t() + def router_question(profile, turns, round) do + """ + You are Codex running the visible #{profile.workflow} interview inside ourocode because the MCP daemon is unavailable. + + Original user request: + #{profile.goal} + + Generate the next adaptive question the human should answer now. + This is a human-judgment product interview turn, so route it to the user. + + Requirements: + - Use the ASK_USER directive. + - Ask only one concrete question at a time. + - Provide 2 to 4 useful options. + - Keep labels short and descriptions one line. + - Adapt to the prior turns; do not repeat answered questions. + - Do not answer the question yourself. + - Do not finish the interview in this turn. + - Round #{round} of at most #{profile.max_rounds}. + + Prior turns: + #{render_turns(turns)} + """ + end + + @spec normalize_options(term()) :: [%{label: String.t(), description: String.t()}] + def normalize_options(options) when is_list(options) do + options + |> Enum.map(&normalize_option/1) + |> Enum.reject(&is_nil/1) + |> Enum.take(4) + end + + def normalize_options(_options), do: [] + + @spec clean_text(term()) :: String.t() + def clean_text(value) when is_binary(value) do + value + |> String.replace_invalid("") + |> String.replace(<<0xFFFD::utf8>>, "") + |> String.replace(~r/\s+/, " ") + |> String.trim() + end + + def clean_text(nil), do: "" + def clean_text(value), do: value |> to_string() |> clean_text() + + @spec usable?(term()) :: boolean() + def usable?(value) when is_binary(value), do: String.trim(value) != "" + def usable?(_value), do: false + + @spec continue_interview?(String.t()) :: boolean() + def continue_interview?(answer) when is_binary(answer) do + answer + |> String.downcase() + |> String.trim() + |> then(fn text -> + String.contains?(text, "continue") or String.contains?(text, "keep interviewing") or + String.contains?(text, "more") + end) + end + + @spec seed_ready_summary(String.t()) :: String.t() + def seed_ready_summary(answer) when is_binary(answer) do + answer = String.trim(answer) + + cond do + String.downcase(answer) == "stop for now" -> + "Stopped the local interview. Run ooo pm again to continue later." + + true -> + "Ready to generate the seed. Run ooo seed when ready." + end + end + + @spec local_error_message(term()) :: String.t() + def local_error_message({:model_not_ready, {:needs_auth, hint}}), + do: "active model needs login; run " <> to_string(hint) + + def local_error_message({:model_not_ready, status}), + do: "active model is not ready: " <> inspect(status) + + def local_error_message({:model_stream_failed, reason}), + do: "active model call failed: " <> inspect(reason) + + def local_error_message({:unexpected_router_answer, source, payload}), + do: "Codex answered instead of asking a user question: #{inspect(source)} #{inspect(payload)}" + + def local_error_message(reason), + do: "Codex did not produce a usable interview question: " <> inspect(reason) + + defp pm_profile(input) do + goal = workflow_goal(input, "ooo pm") + + %{ + workflow: "PM", + goal: goal, + max_rounds: 6 + } + end + + defp interview_profile(input) do + goal = workflow_goal(input, "ooo interview") + + %{ + workflow: "Socratic", + goal: goal, + max_rounds: 5 + } + end + + defp workflow_goal(input, prefix) do + input + |> to_string() + |> String.trim() + |> remove_prefix(prefix) + |> case do + "" -> "this work" + goal -> goal + end + end + + defp remove_prefix(input, prefix) do + if String.starts_with?(String.downcase(input), prefix) do + input + |> String.slice(String.length(prefix)..-1//1) + |> String.trim() + else + input + end + end + + defp render_turns([]), do: "None yet." + + defp render_turns(turns) do + turns + |> Enum.with_index(1) + |> Enum.map(fn {turn, index} -> + question = turn |> Map.get(:question, "") |> to_string() + answer = turn |> Map.get(:answer, "") |> to_string() + "#{index}. Q: #{question}\n A: #{answer}" + end) + |> Enum.join("\n") + end + + defp normalize_option(option) when is_map(option) do + with {label, description} <- option_fields(option), + label <- clean_text(label), + description <- clean_text(description), + true <- usable?(label) and usable?(description) do + %{label: label, description: description} + else + _invalid -> nil + end + end + + defp normalize_option(_option), do: nil + + defp option_fields(%{label: label, description: description}), do: {label, description} + defp option_fields(%{"label" => label, "description" => description}), do: {label, description} + defp option_fields(_option), do: nil +end diff --git a/lib/ourocode/runtime/loop_binding_answers.ex b/lib/ourocode/runtime/loop_binding_answers.ex index 7e94422..224739c 100644 --- a/lib/ourocode/runtime/loop_binding_answers.ex +++ b/lib/ourocode/runtime/loop_binding_answers.ex @@ -25,6 +25,8 @@ defmodule Ourocode.Runtime.LoopBindingAnswers do @spec answer_interview(pid(), String.t(), enqueue_fun()) :: {:ok, String.t()} | {:error, :no_active_interview} def answer_interview(agent, text, enqueue) when is_pid(agent) and is_binary(text) do + text = clean_text(text) + case Agent.get(agent, &{&1.interview, Map.get(&1, :interview_waiter)}) do {%{} = interview, waiter} -> enqueue.(agent, InterviewEvents.answer_ack(interview, text)) @@ -44,6 +46,13 @@ defmodule Ourocode.Runtime.LoopBindingAnswers do end end + defp clean_text(text) when is_binary(text) do + text + |> String.replace_invalid("") + |> String.replace(<<0xFFFD::utf8>>, "") + |> String.trim() + end + @spec cancel_interview(pid(), enqueue_fun()) :: {:ok, String.t()} | {:error, :no_active_interview} def cancel_interview(agent, enqueue) when is_pid(agent) do diff --git a/lib/ourocode/runtime/loop_binding_workflow_dispatch.ex b/lib/ourocode/runtime/loop_binding_workflow_dispatch.ex index 4fdf4d7..5681f87 100644 --- a/lib/ourocode/runtime/loop_binding_workflow_dispatch.ex +++ b/lib/ourocode/runtime/loop_binding_workflow_dispatch.ex @@ -10,6 +10,7 @@ defmodule Ourocode.Runtime.LoopBindingWorkflowDispatch do alias Ourocode.Runtime.{ Dispatcher, InterviewProgress, + LocalInterviewFallback, InterviewWorkflowInvocation, LoopBindingEventFlow, McpDaemonBinding, @@ -263,6 +264,21 @@ defmodule Ourocode.Runtime.LoopBindingWorkflowDispatch do else {:ok, mcp_url} = McpDaemonBinding.ensure(agent, model) + if local_interview_fallback_enabled?(callbacks) and + interview_task?(task_request) and + LocalInterviewFallback.unavailable?(mcp_daemon(agent)) do + LocalInterviewFallback.start( + agent, + task_request, + parent_call_id, + workflow_run_id, + model, + project_dir(runtime) + ) + + throw(:local_interview_fallback_started) + end + %{ streamable_http_url: mcp_url, workflow_run_id: workflow_run_id, @@ -309,6 +325,17 @@ defmodule Ourocode.Runtime.LoopBindingWorkflowDispatch do "parent-" <> to_string(task_request.id), {:dispatch_exception, Exception.message(exception)} ) + catch + :local_interview_fallback_started -> + :ok + end + + defp mcp_daemon(agent) do + Agent.get(agent, &Map.get(&1, :mcp_daemon)) + end + + defp local_interview_fallback_enabled?(callbacks) do + Map.get(callbacks, :local_interview_fallback?, true) end defp transport_invoker(agent, runtime, parent_call_id, workflow_run_id, model, callbacks) do diff --git a/lib/ourocode/runtime/mcp_daemon/process.ex b/lib/ourocode/runtime/mcp_daemon/process.ex index 4734169..fde7bd7 100644 --- a/lib/ourocode/runtime/mcp_daemon/process.ex +++ b/lib/ourocode/runtime/mcp_daemon/process.ex @@ -40,25 +40,21 @@ defmodule Ourocode.Runtime.McpDaemon.Process do @spec spawn_plan(String.t(), [String.t()], :inet.port_number(), String.t() | nil) :: map() def spawn_plan(exe, args, port, shell \\ nil) when is_binary(exe) and is_list(args) and is_integer(port) do - shell = shell || System.find_executable("sh") || "/bin/sh" log_path = Path.join(System.tmp_dir!(), "ourocode-mcp-#{port}.log") + {command, command_args} = launch_plan(shell, exe, args, log_path) %{ - shell: shell, - args: ["-c", "exec \"$0\" \"$@\" >\"#{log_path}\" 2>&1", exe] ++ args, + shell: command, + args: command_args, log_path: log_path } end @spec stop(map() | nil) :: :ok def stop(%{mode: :spawned} = handle) do - case Map.get(handle, :os_pid) do - pid when is_integer(pid) and pid > 0 -> - System.cmd("kill", ["-TERM", Integer.to_string(pid)], stderr_to_stdout: true) - - _none -> - :ok - end + handle + |> Map.get(:os_pid) + |> terminate_os_process() erl_port = Map.get(handle, :port) if is_port(erl_port) and Port.info(erl_port) != nil, do: Port.close(erl_port) @@ -68,4 +64,51 @@ defmodule Ourocode.Runtime.McpDaemon.Process do end def stop(_handle), do: :ok + + defp launch_plan(nil, exe, args, log_path) do + if windows?() do + {exe, args} + else + shell = default_shell() + {shell, shell_args(shell, exe, args, log_path)} + end + end + + defp launch_plan(shell, exe, args, log_path) do + if windows?() and windows_shell?(shell) do + {exe, args} + else + {shell, shell_args(shell, exe, args, log_path)} + end + end + + defp default_shell do + System.find_executable("sh") || "/bin/sh" + end + + defp shell_args(_shell, exe, args, log_path) do + ["-c", "exec \"$0\" \"$@\" >\"#{log_path}\" 2>&1", exe] ++ args + end + + defp terminate_os_process(pid) when is_integer(pid) and pid > 0 do + if windows?() do + command = System.find_executable("taskkill") || "taskkill" + System.cmd(command, ["/PID", Integer.to_string(pid), "/T", "/F"], stderr_to_stdout: true) + else + System.cmd("kill", ["-TERM", Integer.to_string(pid)], stderr_to_stdout: true) + end + + :ok + end + + defp terminate_os_process(_pid), do: :ok + + defp windows_shell?(shell) do + shell + |> Path.basename() + |> String.downcase() + |> then(&(&1 in ["cmd", "cmd.exe"])) + end + + defp windows?, do: match?({:win32, _name}, :os.type()) end diff --git a/lib/ourocode/runtime/route_classifier.ex b/lib/ourocode/runtime/route_classifier.ex index e99562f..f735529 100644 --- a/lib/ourocode/runtime/route_classifier.ex +++ b/lib/ourocode/runtime/route_classifier.ex @@ -140,6 +140,16 @@ defmodule Ourocode.Runtime.RouteClassifier do ouroboros_adapter_route ) + RouteTerms.product_goal?(task_input, tokens) -> + route( + :ouroboros_workflow, + :ouroboros, + RouteTerms.transport_from_tokens(tokens), + false, + :product_goal_terms, + :pm + ) + true -> route( :runtime, diff --git a/lib/ourocode/runtime/route_terms.ex b/lib/ourocode/runtime/route_terms.ex index 23771e5..c50534b 100644 --- a/lib/ourocode/runtime/route_terms.ex +++ b/lib/ourocode/runtime/route_terms.ex @@ -75,6 +75,14 @@ defmodule Ourocode.Runtime.RouteTerms do Enum.any?(tokens, &String.starts_with?(&1, "ouroboros:")) end + @spec product_goal?(String.t(), [String.t()]) :: boolean() + def product_goal?(input, tokens) when is_binary(input) and is_list(tokens) do + normalized = input |> normalize() |> String.downcase() + route_tokens = if tokens == [], do: tokens(normalized), else: tokens + + product_term?(normalized, route_tokens) and creation_intent?(normalized, route_tokens) + end + @spec ouroboros_adapter_route([String.t()]) :: :auto | :interview @@ -180,6 +188,16 @@ defmodule Ourocode.Runtime.RouteTerms do defp explicit_ouroboros_run?(tokens), do: Enum.any?(tokens, &(&1 in ["ouroboros:run", "ouroboros:execute"])) + defp product_term?(normalized, tokens) do + Enum.any?(tokens, &(&1 in ["saas", "product", "service", "app", "mvp", "startup"])) or + String.contains?(normalized, ["서비스", "제품", "앱", "어플", "스타트업"]) + end + + defp creation_intent?(normalized, tokens) do + Enum.any?(tokens, &(&1 in ["build", "create", "make", "design", "launch", "plan", "idea"])) or + String.contains?(normalized, ["만들", "기획", "제작", "출시", "런칭", "구상"]) + end + defp status_terms?(["ooo", "status" | _tokens]), do: true defp status_terms?(["ouroboros", "status" | _tokens]), do: true diff --git a/lib/ourocode/runtime/router.ex b/lib/ourocode/runtime/router.ex index 0fa24dc..3b8de4b 100644 --- a/lib/ourocode/runtime/router.ex +++ b/lib/ourocode/runtime/router.ex @@ -108,6 +108,7 @@ defmodule Ourocode.Runtime.Router do end defp adapter_route_label(:interview), do: "interview" + defp adapter_route_label(:pm), do: "PM interview" defp adapter_route_label(:auto), do: "auto" defp adapter_route_label(:seed), do: "seed" defp adapter_route_label(:run), do: "run" diff --git a/lib/ourocode/terminal/event_loop_task_submission.ex b/lib/ourocode/terminal/event_loop_task_submission.ex index 8b6a3ad..17e06c1 100644 --- a/lib/ourocode/terminal/event_loop_task_submission.ex +++ b/lib/ourocode/terminal/event_loop_task_submission.ex @@ -166,6 +166,13 @@ defmodule Ourocode.Terminal.EventLoopTaskSubmission do defp workflow_mode(%{routing_decision: %{execution_route: :user_level_plugin}}), do: :user_level_plugin + defp workflow_mode(%{routing_decision: %{adapter_route: :pm}}), do: :pm + defp workflow_mode(%{routing_decision: %{"adapter_route" => "pm"}}), do: :pm + defp workflow_mode(%{routing_decision: %{adapter_route: :interview}}), do: :interview + defp workflow_mode(%{routing_decision: %{"adapter_route" => "interview"}}), do: :interview + defp workflow_mode(%{routing_decision: %{adapter_route: :auto}}), do: :auto + defp workflow_mode(%{routing_decision: %{"adapter_route" => "auto"}}), do: :auto + defp workflow_mode(%{task_input: input}) when is_binary(input) do normalized = input |> String.trim() |> String.downcase() diff --git a/lib/ourocode/terminal/interview_panel.ex b/lib/ourocode/terminal/interview_panel.ex index 54b94ec..1018389 100644 --- a/lib/ourocode/terminal/interview_panel.ex +++ b/lib/ourocode/terminal/interview_panel.ex @@ -24,8 +24,7 @@ defmodule Ourocode.Terminal.InterviewPanel do trace = if iv, do: List.first(Map.get(iv, :router, [])), else: nil if paused?(result) or waiting_for_user?(iv) or interview_error?(iv) or - active_question_waiting?(iv) or - (iv && Map.get(iv, :complete) && is_nil(trace)) do + active_question_waiting?(iv) or completed_without_visible_answer?(iv, trace) do [] else [working_line(tick, trace, waiting_elapsed_seconds(iv))] @@ -352,6 +351,20 @@ defmodule Ourocode.Terminal.InterviewPanel do defp waiting_for_user?(_interview), do: false + defp completed_without_visible_answer?(%{complete: complete}, trace) when not is_nil(complete), + do: not seed_ready_answer_trace?(complete, trace) + + defp completed_without_visible_answer?(_interview, _trace), do: false + + defp seed_ready_answer_trace?(complete, trace) when complete in [:seed_ready, "seed_ready"] do + trace + |> Text.flatten_line() + |> String.upcase() + |> String.starts_with?("ANSWER") + end + + defp seed_ready_answer_trace?(_complete, _trace), do: false + defp interview_error?(%{status: status}) when is_binary(status), do: interview_error_status?(status) diff --git a/lib/ourocode/terminal/interview_panel/dialogue.ex b/lib/ourocode/terminal/interview_panel/dialogue.ex index 729735f..5b129ab 100644 --- a/lib/ourocode/terminal/interview_panel/dialogue.ex +++ b/lib/ourocode/terminal/interview_panel/dialogue.ex @@ -36,6 +36,9 @@ defmodule Ourocode.Terminal.InterviewPanel.Dialogue do defp internal_turn?(%{role: :main, text: text}) when is_binary(text), do: leaked_router_prompt?(text) or String.starts_with?(String.trim(text), "→ asking you:") + defp internal_turn?(%{role: :mcp, text: text}) when is_binary(text), + do: completion_turn?(text) + defp internal_turn?(_turn), do: false defp row(%{role: role, text: text}) do @@ -43,13 +46,38 @@ defmodule Ourocode.Terminal.InterviewPanel.Dialogue do case role do :mcp -> {"Question", :warn} :main -> {"MAIN", :ok} - :user -> {"Answer", :strong} + :user -> user_row_label(text) _other -> {"TURN", :dim} end {label <> " " <> (text |> InterviewResponse.clean_markdown() |> Text.flatten_line()), style} end + defp user_row_label(text) when is_binary(text) do + if workflow_command?(text), do: {"Goal", :strong}, else: {"Answer", :strong} + end + + defp user_row_label(_text), do: {"Answer", :strong} + + defp workflow_command?(text) do + case String.trim_leading(text) do + "ooo" -> true + "ooo" <> rest -> String.match?(rest, ~r/^\s/) + _other -> false + end + end + + defp completion_turn?(text) do + text = String.downcase(text) + + String.contains?(text, "interview complete") or + String.contains?(text, "interview completed") or + String.contains?(text, "local interview fallback complete") or + String.contains?(text, "ai interview fallback complete") or + String.contains?(text, "ready for seed generation") or + String.contains?(text, "ooo seed") + end + defp leaked_router_prompt?(text) when is_binary(text) do flat = String.replace(text, ~r/\s+/, " ") diff --git a/lib/ourocode/terminal/interview_panel/question_ledger.ex b/lib/ourocode/terminal/interview_panel/question_ledger.ex index d096ee5..7f0fce5 100644 --- a/lib/ourocode/terminal/interview_panel/question_ledger.ex +++ b/lib/ourocode/terminal/interview_panel/question_ledger.ex @@ -153,6 +153,9 @@ defmodule Ourocode.Terminal.InterviewPanel.QuestionLedger do text == "" -> {blocks, current} + role == :mcp and completion_turn?(text) -> + {finish_block(blocks, current), nil} + role == :mcp -> {finish_block(blocks, current), new_block(text)} @@ -182,6 +185,17 @@ defmodule Ourocode.Terminal.InterviewPanel.QuestionLedger do } end + defp completion_turn?(text) do + text = String.downcase(text) + + String.contains?(text, "interview complete") or + String.contains?(text, "interview completed") or + String.contains?(text, "local interview fallback complete") or + String.contains?(text, "ai interview fallback complete") or + String.contains?(text, "ready for seed generation") or + String.contains?(text, "ooo seed") + end + defp append_transition_block(blocks, %{waiting: true, last_answer: answer} = interview) when is_binary(answer) do answer = clean_line(answer) diff --git a/lib/ourocode/terminal/interview_panel/text.ex b/lib/ourocode/terminal/interview_panel/text.ex index 8f3e758..c1f2d90 100644 --- a/lib/ourocode/terminal/interview_panel/text.ex +++ b/lib/ourocode/terminal/interview_panel/text.ex @@ -32,6 +32,7 @@ defmodule Ourocode.Terminal.InterviewPanel.Text do def md_text(text) do text |> to_string() + |> scrub_invalid_utf8() |> String.replace(~r/(\*\*|__)(.*?)\1/s, "\\2") |> String.replace(~r/(\*|_)(.*?)\1/s, "\\2") |> String.replace(~r/`([^`]+)`/, "\\1") @@ -55,6 +56,7 @@ defmodule Ourocode.Terminal.InterviewPanel.Text do def plain_line(text) do text |> to_string() + |> scrub_invalid_utf8() |> repair_hangul_syllable_spacing() |> String.replace(~r/\s+/, " ") |> String.trim() @@ -63,7 +65,24 @@ defmodule Ourocode.Terminal.InterviewPanel.Text do defp strip_unstable_glyphs(text) do text |> String.replace(~r/[\x{FFFD}\x{FE0E}\x{FE0F}\x{200D}]/u, "") - |> String.replace(~r/[\x{1F000}-\x{1FAFF}]/u, "") + |> strip_supplemental_symbols() + end + + defp scrub_invalid_utf8(text) do + String.replace_invalid(text, "") + end + + defp strip_supplemental_symbols(text) do + text + |> String.graphemes() + |> Enum.reject(&supplemental_symbol?/1) + |> Enum.join() + end + + defp supplemental_symbol?(grapheme) do + grapheme + |> String.to_charlist() + |> Enum.any?(&(&1 in 0x1F000..0x1FAFF)) end defp repair_hangul_syllable_spacing(text) do diff --git a/lib/ourocode/terminal/tui_answer_submission.ex b/lib/ourocode/terminal/tui_answer_submission.ex index 8a4ac52..fa2c68c 100644 --- a/lib/ourocode/terminal/tui_answer_submission.ex +++ b/lib/ourocode/terminal/tui_answer_submission.ex @@ -129,5 +129,5 @@ defmodule Ourocode.Terminal.TuiAnswerSubmission do |> Kernel.in(["cancel", "decline", "/cancel"]) end - defp log(output, text), do: IO.puts(output, text) + defp log(output, text), do: IO.puts(output, String.replace_invalid(text, "")) end diff --git a/lib/ourocode/terminal/tui_interaction.ex b/lib/ourocode/terminal/tui_interaction.ex index 61be16e..884d330 100644 --- a/lib/ourocode/terminal/tui_interaction.ex +++ b/lib/ourocode/terminal/tui_interaction.ex @@ -431,7 +431,7 @@ defmodule Ourocode.Terminal.TuiInteraction do defp accepted_notification(""), do: "accepted - answer captured" defp accepted_notification(label), do: "accepted - " <> label - defp log(output, text), do: IO.puts(output, text) + defp log(output, text), do: IO.puts(output, String.replace_invalid(text, "")) defp clear_captured_activity(output) do StringIO.flush(output) diff --git a/test/ourocode/model/profile_test.exs b/test/ourocode/model/profile_test.exs index a30ee06..3304437 100644 --- a/test/ourocode/model/profile_test.exs +++ b/test/ourocode/model/profile_test.exs @@ -50,6 +50,22 @@ defmodule Ourocode.Model.ProfileTest do assert profile.llm_backend == "gemini" end + test "prefers the active model instance when it is a ready profile candidate" do + active = model(:codex, "codex fake runner", :ready) + + profile = + Profile.for_route(:pm, + active_model: active, + models: [ + model(:claude_api, "claude api", :unavailable), + model(:codex, "catalog codex", :ready) + ] + ) + + assert profile.model == active + assert profile.model_label == "codex fake runner" + end + test "display label uses product-facing role language" do assert Profile.display_label(%{label: "execute/codex", model_label: "codex (ChatGPT)"}) == "Execute · Codex Runtime" diff --git a/test/ourocode/ooo_baseline_e2e_test.exs b/test/ourocode/ooo_baseline_e2e_test.exs index e3b0fbe..f9af047 100644 --- a/test/ourocode/ooo_baseline_e2e_test.exs +++ b/test/ourocode/ooo_baseline_e2e_test.exs @@ -110,10 +110,19 @@ defmodule Ourocode.OooBaselineE2ETest do assert inspect(registry, limit: :infinity) =~ "ouroboros_interview" end - test "unreachable MCP server surfaces a visible failure without crashing", %{ + test "unavailable MCP server opens a visible local interview fallback", %{ runtime: runtime } do - {:ok, _agent, binding_options} = + previous_autostart = System.get_env("OUROCODE_MCP_AUTOSTART") + System.put_env("OUROCODE_MCP_AUTOSTART", "0") + + on_exit(fn -> + if previous_autostart, + do: System.put_env("OUROCODE_MCP_AUTOSTART", previous_autostart), + else: System.delete_env("OUROCODE_MCP_AUTOSTART") + end) + + {:ok, agent, binding_options} = LoopBindings.attach(%{status: :healthy, runtime: runtime}) task_request = %Ourocode.TaskRequest{ @@ -128,21 +137,62 @@ defmodule Ourocode.OooBaselineE2ETest do } on_prompt_input = Keyword.fetch!(binding_options, :on_prompt_input) - poll = Keyword.fetch!(binding_options, :poll_runtime_event) - - # No server is listening; the invoker must fail soft, not raise. - assert :ok == on_prompt_input.(task_request, %{}, %{status: :healthy}) - - # Give the relay a moment to attempt the connection and enqueue a failure. - failure = - Enum.reduce_while(1..100, nil, fn _i, _acc -> - case poll.(%{}) do - {:ok, %{type: :parent_call_failed} = event} -> {:halt, event} - {:ok, _other} -> {:cont, nil} - :none -> Process.sleep(50) && {:cont, nil} + + input_event = %{active_model: scripted_interview_model()} + + assert :ok == on_prompt_input.(task_request, input_event, %{status: :healthy}) + + snapshot = + Enum.reduce_while(1..400, nil, fn _i, _acc -> + snapshot = LoopBindings.pane_snapshot(agent) + question = get_in(snapshot, [:interview, :question]) + options = get_in(snapshot, [:interview, :question_options]) || [] + + if is_binary(question) and String.trim(question) != "" and String.contains?(question, "?") and + length(options) >= 2 do + {:halt, snapshot} + else + Process.sleep(50) + {:cont, nil} end end) - assert %{type: :parent_call_failed, parent_call_id: "parent-e2e-fail-1"} = failure + assert snapshot, "local fallback did not surface a visible interview question" + + assert %{ + interview: %{ + question: question, + question_options: [_first | _rest] = options, + status: "waiting for your answer" + }, + wonder_tool: %{question_count: 1} = wonder_tool + } = snapshot + + assert length(options) >= 2 + assert [%{question: ^question} | _rest] = get_in(wonder_tool, [:request, :questions]) + end + + defp scripted_interview_model do + %Ourocode.Model{ + id: :codex, + label: "codex (test)", + kind: :oauth, + status: :ready, + run: fn prompt, _opts, on_chunk -> + question = + if String.contains?(prompt, "MCP UI"), + do: "What should the MCP UI clarify first?", + else: "What should this interview clarify first?" + + output = """ + ASK_USER #{question} + - User flow | Clarify the path a user should complete + - Success signal | Define what proves the UI works + """ + + on_chunk.(output) + {:ok, output} + end + } end end diff --git a/test/ourocode/runtime/interview_events_test.exs b/test/ourocode/runtime/interview_events_test.exs index 5b82601..36b1175 100644 --- a/test/ourocode/runtime/interview_events_test.exs +++ b/test/ourocode/runtime/interview_events_test.exs @@ -92,7 +92,7 @@ defmodule Ourocode.Runtime.InterviewEventsTest do state = %{ - interview: %{waiting: true}, + interview: %{waiting: true, question: "Pending?", question_options: [%{label: "A"}]}, interview_session: %{id: "session-1"}, interview_waiter: self() } @@ -101,6 +101,9 @@ defmodule Ourocode.Runtime.InterviewEventsTest do assert state.interview.seed_ready == true assert state.interview.complete == :seed_ready assert state.interview.waiting == false + assert state.interview.status == "interview complete: seed_ready" + assert state.interview.question == "" + refute Map.has_key?(state.interview, :question_options) assert state.interview_session == nil assert state.interview_waiter == nil end diff --git a/test/ourocode/runtime/interview_progress_test.exs b/test/ourocode/runtime/interview_progress_test.exs index 3eec979..10ccbb7 100644 --- a/test/ourocode/runtime/interview_progress_test.exs +++ b/test/ourocode/runtime/interview_progress_test.exs @@ -30,7 +30,15 @@ defmodule Ourocode.Runtime.InterviewProgressTest do test "does not open an optimistic PM picker before the transport is ready" do {:ok, agent} = Agent.start_link(fn -> - %{interview: nil, interview_session: nil, wonder: nil, paused: true} + %{ + interview: %{ + question: "Which package manager?", + question_options: [%{label: "npm", description: "Node default"}] + }, + interview_session: nil, + wonder: %{request_id: "stale-picker"}, + paused: true + } end) on_exit(fn -> if Process.alive?(agent), do: Agent.stop(agent) end) @@ -46,6 +54,7 @@ defmodule Ourocode.Runtime.InterviewProgressTest do assert state.interview.waiting == true assert state.interview.status == "starting interview session" assert state.interview.question == "" + refute Map.has_key?(state.interview, :question_options) end test "marks interview waiting rounds with stable status text" do diff --git a/test/ourocode/runtime/interview_router/directive_test.exs b/test/ourocode/runtime/interview_router/directive_test.exs index e6df9f3..a6b4ccc 100644 --- a/test/ourocode/runtime/interview_router/directive_test.exs +++ b/test/ourocode/runtime/interview_router/directive_test.exs @@ -31,6 +31,60 @@ defmodule Ourocode.Runtime.InterviewRouter.DirectiveTest do ]} end + test "parses ask-user directives with ASCII pipe options" do + assert Directive.parse(""" + ASK_USER Which proof should gate this change? + - Focused tests | Parser and fallback regressions only + - Full suite | Broader confidence after the focused checks + """) == + {:ask_user, "Which proof should gate this change?", + [ + %{label: "Focused tests", description: "Parser and fallback regressions only"}, + %{label: "Full suite", description: "Broader confidence after the focused checks"} + ]} + end + + test "parses ask-user directives with fullwidth pipe options" do + assert Directive.parse(""" + ASK_USER 어떤 옵션 구분자를 허용해야 하나요? + - ASCII | 기존 모델 출력과 호환됩니다 + - Fullwidth | 한국어 입력기 출력과 호환됩니다 + """) == + {:ask_user, "어떤 옵션 구분자를 허용해야 하나요?", + [ + %{label: "ASCII", description: "기존 모델 출력과 호환됩니다"}, + %{label: "Fullwidth", description: "한국어 입력기 출력과 호환됩니다"} + ]} + end + + test "parses Korean ask-user option labels with spaces" do + assert Directive.parse(""" + ASK_USER \uce74\ub4dc\ub274\uc2a4 SaaS\uc758 \uccab \uc0ac\uc6a9\uc790\ub294 \ub204\uad6c\uc778\uac00\uc694? + - 1\uc778 \ucc3d\uc5c5\uc790 | \ud63c\uc790 \ucf58\ud150\uce20 \uc81c\uc791\uacfc \ubc30\ud3ec\ub97c \ucc98\ub9ac\ud569\ub2c8\ub2e4 + - \ub9c8\ucf00\ud305 \ud300 | \uc5ec\ub7ec \ucea0\ud398\uc778\uc758 \uce74\ub4dc\ub274\uc2a4\ub97c \ud568\uaed8 \uad00\ub9ac\ud569\ub2c8\ub2e4 + - \uad50\uc721 \uc6b4\uc601\uc790 | \uac15\uc758\ub098 \ud559\uc2b5 \uc790\ub8cc\ub97c \uce74\ub4dc\ub274\uc2a4\ub85c \ubc14\uafc9\ub2c8\ub2e4 + """) == + {:ask_user, + "\uce74\ub4dc\ub274\uc2a4 SaaS\uc758 \uccab \uc0ac\uc6a9\uc790\ub294 \ub204\uad6c\uc778\uac00\uc694?", + [ + %{ + label: "1\uc778 \ucc3d\uc5c5\uc790", + description: + "\ud63c\uc790 \ucf58\ud150\uce20 \uc81c\uc791\uacfc \ubc30\ud3ec\ub97c \ucc98\ub9ac\ud569\ub2c8\ub2e4" + }, + %{ + label: "\ub9c8\ucf00\ud305 \ud300", + description: + "\uc5ec\ub7ec \ucea0\ud398\uc778\uc758 \uce74\ub4dc\ub274\uc2a4\ub97c \ud568\uaed8 \uad00\ub9ac\ud569\ub2c8\ub2e4" + }, + %{ + label: "\uad50\uc721 \uc6b4\uc601\uc790", + description: + "\uac15\uc758\ub098 \ud559\uc2b5 \uc790\ub8cc\ub97c \uce74\ub4dc\ub274\uc2a4\ub85c \ubc14\uafc9\ub2c8\ub2e4" + } + ]} + end + test "expands inline ask-user options" do assert Directive.parse( "ASK_USER Pick scope - Small | One module - Broad | Related runtime paths" @@ -42,6 +96,17 @@ defmodule Ourocode.Runtime.InterviewRouter.DirectiveTest do ]} end + test "expands inline ask-user options with fullwidth pipes" do + assert Directive.parse( + "ASK_USER Pick scope - Small | One module - Broad | Related runtime paths" + ) == + {:ask_user, "Pick scope", + [ + %{label: "Small", description: "One module"}, + %{label: "Broad", description: "Related runtime paths"} + ]} + end + test "finds first directive after echoed prompt and drops cli footer" do wrapped = """ runner banner diff --git a/test/ourocode/runtime/interview_router/tool_sandbox_test.exs b/test/ourocode/runtime/interview_router/tool_sandbox_test.exs index f71a25a..c79086e 100644 --- a/test/ourocode/runtime/interview_router/tool_sandbox_test.exs +++ b/test/ourocode/runtime/interview_router/tool_sandbox_test.exs @@ -49,7 +49,7 @@ defmodule Ourocode.Runtime.InterviewRouter.ToolSandboxTest do test "read rejects symlink escapes below the project root" do root = sandbox_dir!() - File.ln_s!("/etc", Path.join(root, "escape")) + link_escape!(root, "escape") assert {"READ escape/passwd", "rejected: symlinked path not allowed in sandbox"} = ToolSandbox.run(:read, "escape/passwd", root) @@ -95,8 +95,54 @@ defmodule Ourocode.Runtime.InterviewRouter.ToolSandboxTest do "ourocode-router-sandbox-#{System.unique_integer([:positive])}" ) + remove_sandbox_root(root) File.mkdir_p!(root) - on_exit(fn -> File.rm_rf(root) end) + on_exit(fn -> remove_sandbox_root(root) end) root end + + defp link_escape!(root, name) do + link_path = Path.join(root, name) + remove_escape_link(link_path) + + if match?({:win32, _}, :os.type()) do + target = + Path.join( + System.tmp_dir!(), + "ourocode-router-sandbox-target-#{System.unique_integer([:positive])}" + ) + + File.rm_rf!(target) + File.mkdir_p!(target) + on_exit(fn -> File.rm_rf(target) end) + on_exit(fn -> remove_escape_link(link_path) end) + + {out, status} = + System.cmd( + "cmd", + ["/d", "/c", "mklink", "/J", windows_path(link_path), windows_path(target)], + stderr_to_stdout: true + ) + + assert status == 0, out + else + File.ln_s!("/etc", link_path) + on_exit(fn -> remove_escape_link(link_path) end) + end + end + + defp remove_sandbox_root(root) do + remove_escape_link(Path.join(root, "escape")) + File.rm_rf(root) + end + + defp remove_escape_link(path) do + if match?({:win32, _}, :os.type()) do + System.cmd("cmd", ["/d", "/c", "rmdir", windows_path(path)], stderr_to_stdout: true) + else + File.rm(path) + end + end + + defp windows_path(path), do: path |> Path.expand() |> String.replace("/", "\\") end diff --git a/test/ourocode/runtime/interview_router_test.exs b/test/ourocode/runtime/interview_router_test.exs index 6178149..f98ec6a 100644 --- a/test/ourocode/runtime/interview_router_test.exs +++ b/test/ourocode/runtime/interview_router_test.exs @@ -38,6 +38,51 @@ defmodule Ourocode.Runtime.InterviewRouterTest do defp ctx, do: %{project_dir: File.cwd!(), streak: 0} + defp link_escape!(root, name) do + link_path = Path.join(root, name) + remove_escape_link(link_path) + + if match?({:win32, _}, :os.type()) do + target = + Path.join( + System.tmp_dir!(), + "ourocode_sbx_target_#{System.unique_integer([:positive])}" + ) + + File.rm_rf!(target) + File.mkdir_p!(target) + on_exit(fn -> File.rm_rf(target) end) + on_exit(fn -> remove_escape_link(link_path) end) + + {out, status} = + System.cmd( + "cmd", + ["/d", "/c", "mklink", "/J", windows_path(link_path), windows_path(target)], + stderr_to_stdout: true + ) + + assert status == 0, out + else + File.ln_s!("/etc", link_path) + on_exit(fn -> remove_escape_link(link_path) end) + end + end + + defp remove_sandbox_root(root) do + remove_escape_link(Path.join(root, "escape")) + File.rm_rf(root) + end + + defp remove_escape_link(path) do + if match?({:win32, _}, :os.type()) do + System.cmd("cmd", ["/d", "/c", "rmdir", windows_path(path)], stderr_to_stdout: true) + else + File.rm(path) + end + end + + defp windows_path(path), do: path |> Path.expand() |> String.replace("/", "\\") + test "single-turn ANSWER is parsed with its source prefix" do model = scripted_model(["ANSWER [from-code] Elixir 1.15 escript CLI (mix.exs)"]) @@ -181,11 +226,12 @@ defmodule Ourocode.Runtime.InterviewRouterTest do test "sandbox rejects a symlink inside the project that escapes the root" do root = Path.join(System.tmp_dir!(), "ourocode_sbx_#{System.unique_integer([:positive])}") + remove_sandbox_root(root) File.mkdir_p!(root) - on_exit(fn -> File.rm_rf(root) end) + on_exit(fn -> remove_sandbox_root(root) end) # A link inside the project pointing OUT — a pure string-prefix check # would wrongly accept `escape/anything`; resolve-then-contain rejects it. - File.ln_s!("/etc", Path.join(root, "escape")) + link_escape!(root, "escape") model = scripted_model([ diff --git a/test/ourocode/runtime/loop_binding_workflow_dispatch_test.exs b/test/ourocode/runtime/loop_binding_workflow_dispatch_test.exs index 42fcdeb..a556b57 100644 --- a/test/ourocode/runtime/loop_binding_workflow_dispatch_test.exs +++ b/test/ourocode/runtime/loop_binding_workflow_dispatch_test.exs @@ -1,5 +1,5 @@ defmodule Ourocode.Runtime.LoopBindingWorkflowDispatchTest do - use ExUnit.Case, async: true + use ExUnit.Case, async: false alias Ourocode.Model alias Ourocode.Plugin.UserLevel.Capability @@ -226,6 +226,435 @@ defmodule Ourocode.Runtime.LoopBindingWorkflowDispatchTest do LoopBindings.stop(agent) end + test "handle_prompt uses the active model router for local PM questions when the MCP daemon is unavailable" do + parent = self() + previous_autostart = System.get_env("OUROCODE_MCP_AUTOSTART") + System.put_env("OUROCODE_MCP_AUTOSTART", "0") + + on_exit(fn -> + if previous_autostart, + do: System.put_env("OUROCODE_MCP_AUTOSTART", previous_autostart), + else: System.delete_env("OUROCODE_MCP_AUTOSTART") + end) + + {:ok, agent} = LoopBindings.start_link() + {:ok, model_calls} = Agent.start_link(fn -> 0 end) + + active_model = %Model{ + id: :codex, + label: "Codex", + kind: :oauth, + status: :ready, + run: fn prompt, _opts, on_chunk -> + send(parent, {:model_prompt, prompt}) + + response = + Agent.get_and_update(model_calls, fn + 0 -> + { + """ + thinking through the product context + ASK_USER Who should use this first? + - Solo user | optimize for one person first + - Small team | support shared coordination first + """, + 1 + } + + count -> + { + """ + adapting to the previous answer + ASK_USER What should prove the first version worked? + - Task captured | user can add a task without friction + - Task completed | user can finish a task and see progress + """, + count + 1 + } + end) + + on_chunk.(response) + {:ok, response} + end + } + + task_request = %TaskRequest{ + id: "pm-local-fallback", + source: :dashboard, + task_input: "ooo pm 간단한 할 일 앱 만들어줘", + submitted_at_ms: System.system_time(:millisecond), + routing_decision: %{ + kind: :ouroboros_workflow, + execution_route: :ouroboros_workflow, + runtime_source: :ouroboros, + transport: :streamable_http, + adapter_route: :pm, + requires_command_syntax?: false, + advanced_shortcut?: true, + reason: :advanced_shortcut + } + } + + assert :ok == + LoopBindingWorkflowDispatch.handle_prompt( + agent, + %{project_dir: File.cwd!()}, + task_request, + %{active_model: active_model}, + %{ + enqueue_failure: fn _agent, _parent_call_id, reason -> + send(parent, {:failure, reason}) + :ok + end, + run_interview_session: fn _agent, _opts -> + send(parent, :unexpected_live_interview) + :ok + end, + production_parent_call: fn _agent, _runtime, _parent_call_id -> + fn _payload -> {:error, :unexpected_parent_call} end + end, + mcp_url: fn -> "http://127.0.0.1:4000/mcp" end + } + ) + + wait_for(fn -> + snap = LoopBindings.pane_snapshot(agent) + + snap.wonder_tool && + snap.interview && + snap.interview.question == "Who should use this first?" && + Enum.map(snap.interview.question_options, & &1["label"]) == [ + "Solo user", + "Small team" + ] && + Enum.any?(Map.get(snap.interview, :reasoning, []), &String.contains?(&1, "thinking")) && + not String.contains?( + snap.interview.question, + "What outcome should this PM interview produce" + ) + end) + + assert_receive {:model_prompt, prompt}, 1_000 + assert prompt =~ "간단한 할 일 앱 만들어줘" + assert prompt =~ "ASK_USER " + refute_receive :unexpected_live_interview, 100 + refute_receive {:failure, _reason}, 100 + + snapshot = LoopBindings.pane_snapshot(agent) + + assert %{model_profile: %{model_id: :codex, llm_backend: "codex"}} = + snapshot.runtime.workflow.runs["workflow-run:parent-pm-local-fallback"] + + assert {:ok, _selection} = LoopBindings.answer_wonder(agent, 1) + + wait_for(fn -> + snap = LoopBindings.pane_snapshot(agent) + + snap.interview && + snap.interview.question == "What should prove the first version worked?" && + Enum.map(snap.interview.question_options, & &1["label"]) == [ + "Task captured", + "Task completed" + ] && + Map.get(snap.interview, :complete) == nil + end) + + assert Agent.get(model_calls, & &1) == 2 + Agent.stop(model_calls) + LoopBindings.stop(agent) + end + + test "local fallback preserves Korean PM intent and extracts real ASK_USER questions" do + parent = self() + previous_autostart = System.get_env("OUROCODE_MCP_AUTOSTART") + System.put_env("OUROCODE_MCP_AUTOSTART", "0") + + on_exit(fn -> + restore_env("OUROCODE_MCP_AUTOSTART", previous_autostart) + end) + + {:ok, agent} = LoopBindings.start_link() + goal = korean_goal() + + active_model = %Model{ + id: :codex, + label: "Codex", + kind: :oauth, + status: :ready, + run: fn prompt, _opts, on_chunk -> + send(parent, {:model_prompt, prompt}) + + response = korean_ask_user_response() + + on_chunk.(response) + {:ok, response} + end + } + + task_request = %TaskRequest{ + id: "pm-local-fallback-korean", + source: :dashboard, + task_input: "ooo pm " <> goal, + submitted_at_ms: System.system_time(:millisecond), + routing_decision: %{ + kind: :ouroboros_workflow, + execution_route: :ouroboros_workflow, + runtime_source: :ouroboros, + transport: :streamable_http, + adapter_route: :pm, + requires_command_syntax?: false, + advanced_shortcut?: true, + reason: :advanced_shortcut + } + } + + assert :ok == + LoopBindingWorkflowDispatch.handle_prompt( + agent, + %{project_dir: File.cwd!()}, + task_request, + %{active_model: active_model}, + %{ + enqueue_failure: fn _agent, _parent_call_id, reason -> + send(parent, {:failure, reason}) + :ok + end, + run_interview_session: fn _agent, _opts -> + send(parent, :unexpected_live_interview) + :ok + end, + production_parent_call: fn _agent, _runtime, _parent_call_id -> + fn _payload -> {:error, :unexpected_parent_call} end + end, + mcp_url: fn -> "http://127.0.0.1:4000/mcp" end + } + ) + + wait_for(fn -> + snap = LoopBindings.pane_snapshot(agent) + + snap.wonder_tool && + snap.interview && + snap.interview.question == korean_question() && + Enum.map(snap.interview.question_options, & &1["label"]) == korean_option_labels() && + Map.get(snap.interview, :complete) == nil + end) + + assert_receive {:model_prompt, prompt}, 1_000 + assert prompt =~ goal + assert prompt =~ "ASK_USER " + refute_receive :unexpected_live_interview, 100 + refute_receive {:failure, _reason}, 100 + + LoopBindings.stop(agent) + end + + test "available MCP interview path is not masked by the local fallback" do + parent = self() + previous_url = System.get_env("OUROCODE_MCP_URL") + previous_autostart = System.get_env("OUROCODE_MCP_AUTOSTART") + + {:ok, socket} = + :gen_tcp.listen(0, [:binary, ip: {127, 0, 0, 1}, active: false, reuseaddr: true]) + + {:ok, port} = :inet.port(socket) + + System.put_env("OUROCODE_MCP_URL", "http://127.0.0.1:#{port}/mcp") + System.delete_env("OUROCODE_MCP_AUTOSTART") + + on_exit(fn -> + :gen_tcp.close(socket) + restore_env("OUROCODE_MCP_URL", previous_url) + restore_env("OUROCODE_MCP_AUTOSTART", previous_autostart) + end) + + {:ok, agent} = LoopBindings.start_link() + + active_model = %Model{ + id: :codex, + label: "Codex", + kind: :oauth, + status: :ready, + run: fn _prompt, _opts, _on_chunk -> + send(parent, :unexpected_local_model_run) + {:ok, "ASK_USER This should not be used?"} + end + } + + task_request = %TaskRequest{ + id: "pm-live-mcp", + source: :dashboard, + task_input: "ooo pm 카드 뉴스를 만들어주는 나만의 SaaS를 만들고 싶어", + submitted_at_ms: System.system_time(:millisecond), + routing_decision: %{ + kind: :ouroboros_workflow, + execution_route: :ouroboros_workflow, + runtime_source: :ouroboros, + transport: :streamable_http, + adapter_route: :pm, + requires_command_syntax?: false, + advanced_shortcut?: true, + reason: :advanced_shortcut + } + } + + assert :ok == + LoopBindingWorkflowDispatch.handle_prompt( + agent, + %{project_dir: File.cwd!()}, + task_request, + %{active_model: active_model}, + %{ + enqueue_failure: fn _agent, _parent_call_id, reason -> + send(parent, {:failure, reason}) + :ok + end, + run_interview_session: fn _agent, opts -> + send(parent, {:live_interview, opts}) + :ok + end, + production_parent_call: fn _agent, _runtime, parent_call_id -> + send(parent, {:parent_call_built, parent_call_id}) + fn _payload -> {:ok, %{response: "Which question?"}} end + end, + mcp_url: fn -> "http://127.0.0.1:#{port}/mcp" end + } + ) + + assert_receive {:parent_call_built, "parent-pm-live-mcp"}, 1_000 + + assert_receive {:live_interview, + [ + parent_call_id: "parent-pm-live-mcp", + initial_payload: %{"params" => %{"name" => "ouroboros_pm_interview"}}, + parent_call_fun: parent_call_fun, + model: %Model{id: :codex}, + workflow_run_id: "workflow-run:parent-pm-live-mcp", + project_dir: _ + ]}, + 1_000 + + assert is_function(parent_call_fun, 1) + refute_receive :unexpected_local_model_run, 100 + refute_receive {:failure, _reason}, 100 + + assert LoopBindings.pane_snapshot(agent).wonder_tool == nil + + LoopBindings.stop(agent) + end + + test "local PM fallback asks whether to continue instead of ending at the round limit" do + parent = self() + previous_autostart = System.get_env("OUROCODE_MCP_AUTOSTART") + System.put_env("OUROCODE_MCP_AUTOSTART", "0") + + on_exit(fn -> + if previous_autostart, + do: System.put_env("OUROCODE_MCP_AUTOSTART", previous_autostart), + else: System.delete_env("OUROCODE_MCP_AUTOSTART") + end) + + {:ok, agent} = LoopBindings.start_link() + {:ok, model_calls} = Agent.start_link(fn -> 0 end) + + active_model = %Model{ + id: :codex, + label: "Codex", + kind: :oauth, + status: :ready, + run: fn _prompt, _opts, on_chunk -> + call = + Agent.get_and_update(model_calls, fn count -> + next = count + 1 + {next, next} + end) + + response = """ + ASK_USER Question #{call}? + - Option #{call}A | first option + - Option #{call}B | second option + """ + + on_chunk.(response) + {:ok, response} + end + } + + task_request = %TaskRequest{ + id: "pm-local-fallback-continue", + source: :dashboard, + task_input: "ooo pm 카드 뉴스 SaaS", + submitted_at_ms: System.system_time(:millisecond), + routing_decision: %{ + kind: :ouroboros_workflow, + execution_route: :ouroboros_workflow, + runtime_source: :ouroboros, + transport: :streamable_http, + adapter_route: :pm, + requires_command_syntax?: false, + advanced_shortcut?: true, + reason: :advanced_shortcut + } + } + + assert :ok == + LoopBindingWorkflowDispatch.handle_prompt( + agent, + %{project_dir: File.cwd!()}, + task_request, + %{active_model: active_model}, + %{ + enqueue_failure: fn _agent, _parent_call_id, reason -> + send(parent, {:failure, reason}) + :ok + end, + run_interview_session: fn _agent, _opts -> + send(parent, :unexpected_live_interview) + :ok + end, + production_parent_call: fn _agent, _runtime, _parent_call_id -> + fn _payload -> {:error, :unexpected_parent_call} end + end, + mcp_url: fn -> "http://127.0.0.1:4000/mcp" end + } + ) + + for round <- 1..6 do + wait_for(fn -> + snap = LoopBindings.pane_snapshot(agent) + snap.interview && snap.interview.question == "Question #{round}?" + end) + + assert {:ok, _selection} = LoopBindings.answer_wonder(agent, 1) + end + + wait_for(fn -> + snap = LoopBindings.pane_snapshot(agent) + + snap.interview && + snap.interview.question == "Do you want to keep interviewing or generate the seed now?" && + Enum.map(snap.interview.question_options, & &1["label"]) == [ + "Continue interview", + "Generate seed now", + "Stop for now" + ] && + Map.get(snap.interview, :complete) == nil + end) + + assert {:ok, _selection} = LoopBindings.answer_wonder(agent, 1) + + wait_for(fn -> + snap = LoopBindings.pane_snapshot(agent) + snap.interview && snap.interview.question == "Question 7?" + end) + + refute_receive :unexpected_live_interview, 100 + refute_receive {:failure, _reason}, 100 + + Agent.stop(model_calls) + LoopBindings.stop(agent) + end + defp superpowers_capability do {:ok, capability} = Capability.new(%{ @@ -241,4 +670,56 @@ defmodule Ourocode.Runtime.LoopBindingWorkflowDispatchTest do defp model(id, label) do %Model{id: id, label: label, kind: :cli, status: :ready, run: fn _, _, _ -> :ok end} end + + defp restore_env(key, nil), do: System.delete_env(key) + defp restore_env(key, value), do: System.put_env(key, value) + + defp korean_goal do + "\uce74\ub4dc \ub274\uc2a4\ub97c \ub9cc\ub4e4\uc5b4\uc8fc\ub294 " <> + "\ub098\ub9cc\uc758 SaaS\ub97c \ub9cc\ub4e4\uace0 \uc2f6\uc5b4" + end + + defp korean_question do + "\uce74\ub4dc\ub274\uc2a4 SaaS\uc758 \uccab \uc0ac\uc6a9\uc790\ub294 " <> + "\ub204\uad6c\uc778\uac00\uc694?" + end + + defp korean_option_labels do + [ + "1\uc778 \ucc3d\uc5c5\uc790", + "\ub9c8\ucf00\ud305 \ud300", + "\uad50\uc721 \uc6b4\uc601\uc790" + ] + end + + defp korean_ask_user_response do + """ + ASK_USER #{korean_question()} + - 1\uc778 \ucc3d\uc5c5\uc790 | \ud63c\uc790 \ucf58\ud150\uce20 \uc81c\uc791\uacfc \ubc30\ud3ec\ub97c \ucc98\ub9ac\ud569\ub2c8\ub2e4 + - \ub9c8\ucf00\ud305 \ud300 | \uc5ec\ub7ec \ucea0\ud398\uc778\uc758 \uce74\ub4dc\ub274\uc2a4\ub97c \ud568\uaed8 \uad00\ub9ac\ud569\ub2c8\ub2e4 + - \uad50\uc721 \uc6b4\uc601\uc790 | \uac15\uc758\ub098 \ud559\uc2b5 \uc790\ub8cc\ub97c \uce74\ub4dc\ub274\uc2a4\ub85c \ubc14\uafc9\ub2c8\ub2e4 + """ + end + + defp wait_for(fun, timeout_ms \\ 1_000) do + deadline = System.monotonic_time(:millisecond) + timeout_ms + wait_for(fun, deadline, nil) + end + + defp wait_for(fun, deadline, last_value) do + case fun.() do + truthy when truthy not in [false, nil] -> + truthy + + value -> + if System.monotonic_time(:millisecond) >= deadline do + flunk( + "condition was not met before timeout; last value: #{inspect(last_value || value)}" + ) + else + Process.sleep(20) + wait_for(fun, deadline, value) + end + end + end end diff --git a/test/ourocode/runtime/loop_bindings_test.exs b/test/ourocode/runtime/loop_bindings_test.exs index 124721f..1168825 100644 --- a/test/ourocode/runtime/loop_bindings_test.exs +++ b/test/ourocode/runtime/loop_bindings_test.exs @@ -148,7 +148,8 @@ defmodule Ourocode.Runtime.LoopBindingsTest do production_parent_call: fn _agent, _runtime, _parent_call_id -> fn _payload -> {:error, :not_used_in_test} end end, - mcp_url: fn -> "http://127.0.0.1:4000/mcp" end + mcp_url: fn -> "http://127.0.0.1:4000/mcp" end, + local_interview_fallback?: false } cases = [ diff --git a/test/ourocode/runtime/mcp_daemon/process_test.exs b/test/ourocode/runtime/mcp_daemon/process_test.exs index c1ad724..fe11b23 100644 --- a/test/ourocode/runtime/mcp_daemon/process_test.exs +++ b/test/ourocode/runtime/mcp_daemon/process_test.exs @@ -26,6 +26,76 @@ defmodule Ourocode.Runtime.McpDaemon.ProcessTest do ] end + test "spawn_plan uses direct executable invocation for default Windows launches" do + exe = "C:\\Program Files\\Ouroboros\\ouroboros.exe" + args = ["mcp", "serve", "--port", "4322"] + + plan = + DaemonProcess.spawn_plan( + exe, + args, + 4322 + ) + + if windows?() do + assert plan.shell == exe + assert plan.args == args + else + assert plan.shell == (System.find_executable("sh") || "/bin/sh") + + assert plan.args == [ + "-c", + "exec \"$0\" \"$@\" >\"#{plan.log_path}\" 2>&1", + "C:\\Program Files\\Ouroboros\\ouroboros.exe", + "mcp", + "serve", + "--port", + "4322" + ] + end + end + + test "spawn_plan executes a real Windows executable path containing spaces" do + if windows?() do + exe = "C:\\Program Files\\Git\\bin\\bash.exe" + assert File.exists?(exe) + + plan = DaemonProcess.spawn_plan(exe, ["-lc", "printf spawn_plan_exec_ok"], 4323) + File.rm(plan.log_path) + + {status, output} = run_spawn_plan(plan) + log_output = if File.exists?(plan.log_path), do: File.read!(plan.log_path), else: "" + + assert status == 0 + assert output <> log_output == "spawn_plan_exec_ok" + end + after + File.rm(Path.join(System.tmp_dir!(), "ourocode-mcp-4323.log")) + end + + test "spawn_plan does not route explicit Windows cmd shell through broken command string" do + if windows?() do + exe = "C:\\Program Files\\Git\\bin\\bash.exe" + shell = "C:\\Windows\\System32\\cmd.exe" + assert File.exists?(exe) + assert File.exists?(shell) + + plan = DaemonProcess.spawn_plan(exe, ["-lc", "printf cmd_spawn_ok"], 54322, shell) + File.rm(plan.log_path) + + assert plan.shell == exe + assert plan.args == ["-lc", "printf cmd_spawn_ok"] + + {status, output} = run_spawn_plan(plan) + log_output = if File.exists?(plan.log_path), do: File.read!(plan.log_path), else: "" + + assert status == 0 + assert output <> log_output == "cmd_spawn_ok" + end + after + File.rm(Path.join(System.tmp_dir!(), "ourocode-mcp-54322.log")) + end + test "stop is a safe no-op for non-spawned handles" do assert :ok == DaemonProcess.stop(nil) assert :ok == DaemonProcess.stop(%{mode: :external, url: "http://x/mcp"}) @@ -33,24 +103,92 @@ defmodule Ourocode.Runtime.McpDaemon.ProcessTest do end test "stop reaps a spawned OS process" do + command = System.find_executable("erl") + assert is_binary(command) + erl_port = - Port.open({:spawn_executable, "/bin/sh"}, [ + Port.open({:spawn_executable, command}, [ :binary, :exit_status, :hide, - args: ["-c", "exec sleep 30"] + args: ["-noshell", "-eval", "timer:sleep(infinity)."] ]) {:os_pid, os_pid} = Port.info(erl_port, :os_pid) - assert {_out, 0} = System.cmd("kill", ["-0", Integer.to_string(os_pid)]) + assert os_process_alive?(os_pid) assert :ok == DaemonProcess.stop(%{mode: :spawned, port: erl_port, os_pid: os_pid, url: "u"}) - Process.sleep(150) + refute_os_process_alive(os_pid) + end + + defp refute_os_process_alive(os_pid) do + deadline = System.monotonic_time(:millisecond) + 1_500 + + unless wait_until_dead(os_pid, deadline) do + flunk("expected spawned OS process #{os_pid} to be reaped") + end + end + + defp wait_until_dead(os_pid, deadline) do + cond do + not os_process_alive?(os_pid) -> + true - assert {_err, code} = - System.cmd("kill", ["-0", Integer.to_string(os_pid)], stderr_to_stdout: true) + System.monotonic_time(:millisecond) >= deadline -> + false - assert code != 0 + true -> + Process.sleep(25) + wait_until_dead(os_pid, deadline) + end end + + defp os_process_alive?(os_pid) when is_integer(os_pid) and os_pid > 0 do + if windows?() do + tasklist_contains_pid?(os_pid) + else + {_output, code} = + System.cmd("kill", ["-0", Integer.to_string(os_pid)], stderr_to_stdout: true) + + code == 0 + end + end + + defp tasklist_contains_pid?(os_pid) do + {output, _code} = + System.cmd("tasklist", ["/FI", "PID eq #{os_pid}", "/NH"], stderr_to_stdout: true) + + output + |> String.split() + |> Enum.member?(Integer.to_string(os_pid)) + end + + defp run_spawn_plan(plan) do + port = + Port.open({:spawn_executable, plan.shell}, [ + :binary, + :exit_status, + :hide, + :stderr_to_stdout, + args: plan.args + ]) + + collect_port(port, []) + end + + defp collect_port(port, chunks) do + receive do + {^port, {:data, data}} -> + collect_port(port, [data | chunks]) + + {^port, {:exit_status, status}} -> + {status, chunks |> Enum.reverse() |> IO.iodata_to_binary()} + after + 2_000 -> + flunk("spawn_plan did not exit") + end + end + + defp windows?, do: match?({:win32, _name}, :os.type()) end diff --git a/test/ourocode/runtime/route_classifier_test.exs b/test/ourocode/runtime/route_classifier_test.exs index 892b654..f99af97 100644 --- a/test/ourocode/runtime/route_classifier_test.exs +++ b/test/ourocode/runtime/route_classifier_test.exs @@ -95,6 +95,19 @@ defmodule Ourocode.Runtime.RouteClassifierTest do } = RouteClassifier.routing_decision("Run Ouroboros workflow evolve") end + test "classifies natural product SaaS goals as PM interview workflow" do + assert %{ + kind: :ouroboros_workflow, + execution_route: :ouroboros_workflow, + runtime_source: :ouroboros, + transport: :auto, + requires_command_syntax?: false, + advanced_shortcut?: false, + reason: :product_goal_terms, + adapter_route: :pm + } = RouteClassifier.routing_decision("카드 뉴스를 만들어주는 나만의 SaaS를 만들고 싶어") + end + test "classifies explicit runtime shortcuts" do assert %{ execution_route: :runtime, diff --git a/test/ourocode/runtime/route_terms_test.exs b/test/ourocode/runtime/route_terms_test.exs index f4744fb..75f795c 100644 --- a/test/ourocode/runtime/route_terms_test.exs +++ b/test/ourocode/runtime/route_terms_test.exs @@ -66,6 +66,16 @@ defmodule Ourocode.Runtime.RouteTermsTest do assert RouteTerms.ouroboros_adapter_route(["ooo", "build", "me", "a", "thing"]) == :interview end + test "detects natural product goals for PM interview routing" do + input = "카드 뉴스를 만들어주는 나만의 SaaS를 만들고 싶어" + + assert RouteTerms.product_goal?(input, RouteTerms.tokens(input)) + assert RouteTerms.product_goal?("Build a SaaS that turns blog posts into card news", []) + + refute RouteTerms.product_goal?("Fix the renderer state bug", ["fix", "the", "renderer"]) + refute RouteTerms.product_goal?("git status", ["git", "status"]) + end + test "does not treat plain run commands as implicit Ouroboros workflow" do refute RouteTerms.ouroboros_workflow?(["run", "the", "unit", "tests"]) refute RouteTerms.ouroboros_workflow?(["git", "status"]) diff --git a/test/ourocode/runtime/router_test.exs b/test/ourocode/runtime/router_test.exs index 5df819d..9b78d05 100644 --- a/test/ourocode/runtime/router_test.exs +++ b/test/ourocode/runtime/router_test.exs @@ -42,6 +42,23 @@ defmodule Ourocode.Runtime.RouterTest do }} = Router.route("Run Ouroboros workflow evolve for plugin renderer") end + test "routes natural SaaS product goals to the visible PM interview" do + assert {:ok, + %Router{ + execution_route: :ouroboros_workflow, + runtime_source: :ouroboros, + adapter_route: :pm, + route_label: "Ouroboros PM interview", + message: "Ouroboros PM interview via Ouroboros using Auto transport", + routing_decision: %{ + adapter_route: :pm, + reason: :product_goal_terms, + advanced_shortcut?: false, + requires_command_syntax?: false + } + }} = Router.route("카드 뉴스를 만들어주는 나만의 SaaS를 만들고 싶어") + end + test "allows explicit diagnostics and test commands only as advanced shortcuts" do assert {:ok, %Router{ diff --git a/test/ourocode/terminal/interview_handoff_test.exs b/test/ourocode/terminal/interview_handoff_test.exs index b5047cd..f8aea82 100644 --- a/test/ourocode/terminal/interview_handoff_test.exs +++ b/test/ourocode/terminal/interview_handoff_test.exs @@ -6,12 +6,21 @@ defmodule Ourocode.Terminal.InterviewHandoffTest do test "prompt wraps a paused interview question and user message" do prompt = InterviewHandoff.prompt("Which provider?", "Compare Stripe and Toss") + refute prompt =~ "\r\n" assert prompt =~ "An interview checkpoint is paused" assert prompt =~ "Pending interview question:\nWhich provider?" assert prompt =~ "User message:\nCompare Stripe and Toss" assert prompt =~ "INTERVIEW_ANSWER: " end + test "prompt normalizes CRLF in handoff inputs" do + prompt = InterviewHandoff.prompt("Which\r\nprovider?", "Compare\r\nStripe and Toss") + + refute prompt =~ "\r\n" + assert prompt =~ "Pending interview question:\nWhich\nprovider?" + assert prompt =~ "User message:\nCompare\nStripe and Toss" + end + test "extract_answer reads the last explicit handoff line" do assert InterviewHandoff.extract_answer(""" Earlier thought. diff --git a/test/ourocode/terminal/interview_panel/dialogue_test.exs b/test/ourocode/terminal/interview_panel/dialogue_test.exs index e6fce69..36f0a9f 100644 --- a/test/ourocode/terminal/interview_panel/dialogue_test.exs +++ b/test/ourocode/terminal/interview_panel/dialogue_test.exs @@ -29,6 +29,17 @@ defmodule Ourocode.Terminal.InterviewPanel.DialogueTest do assert Dialogue.rows(state, true) == [{"Answer Known answer", :strong}] end + test "does not label completion notices as questions" do + state = %{ + dialogue: [ + %{role: :mcp, text: "AI interview fallback complete - run ooo seed when ready"}, + %{role: :user, text: "Accounts and sharing"} + ] + } + + assert Dialogue.rows(state, false) == [{"Answer Accounts and sharing", :strong}] + end + test "filters leaked internal router prompts from main dialogue" do state = %{ dialogue: [ @@ -40,6 +51,16 @@ defmodule Ourocode.Terminal.InterviewPanel.DialogueTest do assert Dialogue.rows(state, false) == [{"Answer Visible", :strong}] end + test "labels workflow commands as goals instead of answers" do + state = %{ + dialogue: [ + %{role: :user, text: "ooo pm build onboarding"} + ] + } + + assert Dialogue.rows(state, false) == [{"Goal ooo pm build onboarding", :strong}] + end + test "keeps only the most recent dialogue turns from newest-first state" do state = %{ dialogue: diff --git a/test/ourocode/terminal/interview_panel/question_ledger_test.exs b/test/ourocode/terminal/interview_panel/question_ledger_test.exs index 7479884..6e5dab5 100644 --- a/test/ourocode/terminal/interview_panel/question_ledger_test.exs +++ b/test/ourocode/terminal/interview_panel/question_ledger_test.exs @@ -83,6 +83,27 @@ defmodule Ourocode.Terminal.InterviewPanel.QuestionLedgerTest do assert {"- [pending] Q1 Current question", :strong} in rows end + test "does not render completion notices as pending questions" do + interview = %{ + complete: :local_preview, + dialogue: [ + %{role: :mcp, text: "AI interview fallback complete - run ooo seed when ready"}, + %{role: :user, text: "Accounts and sharing"}, + %{role: :mcp, text: "What should stay out of scope?"} + ] + } + + assert %{blocks: [%{question: "What should stay out of scope?", status: :answered}]} = + QuestionLedger.from_interview(interview) + + rows = QuestionLedger.rows(interview) + + refute Enum.any?( + rows, + &match?({"- [pending] Q1 AI interview fallback complete" <> _, _}, &1) + ) + end + test "rows keep previous answers above an active picker without duplicating current question" do interview = %{ dialogue: [ diff --git a/test/ourocode/terminal/interview_panel/text_test.exs b/test/ourocode/terminal/interview_panel/text_test.exs index 016159f..7e4cff0 100644 --- a/test/ourocode/terminal/interview_panel/text_test.exs +++ b/test/ourocode/terminal/interview_panel/text_test.exs @@ -12,6 +12,59 @@ defmodule Ourocode.Terminal.InterviewPanel.TextTest do assert Text.flatten_line("**Hello**\n\n`world`") == "Hello world" end + test "flatten_line keeps Korean PM option text while stripping unstable glyphs" do + option = + <<234, 176>> <> + " - " <> + hangul([0xC778]) <> + " " <> + hangul([0xD074, 0xB9AC, 0xC5D0, 0xC774, 0xD130]) <> + " | " <> + hangul([0xC778, 0xC2A4, 0xD0C0, 0xADF8, 0xB7A8]) <> + ", " <> + hangul([0xBE14, 0xB85C, 0xADF8]) <> + ", " <> + hangul([0xB274, 0xC2A4, 0xB808, 0xD130]) <> + hangul([0xC6A9]) <> + " " <> + hangul([0xCF58, 0xD150, 0xCE20]) <> + hangul([0xB97C]) <> + " " <> + hangul([0xD63C, 0xC790]) <> + " " <> + hangul([0xBE60, 0xB974, 0xAC8C]) <> + " " <> + hangul([0xB9CC, 0xB4DC, 0xB294]) <> + " " <> + hangul([0xC0AC, 0xB78C]) <> + " " <> + <<0x1F44B::utf8>> + + assert Text.flatten_line(option) == + "- " <> + hangul([0xC778]) <> + " " <> + hangul([0xD074, 0xB9AC, 0xC5D0, 0xC774, 0xD130]) <> + " | " <> + hangul([0xC778, 0xC2A4, 0xD0C0, 0xADF8, 0xB7A8]) <> + ", " <> + hangul([0xBE14, 0xB85C, 0xADF8]) <> + ", " <> + hangul([0xB274, 0xC2A4, 0xB808, 0xD130]) <> + hangul([0xC6A9]) <> + " " <> + hangul([0xCF58, 0xD150, 0xCE20]) <> + hangul([0xB97C]) <> + " " <> + hangul([0xD63C, 0xC790]) <> + " " <> + hangul([0xBE60, 0xB974, 0xAC8C]) <> + " " <> + hangul([0xB9CC, 0xB4DC, 0xB294]) <> + " " <> + hangul([0xC0AC, 0xB78C]) + end + test "plain_line only collapses whitespace" do assert Text.plain_line(" **Hello**\n world ") == "**Hello** world" end @@ -41,7 +94,9 @@ defmodule Ourocode.Terminal.InterviewPanel.TextTest do test "keeps deliberate Hangul word boundaries when the source has wider gaps" do first = hangul([0xC778, 0xD130, 0xBDF0]) second = hangul([0xD50C, 0xB85C, 0xC6B0]) - spaced = Enum.join(String.graphemes(first), " ") <> " " <> Enum.join(String.graphemes(second), " ") + + spaced = + Enum.join(String.graphemes(first), " ") <> " " <> Enum.join(String.graphemes(second), " ") assert Text.md_text(spaced) == first <> " " <> second end @@ -106,7 +161,8 @@ defmodule Ourocode.Terminal.InterviewPanel.TextTest do |> String.graphemes() |> Enum.join(" ") - assert Text.md_text(spaced) == Enum.join([first, second, third, fourth, fifth, sixth, seventh], " ") + assert Text.md_text(spaced) == + Enum.join([first, second, third, fourth, fifth, sixth, seventh], " ") end test "restores quality and execution phrase boundaries after compacting a model-spaced option" do diff --git a/test/ourocode/terminal/interview_panel_test.exs b/test/ourocode/terminal/interview_panel_test.exs index 3386a47..2465c9f 100644 --- a/test/ourocode/terminal/interview_panel_test.exs +++ b/test/ourocode/terminal/interview_panel_test.exs @@ -95,6 +95,29 @@ defmodule Ourocode.Terminal.InterviewPanelTest do assert InterviewPanel.mcp_activity_lines(result) == [] end + test "completed local preview does not keep router trace spinner running" do + result = %{ + interview: %{ + complete: :local_preview, + status: "interview complete: local_preview", + router: ["MCP daemon unavailable; using local PM interview fallback"], + dialogue: [ + %{role: :mcp, text: "local interview fallback complete - run ooo seed when ready"}, + %{role: :user, text: "Accounts and sharing"} + ] + } + } + + assert InterviewPanel.interview_working_lines(result, 0) == [] + assert {_, lines, _} = InterviewPanel.interview_block_lines(result, nil, 0) + + refute Enum.any?(lines, fn + {line, _style} -> String.contains?(line, "MCP daemon unavailable") + line when is_binary(line) -> String.contains?(line, "MCP daemon unavailable") + :rule -> false + end) + end + test "interview block strips MCP session preamble from dialogue rows" do result = %{ interview: %{ @@ -373,7 +396,7 @@ defmodule Ourocode.Terminal.InterviewPanelTest do line -> line end) - assert text =~ "Answer ooo interview validate plugin install flow" + assert text =~ "Goal ooo interview validate plugin install flow" assert text =~ "■⬝⬝ building the first question" refute text =~ "Answer in my own words" refute text =~ "[Free answer]"