diff --git a/.claude/plans/folder_size.md b/.claude/plans/folder_size.md new file mode 100644 index 00000000..4e61e0c3 --- /dev/null +++ b/.claude/plans/folder_size.md @@ -0,0 +1,27 @@ +# FolderSize follow-ups + +`El.Credo.FolderSize` enforces 3 ≤ .ex files per folder ≤ 13. + +## Config + +`.credo.exs`: + +```elixir +{El.Credo.FolderSize, [exempt: ["lib"], exempt_names: ["behaviours"]]} +``` + +- `exempt: ["lib"]` — package root (`lib/el.ex` only). +- `exempt_names: ["behaviours"]` — any folder with `behaviours` as a path segment. Behaviours dirs mirror their impl folder; refactor the impl, the behaviours follow. + +## Outstanding LEAVE-AS-FINDING folders + +These are real namespaces flagged by the check. Don't exempt — revisit when scope changes. + +| Folder | Files now | Note | +|---|---|---| +| `lib/el/cli/daemon` | 2 (connection.ex, env.ex; sibling `behaviours/`) | Real namespace, grows with daemon work. | +| `lib/el/platform` | 2 (code.ex, parser.ex; sibling `behaviours/`) | Same shape as cli/daemon. | +| `lib/el/session/claude` | 2 (driver.ex, opts.ex) | Cohesive but thin. Revisit when claude/ grows or shrinks. | +| `lib/el/session/commands` | 2 (ask.ex, tell.ex) | Command-pattern namespace, grows with commands. | + +Re-evaluate when any of these folders gain/lose a file. diff --git a/.claude/plans/help.md b/.claude/plans/help.md new file mode 100644 index 00000000..5f22b4d5 --- /dev/null +++ b/.claude/plans/help.md @@ -0,0 +1,21 @@ +# Help Plan + +Gap list between `features/help.feature` and current implementation. + +## Not Implemented + +- `Output.usage_message` layout doesn't match the grouped sectioned block in `features/help.feature` (info/log with `[-json]`, `start [args]` with sub-args, `cmds:` group). Tracked as H7. +- README + `~/.claude/skills/el/` docs don't mention `-json`. Tracked as H8. + +## Already Working + +- `el -v`, `el ls`, `el exit`, `el clear` +- `el start [-m model] [-a agent]` — explicit `start` keyword wired (H9) +- `el ` +- `el ` → info (alive ? info : usage) +- `el [-json]` → JSON info (alive: full shape; dead: `{name, alive: false}`) +- `el log [n|all]`, `el log all` +- `el log [n|all] [-json]` → JSON array of `{type, message, response, metadata}` (dead session → `[]`; H6/H11 pin) +- `el clear`, `el exit`, `el restart`, `el log [n|all]` +- `el restart` → daemon restart (kills daemon BEAM + all `claude` subprocesses; `Restorer.restore_sessions` rehydrates every session from DETS on boot — strict superset of "restart all sessions") +- Plain `el log` gated on `alive?` — dead session prints "No sessions running" instead of crashing (H10) diff --git a/.claude/settings.json b/.claude/settings.json index 0bcfb27a..42db3fbb 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,7 +1,28 @@ { "model": "opus", "permissions": { - "allow": ["Write", "Bash", "Bash(bundle exec cucumber*)"], - "deny": [] + "deny": ["Bash(* cucumber *)", "Bash(git push *)"] + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "jq -r '[.tool_input.file_path // \"\", .tool_input.new_string // \"\", .tool_input.content // \"\"] | @tsv' | awk -F'\t' '$1 ~ /\\.claude\\/settings(\\.local)?\\.json$/ && ($2 ~ /allow/ || $3 ~ /allow/) {print; exit}' | grep -q . && printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Blocked: Claude is not allowed to add allow permissions to .claude/settings.json\"}}' || true" + } + ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "cmd=$(jq -r '.tool_input.command // \"\"'); echo \"$cmd\" | grep -q '\\.claude/settings.*\\.json' && echo \"$cmd\" | grep -q 'allow' && printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Blocked: Claude is not allowed to modify allow permissions in .claude/settings.json\"}}' || true" + } + ] + } + ] } } diff --git a/.credo.exs b/.credo.exs index 478148dd..37e228e1 100644 --- a/.credo.exs +++ b/.credo.exs @@ -21,18 +21,8 @@ # You can give explicit globs or simply directories. # In the latter case `**/*.{ex,exs}` will be used. # - included: [ - "lib/", - "specs/", - "src/", - "test/", - "web/", - "apps/*/lib/", - "apps/*/src/", - "apps/*/test/", - "apps/*/web/" - ], - excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/", ~r"specs/"] + included: ["lib/"], + excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] }, # # Load and configure plugins here: @@ -42,7 +32,7 @@ # If you create your own checks, you must specify the source files for # them here, so they can be loaded by Credo before running the analysis. # - requires: ["credo_checks/line_check.ex", "credo_checks/max_function_lines.ex", "credo_checks/max_module_lines.ex"], + requires: ["credo_checks/line_check.ex", "credo_checks/max_function_lines.ex", "credo_checks/max_module_lines.ex", "credo_checks/folder_size.ex"], # # If you want to enforce a style guide and need a more traditional linting # experience, you can change `strict` to `true` below: @@ -76,6 +66,7 @@ {Credo.Check.Design.DuplicatedCode, mass_threshold: 40}, {El.Credo.MaxFunctionLines, [max_lines: 5]}, {El.Credo.MaxModuleLines, [max_lines: 100]}, + {El.Credo.FolderSize, [exempt: ["lib"], exempt_names: ["behaviours"]]}, {Credo.Check.Warning.Dbg, []}, {Credo.Check.Warning.IoInspect, []}, {Credo.Check.Warning.IExPry, []}, diff --git a/README.md b/README.md index bf31cb09..4bf53915 100644 --- a/README.md +++ b/README.md @@ -30,12 +30,22 @@ brew install limadelic/tap/el ``` > el -el -v version -el ls list sessions -el [-m ] [-a ] start or status -el send a msg -el log [n|all] view log (default: last 1) -el clear clear log -el exit exit session -el exit exit all sessions +el -v version +el ls list names +el [-json] info +el log [n|all] [-json] view log (default: last 1) + +el start [args] start session + args: + -m model + -a agent + +el send a msg + +el apply command to one or many +el apply command to all + cmds: + clear start new session + exit exit session + restart restart session ``` diff --git a/credo_checks/folder_size.ex b/credo_checks/folder_size.ex new file mode 100644 index 00000000..33bc7aaf --- /dev/null +++ b/credo_checks/folder_size.ex @@ -0,0 +1,63 @@ +defmodule El.Credo.FolderSize do + use Credo.Check, category: :refactor, base_priority: :normal, run_on_all: true + + alias Credo.SourceFile + alias Credo.Check.Params + alias Credo.IssueMeta + + def param_defaults do + [max: 13, min: 3, exempt: [], exempt_names: []] + end + + @check_desc "Folder structure is maintainable." + + def explanations do + [ + check: @check_desc, + params: [ + max: "Maximum .ex files allowed in a folder (default 13)", + min: "Minimum .ex files required in a folder (default 3)", + exempt: "List of folder paths to skip (default [])", + exempt_names: "List of folder names to skip (default [])" + ] + ] + end + + def run(%SourceFile{} = _source_file, _params) do + [] + end + + def run_on_all_source_files(exec, source_files, params) do + max = Params.get(params, :max, __MODULE__) + min = Params.get(params, :min, __MODULE__) + exempt = Params.get(params, :exempt, __MODULE__) + exempt_names = Params.get(params, :exempt_names, __MODULE__) + source_files + |> Enum.group_by(fn sf -> Path.dirname(sf.filename) end) + |> Enum.each(fn {folder, files} -> + skip? = folder in exempt or Enum.any?(Path.split(folder), &(&1 in exempt_names)) + unless skip? do + sf = hd(files) + issue_meta = IssueMeta.for(sf, params) + file_count = length(files) + + if file_count > max do + issue = format_issue(issue_meta, + message: "Folder #{folder} has #{file_count} .ex files (max: #{max})", + line_no: 1 + ) + Credo.Execution.ExecutionIssues.append(exec, sf, issue) + end + + if file_count < min do + issue = format_issue(issue_meta, + message: "Folder #{folder} has #{file_count} .ex files (min: #{min})", + line_no: 1 + ) + Credo.Execution.ExecutionIssues.append(exec, sf, issue) + end + end + end) + :ok + end +end diff --git a/features/agent.feature b/features/agent.feature index 1b511629..65bf9e4a 100644 --- a/features/agent.feature +++ b/features/agent.feature @@ -7,7 +7,7 @@ Feature: Agent support * > el kenny exit Scenario: Implicit agent detection from name - * > el kent: + * > el kent start: | agent | kent | | model | opus | * > el kent exit @@ -19,7 +19,7 @@ Feature: Agent support * > el kent exit Scenario: Lisa agent with sonnet model - * > el lisa: + * > el lisa start: | agent | lisa | | model | sonnet | * > el lisa exit diff --git a/features/card.feature b/features/card.feature index 4c760a14..af503671 100644 --- a/features/card.feature +++ b/features/card.feature @@ -17,10 +17,10 @@ Feature: Agent card * > el exit Examples: - | args | name | - | kent | kent | - | kento -a kent | kento | - | kent@el | kent@el | + | args | name | + | kent start | kent | + | kento start -a kent | kento | + | kent@el start | kent@el | @el_kent Scenario: Used @@ -41,7 +41,7 @@ Feature: Agent card * > el kent exit Scenario: Anom - * > el anom: + * > el anom start: """ ╭────────────────────────────────────────────────╮ │ name: anom id: … │ diff --git a/features/help.feature b/features/help.feature index 6c813151..cda1d1d9 100644 --- a/features/help.feature +++ b/features/help.feature @@ -3,19 +3,30 @@ Feature: Help Scenario Outline: * > el : """ - el v0.1. - el -v version - el ls list sessions - el [-m ] [-a ] start or status - el send a msg - el log [n|all] view log (default: last 1) - el clear clear log - el exit exit session - el exit exit all sessions - el restart restart daemon + el v0.1.* + + el -v version + el ls list names + el [-json] info + el log [n|all] [-json] view log (default: last 1) + + el start [args] start session + args: + -m model + -a agent + + el send a msg + + el apply command to one or many + el apply command to all + cmds: + clear start new session + exit exit session + restart restart session """ Examples: - | scenario | args | - | Help | | - | Usage | --nonsense | + | scenario | args | + | Help | | + | Usage not a flag | --noflag | + | Usage not an agent | nobody | diff --git a/features/support/el_helper.rb b/features/support/el_helper.rb index 6760b301..8036448b 100644 --- a/features/support/el_helper.rb +++ b/features/support/el_helper.rb @@ -21,7 +21,13 @@ def verify_docstring(args, expected) next if words.empty? words.each do |word| - unless output.downcase.include?(word.downcase) + matched = output.downcase.include?(word.downcase) || + begin + Regexp.new(word, Regexp::IGNORECASE).match?(output) + rescue RegexpError + false + end + unless matched missing_words << { word: word, source_line: expected_line } end end diff --git a/features/support/hooks.rb b/features/support/hooks.rb index 02d1846c..cedca05c 100644 --- a/features/support/hooks.rb +++ b/features/support/hooks.rb @@ -7,7 +7,7 @@ end Before do |scenario| - scenario.tags.map(&:name).grep(/^@el_(.+)$/) { el($1) } + scenario.tags.map(&:name).grep(/^@el_(.+)$/) { el("#{$1} start") } end After do |scenario| diff --git a/lib/el.ex b/lib/el.ex index fcfb9b4e..9b527ddb 100644 --- a/lib/el.ex +++ b/lib/el.ex @@ -62,37 +62,32 @@ defmodule El do end @impl true - def exit_pattern(pattern, opts \\ []) do - ls(opts) - |> Enum.filter(&match_pattern?(&1, pattern)) - |> Enum.each(&El.exit(&1, opts)) + def restart(name, opts \\ []) do + El.Lifecycle.exit(name, :restart, opts) + resume_session(name, opts) end - @impl true - def clear_pattern(pattern, opts \\ []) do - ls(opts) |> Enum.filter(&match_pattern?(&1, pattern)) |> Enum.each(&El.clear(&1, opts)) + defp resume_session(name, opts) do + session_meta = Keyword.get(opts, :session_meta, El.Session.Meta) + resume_with_session_meta(name, opts, session_meta.lookup(name)) end - @impl true - def log_pattern(pattern, count, opts \\ []), - do: - ls(opts) |> Enum.filter(&match_pattern?(&1, pattern)) |> Enum.flat_map(&log_entries(&1, count, opts)) - - defp log_entries(name, count, opts) do - name |> session_api(opts).log(count) |> filter_found() + defp resume_with_session_meta(name, opts, {:ok, session_id, agent, model}) do + resume_opts = [resume: session_id, agent: agent, model: model] ++ opts + start(name, resume_opts) end - defp filter_found(:not_found), do: [] - defp filter_found(entries), do: entries + @impl true + defdelegate restart_pattern(pattern, opts \\ []), to: El.Pattern, as: :restart - defp match_pattern?(name, pattern) do - name_str = Atom.to_string(name) - regex_pattern = pattern_to_regex(pattern) - Regex.match?(~r/^#{regex_pattern}$/, name_str) - end + @impl true + defdelegate exit_pattern(pattern, opts \\ []), to: El.Pattern, as: :exit + + @impl true + defdelegate clear_pattern(pattern, opts \\ []), to: El.Pattern, as: :clear - defp pattern_to_regex(pattern), - do: pattern |> String.replace("*", ".*") |> String.replace("?", ".") + @impl true + defdelegate log_pattern(pattern, count, opts \\ []), to: El.Pattern, as: :log @impl true def ls(opts \\ []) do diff --git a/lib/el/agent/detector.ex b/lib/el/agent/detector.ex index 6dd429fc..a24f8a6a 100644 --- a/lib/el/agent/detector.ex +++ b/lib/el/agent/detector.ex @@ -6,8 +6,8 @@ defmodule El.Agent.Detector do check_paths(fs, paths(name)) end - def cwd(fs \\ El.Infra.FileSystem) do - fs.cwd() + def cwd!(fs \\ El.Infra.FileSystem) do + fs.cwd!() end def mkdir_p!(path, fs \\ El.Infra.FileSystem) do diff --git a/lib/el/behaviours/el.ex b/lib/el/behaviours/el.ex index f0dffdec..f86527f3 100644 --- a/lib/el/behaviours/el.ex +++ b/lib/el/behaviours/el.ex @@ -15,10 +15,14 @@ defmodule El.Behaviours.El do @callback exit_pattern(term(), term()) :: term() @callback clear_pattern(term()) :: term() @callback clear_pattern(term(), term()) :: term() + @callback restart_pattern(term()) :: term() + @callback restart_pattern(term(), term()) :: term() @callback log_pattern(term(), term()) :: term() @callback log_pattern(term(), term(), term()) :: term() @callback ls() :: term() @callback agent(term()) :: term() @callback agent(term(), term()) :: term() + @callback restart(term()) :: term() + @callback restart(term(), term()) :: term() end diff --git a/lib/el/behaviours/pattern.ex b/lib/el/behaviours/pattern.ex new file mode 100644 index 00000000..b2b5090b --- /dev/null +++ b/lib/el/behaviours/pattern.ex @@ -0,0 +1,6 @@ +defmodule El.Behaviours.Pattern do + @callback restart(term(), term()) :: term() + @callback exit(term(), term()) :: term() + @callback clear(term(), term()) :: term() + @callback log(term(), term(), term()) :: term() +end diff --git a/lib/el/claude_port/behaviours/parser.ex b/lib/el/claude_port/behaviours/parser.ex index 2e24bbb1..806303b1 100644 --- a/lib/el/claude_port/behaviours/parser.ex +++ b/lib/el/claude_port/behaviours/parser.ex @@ -1,3 +1,3 @@ defmodule El.ClaudePort.Behaviours.Parser do - @callback try_extract_result(String.t(), String.t()) :: :incomplete | {:ok, term(), String.t()} + @callback normalize_keys(map()) :: map() end diff --git a/lib/el/claude_port/behaviours/parser_extract.ex b/lib/el/claude_port/behaviours/parser_extract.ex new file mode 100644 index 00000000..6363651d --- /dev/null +++ b/lib/el/claude_port/behaviours/parser_extract.ex @@ -0,0 +1,3 @@ +defmodule El.ClaudePort.Behaviours.ParserExtract do + @callback try_extract_result(String.t(), String.t()) :: :incomplete | {:ok, term(), String.t()} +end diff --git a/lib/el/claude_port/behaviours/parser/event_schema.ex b/lib/el/claude_port/behaviours/parser_extract/event_schema.ex similarity index 81% rename from lib/el/claude_port/behaviours/parser/event_schema.ex rename to lib/el/claude_port/behaviours/parser_extract/event_schema.ex index 4918c26c..86f2ce3f 100644 --- a/lib/el/claude_port/behaviours/parser/event_schema.ex +++ b/lib/el/claude_port/behaviours/parser_extract/event_schema.ex @@ -1,4 +1,4 @@ -defmodule El.ClaudePort.Behaviours.Parser.EventSchema do +defmodule El.ClaudePort.Behaviours.ParserExtract.EventSchema do @callback is_result_message(map()) :: boolean() @callback has_model(map()) :: boolean() @callback has_session_id(map()) :: boolean() diff --git a/lib/el/claude_port/behaviours/parser/result.ex b/lib/el/claude_port/behaviours/parser_extract/result.ex similarity index 82% rename from lib/el/claude_port/behaviours/parser/result.ex rename to lib/el/claude_port/behaviours/parser_extract/result.ex index 13c35d29..9861eb7a 100644 --- a/lib/el/claude_port/behaviours/parser/result.ex +++ b/lib/el/claude_port/behaviours/parser_extract/result.ex @@ -1,4 +1,4 @@ -defmodule El.ClaudePort.Behaviours.Parser.Result do +defmodule El.ClaudePort.Behaviours.ParserExtract.Result do @callback merge(map(), {any(), String.t() | nil, String.t() | nil}) :: {{any(), String.t() | nil, String.t() | nil}, boolean()} @callback finalize(any(), String.t() | nil, String.t() | nil, String.t()) :: diff --git a/lib/el/claude_port/connection/port_spawn.ex b/lib/el/claude_port/connection/port_spawn.ex index 5a66c1da..5f30eb2d 100644 --- a/lib/el/claude_port/connection/port_spawn.ex +++ b/lib/el/claude_port/connection/port_spawn.ex @@ -3,7 +3,7 @@ defmodule El.ClaudePort.Connection.PortSpawn do def spawn({:error, reason}, _state), do: {:error, reason} def spawn({:ok, {executable, args}}, state) do - find_and_spawn(:os.find_executable(String.to_charlist(executable)), executable, args, state) + find_and_spawn(executable_module().find(String.to_charlist(executable)), executable, args, state) end defp find_and_spawn(false, executable, _args, _state) do @@ -32,10 +32,14 @@ defmodule El.ClaudePort.Connection.PortSpawn do defp port_flags, do: [:binary, :exit_status, :stderr_to_stdout] defp env_charlist do - Enum.map(System.get_env(), &charlist_pair/1) + Enum.map(env_module().get(), &charlist_pair/1) end defp charlist_pair({k, v}) do {String.to_charlist(k), String.to_charlist(v)} end + + defp env_module, do: Application.get_env(:el, :env_module, El.Infra.Env) + + defp executable_module, do: Application.get_env(:el, :executable_module, El.Infra.Executable) end diff --git a/lib/el/claude_port/parser.ex b/lib/el/claude_port/parser.ex index d771e288..66729eb3 100644 --- a/lib/el/claude_port/parser.ex +++ b/lib/el/claude_port/parser.ex @@ -1,4 +1,6 @@ defmodule El.ClaudePort.Parser do + def normalize_keys(json), do: ClaudeCode.CLI.Parser.normalize_keys(json) + def try_extract_result(buffer, session_id) do apply_extraction(line_extractor().extract_all_lines(buffer, []), session_id) end @@ -39,7 +41,7 @@ defmodule El.ClaudePort.Parser do process_lines(rest, new_acc, session_id) end - defp cc_parser, do: Application.get_env(:el, :cc_parser, El.Platform.Parser) + defp cc_parser, do: Application.get_env(:el, :cc_parser, El.ClaudePort.Parser) defp json_decoder, do: Application.get_env(:el, :json_decoder, El.Infra.JSONDecoder) defp line_extractor, do: Application.get_env(:el, :line_extractor, El.ClaudePort.Parser.LineExtractor) defp result_module, do: Application.get_env(:el, :result_module, El.ClaudePort.Parser.Result) diff --git a/lib/el/claude_port/parser/event_schema.ex b/lib/el/claude_port/parser/event_schema.ex index 524d7b56..762f9b60 100644 --- a/lib/el/claude_port/parser/event_schema.ex +++ b/lib/el/claude_port/parser/event_schema.ex @@ -1,5 +1,5 @@ defmodule El.ClaudePort.Parser.EventSchema do - @behaviour El.ClaudePort.Behaviours.Parser.EventSchema + @behaviour El.ClaudePort.Behaviours.ParserExtract.EventSchema require Logger def is_result_message(%{"type" => "result"}), do: true diff --git a/lib/el/claude_port/parser/result.ex b/lib/el/claude_port/parser/result.ex index 88766ca7..be9380f0 100644 --- a/lib/el/claude_port/parser/result.ex +++ b/lib/el/claude_port/parser/result.ex @@ -1,7 +1,7 @@ defmodule El.ClaudePort.Parser.Result do alias El.ClaudePort.Parser.EventSchema - @behaviour El.ClaudePort.Behaviours.Parser.Result + @behaviour El.ClaudePort.Behaviours.ParserExtract.Result def merge(normalized, acc) do {build_acc(normalized, acc), EventSchema.is_result_message(normalized)} diff --git a/lib/el/claude_port/state.ex b/lib/el/claude_port/state.ex index 6ece109c..755696b7 100644 --- a/lib/el/claude_port/state.ex +++ b/lib/el/claude_port/state.ex @@ -38,6 +38,8 @@ defmodule El.ClaudePort.State do Map.new(@seam_defaults, fn {k, d} -> {k, Keyword.get(opts, k, d)} end) end - defp cwd(nil), do: File.cwd!() + defp cwd(nil), do: fs_module().cwd!() defp cwd(path), do: path + + defp fs_module, do: Application.get_env(:el, :fs_module, El.Infra.FileSystem) end diff --git a/lib/el/cli.ex b/lib/el/cli.ex index e7650f6d..7f5387d4 100644 --- a/lib/el/cli.ex +++ b/lib/el/cli.ex @@ -1,5 +1,5 @@ defmodule El.CLI do - alias El.CLI.{Router, Output, Log, Pattern, Start, Msg} + alias El.CLI.{Router, Output, Log, Pattern, Start, Msg, Json, Info} defp version do Application.spec(:el, :vsn) |> Output.format_version() @@ -7,6 +7,8 @@ defmodule El.CLI do defp el(opts), do: Keyword.fetch!(opts, :el_module) + defp daemon(opts), do: Keyword.get(opts, :daemon_module, El.CLI.Daemon) + def dispatch(args), do: dispatch(args, El.Deps.production()) def dispatch(args, opts) do @@ -26,6 +28,8 @@ defmodule El.CLI do Start.start_daemon_node_for(name, model, el(opts), El.Infra.Sleeper, opts) end + def execute(:start, [name, "start" | rest], deps), do: execute(:start, [name | rest], deps) + def execute(:start, [name], deps) do opts = Start.Options.merge_session_opts(name, nil, nil, deps) Start.handle_find_daemon_for_start(name, opts, el(deps), deps) @@ -48,10 +52,11 @@ defmodule El.CLI do maybe_print_card(status, name, opts, deps) end - def execute(:log, [name, "log"], opts), do: Log.execute_log(name, 1, el(opts), opts) - - def execute(:log_n, [name, "log", n], opts) do - Log.execute_log(name, Log.parse_log_count(n), el(opts), opts) + def execute(:log, [name, "log"], opts), do: Log.execute(name, opts) + def execute(:log_json, [name, "log"], opts), do: Json.execute_log(name, 1, el(opts), opts) + def execute(:log_n, [name, "log", n], opts), do: Log.execute_n(name, n, opts) + def execute(:log_n_json, [name, "log", n, "-json"], opts) do + Json.execute_log_n(name, n, opts) end def execute(:exit, [name, "exit"], opts) do @@ -67,6 +72,26 @@ defmodule El.CLI do IO.puts("exited all") end + def execute(:clear_all, ["clear"], opts) do + el(opts).clear_pattern("*", opts) + IO.puts("cleared all") + end + + def execute(:restart_daemon, ["restart"], opts) do + daemon(opts).restart_daemon(opts) + IO.puts("daemon restarted") + end + def execute(:info, args, deps), do: Info.execute(args, deps) + def execute(:info_json, args, deps), do: Info.execute_json(args, deps) + def execute(:restart, [name, "restart"], opts) do + Pattern.restart_by_kind(el(opts), Pattern.pattern?(name), name, opts) + end + def execute(:restart, [name], deps) do + name_atom = String.to_atom(name) + el(deps).restart(name_atom, deps) + Start.print_session_info(name, [], deps) + end + defp maybe_print_card(:created, name, opts, deps), do: Start.print_session_info(name, opts, deps) defp maybe_print_card(:already_running, _name, _opts, _deps), do: :ok end diff --git a/lib/el/cli/behaviours/daemon.ex b/lib/el/cli/behaviours/daemon.ex new file mode 100644 index 00000000..2ae0ab8c --- /dev/null +++ b/lib/el/cli/behaviours/daemon.ex @@ -0,0 +1,3 @@ +defmodule El.CLI.Behaviours.Daemon do + @callback restart_daemon(keyword()) :: :ok +end diff --git a/lib/el/cli/daemon.ex b/lib/el/cli/daemon.ex index 51948279..3f8a4412 100644 --- a/lib/el/cli/daemon.ex +++ b/lib/el/cli/daemon.ex @@ -1,95 +1,66 @@ defmodule El.CLI.Daemon do - def daemon_script do - :escript.script_name() |> to_string() |> Path.expand() + @behaviour El.CLI.Behaviours.Daemon + + defdelegate daemon_script, to: El.CLI.Daemon.Env + defdelegate daemon_node, to: El.CLI.Daemon.Env + defdelegate daemon_cookie, to: El.CLI.Daemon.Env + defdelegate dev?, to: El.CLI.Daemon.Env + defdelegate host, to: El.CLI.Daemon.Env + defdelegate remote_node, to: El.CLI.Daemon.Env + defdelegate naming_mode(h), to: El.CLI.Daemon.Env + + def stop_daemon(opts \\ []) + def stop_daemon(opts) when is_list(opts) do + %{rpc: rpc, sleeper: sleeper, node_monitor: node_monitor, env: env, disconnect_timeout: disconnect_timeout, disconnect_poll_ms: disconnect_poll_ms} = stop_daemon_deps(opts) + rpc.call(daemon_node(), :init, :stop, []) + wait_for_node_disconnect(node_monitor, sleeper, env, timeout: disconnect_timeout, poll_ms: disconnect_poll_ms) end - def daemon_node do - dev?() |> daemon_node_for() + defp stop_daemon_deps(opts) do + defaults = %{rpc: El.Infra.RPC, sleeper: El.Infra.Sleeper, node_monitor: El.Infra.NodeMonitor, env: El.CLI.Daemon.Env, disconnect_timeout: 5000, disconnect_poll_ms: 100} + Map.merge(defaults, Map.new(opts)) end - def connect_to_daemon(system \\ El.Infra.System, node_connector \\ El.Infra.NodeConnector, net_kernel \\ El.Infra.NetKernel) do - start_epmd(system) - start_client_node(net_kernel, node_connector) |> handle_client_started(system, node_connector) + @impl true + def restart_daemon(opts \\ []) + def restart_daemon(opts) when is_list(opts) do + stop_daemon(opts) + %{connection: connection} = restart_daemon_deps(opts) + connection.connect_to_daemon(opts) + :ok end - defp handle_client_started({:ok, _}, system, node_connector) do - ensure_daemon(system, node_connector) |> handle_daemon_ready() + defp restart_daemon_deps(opts) do + defaults = %{connection: El.CLI.Daemon.Connection} + Map.merge(defaults, Map.new(opts)) end - defp handle_client_started(_, _system, _node_connector), do: :local - - defp handle_daemon_ready(:ok), do: {:ok, daemon_node()} - defp handle_daemon_ready(_), do: :local - - def start_daemon_node(system \\ El.Infra.System, node_connector \\ El.Infra.NodeConnector, net_kernel \\ El.Infra.NetKernel) do - start_epmd(system) - net_kernel.start([daemon_node(), :longnames]) - node_connector.set_cookie(daemon_cookie()) - end - - def dev? do - dev_check(System.get_env("DEV")) - end - - def ensure_daemon(system \\ El.Infra.System, node_connector \\ El.Infra.NodeConnector) do - ensure_daemon_connected(node_connector.connect(daemon_node()), system, node_connector) - end - - defp daemon_node_for(true), do: :"el_dev@127.0.0.1" - defp daemon_node_for(false), do: :"el@127.0.0.1" - - defp daemon_cookie_for(true), do: :el_dev - defp daemon_cookie_for(false), do: :el - - defp daemon_cookie do - dev?() |> daemon_cookie_for() + defp wait_for_node_disconnect(node_monitor, sleeper, env, timeout: max_ms, poll_ms: poll_interval) do + state = %{node_monitor: node_monitor, sleeper: sleeper, start_ms: current_time_ms(), max_ms: max_ms, poll_interval: poll_interval, env: env} + wait_until_disconnected(state) end - defp dev_check(nil), do: script_is_relative() - defp dev_check(_), do: true - - defp script_is_relative do - :escript.script_name() |> to_string() |> Path.type() |> is_relative() + defp wait_until_disconnected(%{start_ms: start_ms, max_ms: max_ms}) when start_ms >= max_ms do + :ok end - defp is_relative(:relative), do: true - defp is_relative(_), do: false - - defp start_client_node(net_kernel, node_connector) do - id = System.unique_integer([:positive]) - start_node_with_id(id, net_kernel, node_connector) + defp wait_until_disconnected(%{node_monitor: node_monitor, env: env} = state) do + node_disconnected?(node_monitor, env) + |> continue_or_retry(state) end - defp start_node_with_id(id, net_kernel, node_connector) do - net_kernel.start([:"el-cli-#{id}@127.0.0.1", :longnames]) - |> maybe_set_cookie(node_connector) - end + defp continue_or_retry(true, _state), do: :ok - defp maybe_set_cookie({:ok, _}, node_connector) do - node_connector.set_cookie(daemon_cookie()) - {:ok, :started} + defp continue_or_retry(false, %{sleeper: sleeper, start_ms: start_ms, poll_interval: poll_interval} = state) do + sleeper.sleep(poll_interval) + wait_until_disconnected(%{state | start_ms: start_ms + poll_interval}) end - defp maybe_set_cookie(error, _node_connector), do: error - - defp ensure_daemon_connected(true, _system, _node_connector), do: :ok - defp ensure_daemon_connected(false, system, _node_connector), do: spawn_and_wait(system) - - defp spawn_and_wait(system) do - spawn_daemon(system) - El.CLI.DaemonConnector.wait_for_daemon(30) - end - - defp start_epmd(system) do - system.cmd("epmd", ["-daemon"]) + defp node_disconnected?(node_monitor, env) do + not Enum.member?(node_monitor.list(), env.daemon_node()) end - defp spawn_daemon(system) do - script = daemon_script() - prefix = dev?() |> env_prefix() - system.cmd("sh", ["-c", "#{prefix}#{script} --daemon > /dev/null 2>&1 &"]) + defp current_time_ms do + System.monotonic_time(:millisecond) end - - defp env_prefix(true), do: "DEV=1 " - defp env_prefix(false), do: "" end diff --git a/lib/el/cli/daemon/behaviours/connection.ex b/lib/el/cli/daemon/behaviours/connection.ex new file mode 100644 index 00000000..0869034e --- /dev/null +++ b/lib/el/cli/daemon/behaviours/connection.ex @@ -0,0 +1,5 @@ +defmodule El.CLI.Daemon.Behaviours.Connection do + @callback connect_to_daemon(keyword()) :: {:ok, atom()} | :local + @callback ensure_daemon(keyword()) :: :ok | {:error, term()} + @callback start_daemon_node(keyword()) :: :ok +end diff --git a/lib/el/cli/daemon/behaviours/env.ex b/lib/el/cli/daemon/behaviours/env.ex new file mode 100644 index 00000000..ae3fe170 --- /dev/null +++ b/lib/el/cli/daemon/behaviours/env.ex @@ -0,0 +1,6 @@ +defmodule El.CLI.Daemon.Behaviours.Env do + @callback daemon_script() :: String.t() + @callback daemon_node() :: atom() + @callback daemon_cookie() :: atom() + @callback dev?() :: boolean() +end diff --git a/lib/el/cli/daemon/connection.ex b/lib/el/cli/daemon/connection.ex new file mode 100644 index 00000000..08fd6381 --- /dev/null +++ b/lib/el/cli/daemon/connection.ex @@ -0,0 +1,112 @@ +defmodule El.CLI.Daemon.Connection do + @behaviour El.CLI.Daemon.Behaviours.Connection + + @impl true + def connect_to_daemon(opts \\ []) do + deps = prepare_deps(opts) + start_epmd(deps) + start_client_node(deps) + end + + @impl true + def ensure_daemon(opts \\ []) do + deps = prepare_deps(opts) + target = target_node(deps) + is_connected = deps.node_connector.connect(target) + ensure_daemon_result(is_connected, deps) + end + + defp ensure_daemon_result(true, _deps) do + :ok + end + + defp ensure_daemon_result(false, deps) do + fail_or_spawn(deps) + end + + defp fail_or_spawn(deps) do + if deps.env.remote_node() do + raise "remote daemon unreachable: #{deps.env.remote_node()}" + else + spawn_and_wait(deps) + end + end + + @impl true + def start_daemon_node(opts \\ []) do + deps = prepare_deps(opts) + start_epmd(deps) + env = deps.env + deps.net_kernel.start([El.CLI.Daemon.daemon_node(), env.naming_mode(env.host())]) + deps.node_connector.set_cookie(El.CLI.Daemon.daemon_cookie()) + end + + defp prepare_deps(opts) do + Map.merge(default_deps(), Map.new(opts)) + end + + defp default_deps do + %{system: El.Infra.System, node_connector: El.Infra.NodeConnector, net_kernel: El.Infra.NetKernel, env: El.CLI.Daemon.Env} + end + + defp start_client_node(deps) do + id = System.unique_integer([:positive]) + env = deps.env + h = env.host() + node_name = :"el-cli-#{id}@#{h}" + result = deps.net_kernel.start([node_name, env.naming_mode(h)]) + maybe_set_cookie_and_handle(result, deps) + end + + defp maybe_set_cookie_and_handle({:ok, _}, deps) do + deps.node_connector.set_cookie(El.CLI.Daemon.daemon_cookie()) + ensure_daemon_or_spawn(deps) + end + + defp maybe_set_cookie_and_handle(_error, _deps) do + :local + end + + defp ensure_daemon_or_spawn(deps) do + target = target_node(deps) + connected?(deps.node_connector.connect(target)) + |> handle_initial_connection(deps) + end + + defp target_node(deps) do + env = deps.env + case env.remote_node() do + nil -> El.CLI.Daemon.daemon_node() + remote -> String.to_atom(remote) + end + end + + defp connected?(true), do: :ok + defp connected?(false), do: :not_connected + + defp handle_initial_connection(:ok, _deps) do + {:ok, El.CLI.Daemon.daemon_node()} + end + + defp handle_initial_connection(:not_connected, deps) do + fail_or_spawn(deps) + end + + defp spawn_and_wait(deps) do + spawn_daemon(deps) + El.CLI.DaemonConnector.wait_for_daemon(30) + end + + defp start_epmd(deps) do + deps.system.cmd("epmd", ["-daemon"]) + end + + defp spawn_daemon(deps) do + script = El.CLI.Daemon.daemon_script() + prefix = El.CLI.Daemon.dev?() |> env_prefix() + deps.system.cmd("sh", ["-c", "#{prefix}#{script} --daemon > /dev/null 2>&1 &"]) + end + + defp env_prefix(true), do: "DEV=1 " + defp env_prefix(false), do: "" +end diff --git a/lib/el/cli/daemon/env.ex b/lib/el/cli/daemon/env.ex new file mode 100644 index 00000000..c04cb2c3 --- /dev/null +++ b/lib/el/cli/daemon/env.ex @@ -0,0 +1,46 @@ +defmodule El.CLI.Daemon.Env do + @behaviour El.CLI.Daemon.Behaviours.Env + + @impl true + def daemon_script do + :escript.script_name() |> to_string() |> Path.expand() + end + + @impl true + def daemon_node do + dev?() |> daemon_node_for(host()) + end + + @impl true + def daemon_cookie do + dev?() |> daemon_cookie_for() + end + + @impl true + def dev? do + dev_check(env_module().get("DEV")) + end + + def host, do: env_module().get("EL_HOST") || "127.0.0.1" + def remote_node, do: env_module().get("EL_NODE") + + def naming_mode(h), do: (if String.contains?(h, "."), do: :longnames, else: :shortnames) + + defp daemon_node_for(true, h), do: :"el_dev@#{h}" + defp daemon_node_for(false, h), do: :"el@#{h}" + + defp daemon_cookie_for(true), do: :el_dev + defp daemon_cookie_for(false), do: :el + + defp dev_check(nil), do: script_is_relative() + defp dev_check(_), do: true + + defp script_is_relative do + :escript.script_name() |> to_string() |> Path.type() |> is_relative() + end + + defp is_relative(:relative), do: true + defp is_relative(_), do: false + + defp env_module, do: Application.get_env(:el, :env_module, El.Infra.Env) +end diff --git a/lib/el/cli/daemon_connector.ex b/lib/el/cli/daemon_connector.ex index 27874f0b..e1e371c6 100644 --- a/lib/el/cli/daemon_connector.ex +++ b/lib/el/cli/daemon_connector.ex @@ -17,9 +17,16 @@ defmodule El.CLI.DaemonConnector do end defp retry_with_daemon_node(n, sleeper, connector) do - daemon_node = El.CLI.Daemon.daemon_node() + daemon_node = target_node() daemon_node |> connector.connect() |> check_connected(n, daemon_node, sleeper, connector) end + + defp target_node do + El.CLI.Daemon.remote_node() |> remote_or_local() + end + + defp remote_or_local(nil), do: El.CLI.Daemon.daemon_node() + defp remote_or_local(remote), do: String.to_atom(remote) end diff --git a/lib/el/cli/info.ex b/lib/el/cli/info.ex new file mode 100644 index 00000000..171916ae --- /dev/null +++ b/lib/el/cli/info.ex @@ -0,0 +1,34 @@ +defmodule El.CLI.Info do + alias El.CLI.{Start, Json, Output} + + def execute(args, deps) do + [name] = args + session_api = Keyword.get(deps, :session_api, El.Session.Api) + print_info(session_api.alive?(String.to_atom(name)), name, session_api, deps) + end + + def execute_json(args, deps) do + [name] = args + Json.execute_info(name, deps) + end + + defp print_info(true, name, session_api, deps) do + name_atom = String.to_atom(name) + info = session_api.info(name_atom) + opts = build_opts(session_api.agent(name_atom), info[:model]) + Start.print_session_info(name, opts, deps) + end + + defp print_info(false, _name, _session_api, _deps) do + IO.puts(Output.usage_message()) + end + + defp build_opts(agent, model) do + [] + |> maybe_put(:agent, agent) + |> maybe_put(:model, model) + end + + defp maybe_put(opts, _key, nil), do: opts + defp maybe_put(opts, key, value), do: [{key, value} | opts] +end diff --git a/lib/el/cli/json.ex b/lib/el/cli/json.ex new file mode 100644 index 00000000..0f7f60e1 --- /dev/null +++ b/lib/el/cli/json.ex @@ -0,0 +1,51 @@ +defmodule El.CLI.Json do + def execute_info(name, deps) do + api = Keyword.get(deps, :session_api, El.Session.Api) + print_info(api.alive?(String.to_atom(name)), name, deps) + end + + defp print_info(false, name, _deps) do + IO.puts(El.CLI.OutputJson.info(%{name: name, alive: false})) + end + + defp print_info(true, name, deps) do + api = Keyword.get(deps, :session_api, El.Session.Api) + data = build_alive_data(name, String.to_atom(name), api) + IO.puts(El.CLI.OutputJson.info(data)) + end + + defp build_alive_data(name, name_atom, api) do + api.info(name_atom) + |> Map.put(:name, name) + |> Map.put(:agent, api.agent(name_atom)) + |> Map.put(:alive, true) + end + + def execute_log(name, count, el_module, opts) do + api = Keyword.get(opts, :session_api, El.Session.Api) + do_execute_log(api.alive?(String.to_atom(name)), name, count, el_module, opts) + end + + defp do_execute_log(false, _name, _count, _el_module, _opts) do + IO.puts("[]") + end + + defp do_execute_log(true, name, count, el_module, opts) do + El.CLI.Log.log_for_name(name, count, el_module, opts) + |> encode_log() + |> IO.puts() + end + + def execute_log_n(name, n, opts) do + count = El.CLI.Log.parse_log_count(n) + execute_log(name, count, Keyword.fetch!(opts, :el_module), opts) + end + + defp encode_log(entries) do + entries |> Enum.map(&log_entry/1) |> Jason.encode!() + end + + defp log_entry({type, message, response, metadata}) do + %{type: type, message: message, response: response, metadata: metadata} + end +end diff --git a/lib/el/cli/log.ex b/lib/el/cli/log.ex index 4f5d463d..240fb891 100644 --- a/lib/el/cli/log.ex +++ b/lib/el/cli/log.ex @@ -4,6 +4,14 @@ defmodule El.CLI.Log do El.CLI.Output.handle_log_result(result, name) end + def execute(name, opts) do + execute_log(name, 1, Keyword.fetch!(opts, :el_module), opts) + end + + def execute_n(name, n, opts) do + execute_log(name, parse_log_count(n), Keyword.fetch!(opts, :el_module), opts) + end + def log_for_name(name, count, el_module, opts) when is_binary(name) do log_by_kind(pattern?(name), name, count, el_module, opts) end @@ -13,9 +21,16 @@ defmodule El.CLI.Log do end def log_by_kind(false, name, count, el_module, opts) do + api = Keyword.get(opts, :session_api, El.Session.Api) + do_log(api.alive?(String.to_atom(name)), name, count, el_module, opts) + end + + defp do_log(true, name, count, el_module, opts) do el_module.log(String.to_atom(name), count, opts) end + defp do_log(false, _name, _count, _el_module, _opts), do: :not_found + def parse_log_count("all"), do: :all def parse_log_count(n), do: String.to_integer(n) diff --git a/lib/el/cli/output.ex b/lib/el/cli/output.ex index 201b6a8e..210246b0 100644 --- a/lib/el/cli/output.ex +++ b/lib/el/cli/output.ex @@ -1,16 +1,26 @@ defmodule El.CLI.Output do - @usage_cmds [ - {"el VERSION", ""}, - {"el -v", "version"}, - {"el ls", "list sessions"}, - {"el [-m ] [-a ]", "start or status"}, - {"el ", "send a msg"}, - {"el log [n|all]", "view log (default: last 1)"}, - {"el clear", "clear log"}, - {"el exit", "exit session"}, - {"el exit", "exit all sessions"}, - {"el restart", "restart daemon"} - ] + @usage_template """ + el {{vsn}} + + el -v version + el ls list names + el [-json] info + el log [n|all] [-json] view log (default: last 1) + + el start [args] start session + args: + -m model + -a agent + + el send a msg + + el apply command to one or many + el apply command to all + cmds: + clear start new session + exit exit session + restart restart session + """ defp version do Application.spec(:el, :vsn) |> format_version() @@ -20,25 +30,17 @@ defmodule El.CLI.Output do def format_version(_), do: "v0.1.0" def usage_message do - cmds = @usage_cmds |> List.replace_at(0, {"el #{version()}", ""}) - pad = max_cmd_length(cmds) - Enum.map_join(cmds, "\n", &format_line(&1, pad)) + @usage_template |> String.replace("{{vsn}}", version()) |> String.trim_trailing() end - def show_sessions([]) do - end - - def show_sessions(names) do - Enum.each(names, &IO.puts/1) - end + def show_sessions([]), do: nil + def show_sessions(names), do: Enum.each(names, &IO.puts/1) def handle_not_found(name) do IO.puts("No sessions running. Start one: el #{name}") end - def handle_result(:not_found, name) do - handle_not_found(name) - end + def handle_result(:not_found, name), do: handle_not_found(name) def handle_result(response, _name) do IO.puts("") @@ -46,19 +48,13 @@ defmodule El.CLI.Output do IO.puts("") end - def handle_log_result(:not_found, name) do - handle_not_found(name) - end + def handle_log_result(:not_found, name), do: handle_not_found(name) def handle_log_result(log, _name) do - log - |> Enum.intersperse(:blank_line) - |> Enum.each(&print_log_item/1) + log |> Enum.intersperse(:blank_line) |> Enum.each(&print_log_item/1) end - defp print_log_item(:blank_line) do - IO.puts("") - end + defp print_log_item(:blank_line), do: IO.puts("") defp print_log_item({_type, message, "", _metadata}) do IO.puts("> #{message}") @@ -68,14 +64,4 @@ defmodule El.CLI.Output do IO.puts("> #{message}") IO.puts("> #{response}") end - - defp max_cmd_length(cmds) do - cmds |> Enum.map(fn {cmd, _} -> String.length(cmd) end) |> Enum.max() - end - - defp format_line({cmd, ""}, _pad), do: cmd - - defp format_line({cmd, desc}, pad) do - String.pad_trailing(cmd, pad) <> " " <> desc - end end diff --git a/lib/el/cli/output_json.ex b/lib/el/cli/output_json.ex new file mode 100644 index 00000000..15edf206 --- /dev/null +++ b/lib/el/cli/output_json.ex @@ -0,0 +1,7 @@ +defmodule El.CLI.OutputJson do + def info(%{alive: true} = data), do: Jason.encode!(data) + + def info(%{alive: false, name: name}) do + Jason.encode!(%{name: name, alive: false}) + end +end diff --git a/lib/el/cli/pattern.ex b/lib/el/cli/pattern.ex index cb8ec430..ba97d2ab 100644 --- a/lib/el/cli/pattern.ex +++ b/lib/el/cli/pattern.ex @@ -11,6 +11,9 @@ defmodule El.CLI.Pattern do def clear_by_kind(el_module, true, name, opts), do: clear_pattern(el_module, name, opts) def clear_by_kind(el_module, false, name, opts), do: clear_single(el_module, name, opts) + def restart_by_kind(el_module, true, name, opts), do: restart_pattern(el_module, name, opts) + def restart_by_kind(el_module, false, name, opts), do: restart_single(el_module, name, opts) + defp exit_pattern(el_module, name, opts) do el_module.exit_pattern(name, opts) IO.puts("exited sessions matching #{name}") @@ -30,4 +33,14 @@ defmodule El.CLI.Pattern do result = el_module.clear(String.to_existing_atom(name), opts) Output.handle_result(result, name) end + + defp restart_pattern(el_module, name, opts) do + el_module.restart_pattern(name, opts) + IO.puts("restarted sessions matching #{name}") + end + + defp restart_single(el_module, name, opts) do + el_module.restart(String.to_existing_atom(name), opts) + IO.puts("restarted #{name}") + end end diff --git a/lib/el/cli/router.ex b/lib/el/cli/router.ex index c23a4f08..4cfbc893 100644 --- a/lib/el/cli/router.ex +++ b/lib/el/cli/router.ex @@ -3,16 +3,21 @@ defmodule El.CLI.Router do def parse_route(["-v"]), do: :version def parse_route(["ls"]), do: :ls def parse_route(["exit"]), do: :exit_all + def parse_route(["restart"]), do: :restart_daemon def parse_route(["--daemon"]), do: :daemon_hub def parse_route(["--daemon", _name]), do: :daemon def parse_route(["--daemon", _name, "-m", _model]), do: :daemon + def parse_route([_name, "log", "-json"]), do: :log_json + def parse_route([_name, "log", _n, "-json"]), do: :log_n_json def parse_route([_name, "log", _n]), do: :log_n def parse_route([_name, "log"]), do: :log def parse_route([_name, "exit"]), do: :exit def parse_route([_name, "clear"]), do: :clear + def parse_route([_name, "restart"]), do: :restart + def parse_route(["clear"]), do: :clear_all def parse_route([<>]) when c != ?- do - :start + :info end def parse_route([<>, "-m", _model | _rest]) when c != ?- do @@ -23,6 +28,22 @@ defmodule El.CLI.Router do :start end + def parse_route([<>, "start"]) when c != ?- do + :start + end + + def parse_route([<>, "start", "-m", _model | _rest]) when c != ?- do + :start + end + + def parse_route([<>, "start", "-a", _agent | _rest]) when c != ?- do + :start + end + + def parse_route([<>, "-json"]) when c != ?- do + :info_json + end + def parse_route([<>, _word | _more_words]) when c != ?- do :msg end diff --git a/lib/el/cli/start.ex b/lib/el/cli/start.ex index 38b1e27b..05d6e0d8 100644 --- a/lib/el/cli/start.ex +++ b/lib/el/cli/start.ex @@ -7,7 +7,7 @@ defmodule El.CLI.Start do def handle_find_daemon_for_start(name, opts, el, deps \\ []) do name_atom = String.to_atom(name) el.start(name_atom, opts ++ deps) - DaemonHealth.ping_if_agent(name_atom, opts, deps) + DaemonHealth.ping_for_session_id(name_atom, opts, deps) print_session_info(name, opts, deps) end @@ -24,7 +24,7 @@ defmodule El.CLI.Start do def handle_find_daemon_with_rest(name, opts, rest, el, deps \\ []) do name_atom = String.to_atom(name) el.start(name_atom, opts ++ deps) - DaemonHealth.ping_if_agent(name_atom, opts, deps) + DaemonHealth.ping_for_session_id(name_atom, opts, deps) print_session_info(name, opts, deps) dispatch_rest(rest, name, opts) end diff --git a/lib/el/cli/start/daemon_health.ex b/lib/el/cli/start/daemon_health.ex index cf9a97b7..3cf2e38f 100644 --- a/lib/el/cli/start/daemon_health.ex +++ b/lib/el/cli/start/daemon_health.ex @@ -1,17 +1,22 @@ defmodule El.CLI.Start.DaemonHealth do - def ping_if_agent(name_atom, opts, deps) do - do_ping(name_atom, Keyword.get(opts, :agent), session_api(deps).info(name_atom), deps) + def ping_for_session_id(name_atom, _opts, deps) do + info = session_api(deps).info(name_atom) + agent = session_api(deps).agent(name_atom) + do_ping(name_atom, agent, info, deps) end - defp do_ping(_name_atom, nil, _info, _deps), do: :ok defp do_ping(_name_atom, _agent, %{messages: messages}, _deps) when messages > 0, do: :ok - defp do_ping(name_atom, _agent, _info, deps), do: quiet_ask(name_atom, deps) + defp do_ping(name_atom, nil, _info, deps), do: quiet_call(name_atom, deps, &probe/3) + defp do_ping(name_atom, _agent, _info, deps), do: quiet_call(name_atom, deps, &ask/3) + + defp probe(name_atom, message, deps), do: session_api(deps).probe_ask(name_atom, message) + defp ask(name_atom, message, deps), do: session_api(deps).ask(name_atom, message) # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity - defp quiet_ask(name_atom, deps) do + defp quiet_call(name_atom, deps, fun) do {original, null_device, gl} = redirect_to_null(deps) - ask_fn = fn -> session_api(deps).ask(name_atom, "who are you?") end - try(do: ask_fn.(), after: restore_io(gl, original, null_device)) + call_fn = fn -> fun.(name_atom, "who are you?", deps) end + try(do: call_fn.(), after: restore_io(gl, original, null_device)) end defp redirect_to_null(deps) do diff --git a/lib/el/cli/start/session_card.ex b/lib/el/cli/start/session_card.ex index 0d037943..fe4a2e65 100644 --- a/lib/el/cli/start/session_card.ex +++ b/lib/el/cli/start/session_card.ex @@ -6,6 +6,11 @@ defmodule El.CLI.Start.SessionCard do |> add_message_history(info) end + defp normalize_model("claude-opus-4-7"), do: "opus" + defp normalize_model("claude-sonnet-4-6"), do: "sonnet" + defp normalize_model("claude-haiku-4-5-20251001"), do: "haiku" + defp normalize_model(model), do: model + defp add_identity(rows, name, opts, info) do rows |> add_name_id(name, info.id) @@ -48,19 +53,20 @@ defmodule El.CLI.Start.SessionCard do end defp second_left_value(agent, _, _) when agent != nil, do: "agent: #{agent}" - defp second_left_value(nil, opts_model, _) when opts_model != nil, do: "model: #{opts_model}" - defp second_left_value(nil, nil, info_model) when info_model != nil, do: "model: #{info_model}" - defp second_left_value(_, _, _), do: nil + defp second_left_value(nil, _, _), do: nil - defp do_add_second_with_cwd(rows, nil, _cwd), do: rows + defp do_add_second_with_cwd(rows, nil, cwd) do + right = "cwd: #{cwd}" + rows ++ [card_box().frame_pair_row("", right)] + end defp do_add_second_with_cwd(rows, left, cwd) do right = "cwd: #{cwd}" rows ++ [card_box().frame_pair_row(left, right)] end defp add_model(rows, nil, nil), do: rows - defp add_model(rows, nil, info_model), do: rows ++ ["model: #{info_model}"] - defp add_model(rows, opts_model, _info_model), do: rows ++ ["model: #{opts_model}"] + defp add_model(rows, nil, info_model), do: rows ++ ["model: #{normalize_model(info_model)}"] + defp add_model(rows, opts_model, _info_model), do: rows ++ ["model: #{normalize_model(opts_model)}"] defp add_msgs(rows, 0), do: rows defp add_msgs(rows, count), do: rows ++ ["msgs: #{count}"] diff --git a/lib/el/infra/behaviours/env.ex b/lib/el/infra/behaviours/env.ex index c6905156..f976c9af 100644 --- a/lib/el/infra/behaviours/env.ex +++ b/lib/el/infra/behaviours/env.ex @@ -1,3 +1,4 @@ defmodule El.Infra.Behaviours.Env do + @callback get() :: [{String.t(), String.t()}] @callback get(String.t()) :: String.t() | nil end diff --git a/lib/el/infra/behaviours/executable.ex b/lib/el/infra/behaviours/executable.ex new file mode 100644 index 00000000..715b0eb8 --- /dev/null +++ b/lib/el/infra/behaviours/executable.ex @@ -0,0 +1,3 @@ +defmodule El.Infra.Behaviours.Executable do + @callback find(charlist()) :: charlist() | false +end diff --git a/lib/el/infra/behaviours/file_system.ex b/lib/el/infra/behaviours/file_system.ex index d5b9909f..393819ff 100644 --- a/lib/el/infra/behaviours/file_system.ex +++ b/lib/el/infra/behaviours/file_system.ex @@ -1,5 +1,5 @@ defmodule El.Infra.Behaviours.FileSystem do @callback exists?(String.t()) :: boolean() - @callback cwd() :: String.t() + @callback cwd!() :: String.t() @callback mkdir_p!(String.t()) :: :ok end diff --git a/lib/el/infra/behaviours/node_monitor.ex b/lib/el/infra/behaviours/node_monitor.ex new file mode 100644 index 00000000..5a5d8190 --- /dev/null +++ b/lib/el/infra/behaviours/node_monitor.ex @@ -0,0 +1,3 @@ +defmodule El.Infra.Behaviours.NodeMonitor do + @callback list() :: [node()] +end diff --git a/lib/el/infra/behaviours/rpc.ex b/lib/el/infra/behaviours/rpc.ex new file mode 100644 index 00000000..46eaeb3e --- /dev/null +++ b/lib/el/infra/behaviours/rpc.ex @@ -0,0 +1,3 @@ +defmodule El.Infra.Behaviours.RPC do + @callback call(node(), atom(), atom(), list()) :: term() +end diff --git a/lib/el/infra/env.ex b/lib/el/infra/env.ex index a303345c..182a9708 100644 --- a/lib/el/infra/env.ex +++ b/lib/el/infra/env.ex @@ -1,6 +1,9 @@ defmodule El.Infra.Env do @behaviour El.Infra.Behaviours.Env + @impl true + def get, do: System.get_env() + @impl true def get(name), do: System.get_env(name) end diff --git a/lib/el/infra/executable.ex b/lib/el/infra/executable.ex new file mode 100644 index 00000000..6e2f8399 --- /dev/null +++ b/lib/el/infra/executable.ex @@ -0,0 +1,6 @@ +defmodule El.Infra.Executable do + @behaviour El.Infra.Behaviours.Executable + + @impl true + def find(name), do: :os.find_executable(name) +end diff --git a/lib/el/infra/file_system.ex b/lib/el/infra/file_system.ex index 7f667a53..64f2fe55 100644 --- a/lib/el/infra/file_system.ex +++ b/lib/el/infra/file_system.ex @@ -1,14 +1,17 @@ defmodule El.Infra.FileSystem do @behaviour El.Infra.Behaviours.FileSystem + @impl true def exists?(path) do File.exists?(path) end - def cwd do + @impl true + def cwd! do File.cwd!() end + @impl true def mkdir_p!(path) do File.mkdir_p!(path) end diff --git a/lib/el/infra/node_monitor.ex b/lib/el/infra/node_monitor.ex new file mode 100644 index 00000000..c566ade8 --- /dev/null +++ b/lib/el/infra/node_monitor.ex @@ -0,0 +1,8 @@ +defmodule El.Infra.NodeMonitor do + @behaviour El.Infra.Behaviours.NodeMonitor + + @impl true + def list do + Node.list() + end +end diff --git a/lib/el/infra/rpc.ex b/lib/el/infra/rpc.ex new file mode 100644 index 00000000..1e33db8d --- /dev/null +++ b/lib/el/infra/rpc.ex @@ -0,0 +1,8 @@ +defmodule El.Infra.RPC do + @behaviour El.Infra.Behaviours.RPC + + @impl true + def call(node, module, function, args) do + :rpc.call(node, module, function, args) + end +end diff --git a/lib/el/lifecycle.ex b/lib/el/lifecycle.ex index 79f51d5e..28daa26a 100644 --- a/lib/el/lifecycle.ex +++ b/lib/el/lifecycle.ex @@ -32,6 +32,10 @@ defmodule El.Lifecycle do El.Deps.monitor(opts).wait_for_down(ref, name, Keyword.put(opts, :app, app)) end + defp delete_stores(_name, :restart, _opts) do + :ok + end + defp delete_stores(name, reason, opts) when reason in [:normal, :shutdown] do app = Keyword.fetch!(opts, :app) app.delete_session_messages(name, opts) diff --git a/lib/el/pattern.ex b/lib/el/pattern.ex new file mode 100644 index 00000000..1ae4ed5c --- /dev/null +++ b/lib/el/pattern.ex @@ -0,0 +1,49 @@ +defmodule El.Pattern do + @behaviour El.Behaviours.Pattern + + @impl true + def restart(pattern, opts) do + el = Keyword.get(opts, :el, El) + ls(opts) |> Enum.filter(&match_pattern?(&1, pattern)) |> Enum.each(&el.restart(&1, opts)) + end + + @impl true + def exit(pattern, opts) do + el = Keyword.get(opts, :el, El) + ls(opts) |> Enum.filter(&match_pattern?(&1, pattern)) |> Enum.each(&el.exit(&1, opts)) + end + + @impl true + def clear(pattern, opts) do + el = Keyword.get(opts, :el, El) + ls(opts) |> Enum.filter(&match_pattern?(&1, pattern)) |> Enum.each(&el.clear(&1, opts)) + end + + @impl true + def log(pattern, count, opts) do + ls(opts) |> Enum.filter(&match_pattern?(&1, pattern)) |> Enum.flat_map(&log_entries(&1, count, opts)) + end + + defp log_entries(name, count, opts) do + name |> session_api(opts).log(count) |> filter_found() + end + + defp filter_found(:not_found), do: [] + defp filter_found(entries), do: entries + + defp match_pattern?(name, pattern) do + name_str = Atom.to_string(name) + regex_pattern = pattern_to_regex(pattern) + Regex.match?(~r/^#{regex_pattern}$/, name_str) + end + + defp pattern_to_regex(pattern), + do: pattern |> String.replace("*", ".*") |> String.replace("?", ".") + + defp session_api(opts), do: Keyword.fetch!(opts, :session_api) + + defp ls(opts) do + session_registry = Application.get_env(:el, :session_registry, El.Session.Registry) + session_registry.list(opts) + end +end diff --git a/lib/el/platform/code.ex b/lib/el/platform.ex similarity index 98% rename from lib/el/platform/code.ex rename to lib/el/platform.ex index 3167adfb..d19c2695 100644 --- a/lib/el/platform/code.ex +++ b/lib/el/platform.ex @@ -1,4 +1,4 @@ -defmodule El.Platform.Code do +defmodule El.Platform do @default_session_module ClaudeCode.Session @default_setting_sources ["user", "project", "local"] diff --git a/lib/el/platform/behaviours/parser.ex b/lib/el/platform/behaviours/parser.ex deleted file mode 100644 index a496ef2f..00000000 --- a/lib/el/platform/behaviours/parser.ex +++ /dev/null @@ -1,3 +0,0 @@ -defmodule El.Platform.Behaviours.Parser do - @callback normalize_keys(map()) :: map() -end diff --git a/lib/el/platform/parser.ex b/lib/el/platform/parser.ex deleted file mode 100644 index 667164f3..00000000 --- a/lib/el/platform/parser.ex +++ /dev/null @@ -1,5 +0,0 @@ -defmodule El.Platform.Parser do - @behaviour El.Platform.Behaviours.Parser - @impl true - def normalize_keys(json), do: ClaudeCode.CLI.Parser.normalize_keys(json) -end diff --git a/lib/el/session.ex b/lib/el/session.ex index d456d575..1475f1d4 100644 --- a/lib/el/session.ex +++ b/lib/el/session.ex @@ -12,8 +12,8 @@ defmodule El.Session do @impl true def init({name, opts}) do Process.flag(:trap_exit, true) - {session_id, rest} = El.Session.Id.extract_resume_or_id(opts) - cwd = file_system(opts).cwd() + {session_id, rest} = Keyword.pop(opts, :resume) + cwd = file_system(opts).cwd!() {:ok, state_module(opts).build(name, opts, rest, session_id, cwd), {:continue, :start_claude}} end @@ -40,6 +40,11 @@ defmodule El.Session do Call.handle(msg, from, state) end + @impl true + def handle_call({:probe_ask, _} = msg, from, state) do + Call.handle(msg, from, state) + end + @impl true def handle_call(:log, _from, state) do Log.handle_log(:log, state) diff --git a/lib/el/session/api.ex b/lib/el/session/api.ex index 9056d758..6c722e98 100644 --- a/lib/el/session/api.ex +++ b/lib/el/session/api.ex @@ -20,6 +20,10 @@ defmodule El.Session.Api do GenServer.call(Registry.via_tuple(name), {:ask, message}, :infinity) end + def probe_ask(name, message) do + GenServer.call(Registry.via_tuple(name), {:probe_ask, message}, :infinity) + end + def log(name) do GenServer.call(Registry.via_tuple(name), :log, :infinity) end diff --git a/lib/el/session/behaviours/session.ex b/lib/el/session/behaviours/session.ex index 047d52c0..48dccbef 100644 --- a/lib/el/session/behaviours/session.ex +++ b/lib/el/session/behaviours/session.ex @@ -1,10 +1,12 @@ defmodule El.Session.Behaviours.Session do @callback tell(term(), term()) :: term() @callback ask(term(), term()) :: term() + @callback probe_ask(term(), term()) :: term() @callback log(term()) :: term() @callback log(term(), term()) :: term() @callback clear(term()) :: term() @callback agent(term()) :: term() @callback info(term()) :: term() @callback cast_store_relay(term(), term(), term()) :: term() + @callback alive?(term()) :: boolean() end diff --git a/lib/el/session/commands/ask.ex b/lib/el/session/handlers/ask.ex similarity index 63% rename from lib/el/session/commands/ask.ex rename to lib/el/session/handlers/ask.ex index e52a4b10..0d7759ed 100644 --- a/lib/el/session/commands/ask.ex +++ b/lib/el/session/handlers/ask.ex @@ -1,4 +1,4 @@ -defmodule El.Session.Commands.Ask do +defmodule El.Session.Handlers.Ask do @behaviour El.Session.Behaviours.Ask alias El.Session.Handlers.Router @@ -12,18 +12,36 @@ defmodule El.Session.Commands.Ask do Store.store_ask_immediate(new_state, message, valid_routes) end + def prepare_probe(state, from, _message) do + ref = make_ref() + new_state = %{state | pending_calls: [from | state.pending_calls]} + {ref, new_state} + end + def spawn_ask(state, ask_info, valid_routes, server_pid) do state.task_module.start(fn -> spawn_ask_task(state, ask_info, valid_routes, server_pid) end) end + def spawn_probe(state, ask_info, valid_routes, server_pid) do + state.task_module.start(fn -> + spawn_probe_task(state, ask_info, valid_routes, server_pid) + end) + end + defp spawn_ask_task(state, ask_info, valid_routes, server_pid) do {from, message, ref} = ask_info {response, model, session_id} = state.claude_session.ask_work(state.claude_pid, message, valid_routes) GenServer.cast(server_pid, {:complete_ask, from, message, response, ref, model, session_id}) end + defp spawn_probe_task(state, ask_info, valid_routes, server_pid) do + {from, message, ref} = ask_info + {response, model, session_id} = state.claude_session.ask_work(state.claude_pid, message, valid_routes) + GenServer.cast(server_pid, {:complete_probe, from, message, response, ref, model, session_id}) + end + def finalize_ask(state, %{from: from, ref: ref, message: message, response: response, model: model} = ask) do Store.delete_ask_entry(state, message, ref) Store.store_ask_entry(state, {"ask", message, response, metadata_for(model)}) @@ -31,12 +49,22 @@ defmodule El.Session.Commands.Ask do finalize_ask_state(state, ask) end + def finalize_probe(state, %{from: from, response: response} = ask) do + Driver.safe_reply(from, response) + finalize_probe_state(state, ask) + end + defp finalize_ask_state(state, %{from: from, ref: ref, message: message, response: response, model: model}) do new_messages = Store.replace_ask(state.messages, ref, message, response, model) new_pending = List.delete(state.pending_calls, from) %{state | messages: new_messages, pending_calls: new_pending} end + defp finalize_probe_state(state, %{from: from}) do + new_pending = List.delete(state.pending_calls, from) + %{state | pending_calls: new_pending} + end + defp metadata_for(nil), do: %{} defp metadata_for(model), do: %{model: model} @@ -52,9 +80,8 @@ defmodule El.Session.Commands.Ask do end defp start_new_session(state) do - session_id = El.Session.Id.generate_session_id() - opts = Keyword.put(state.opts, :session_id, session_id) + opts = Keyword.delete(state.opts, :session_id) pid = Driver.start(state.claude_module, opts) - %{state | claude_pid: pid, claude_opts: opts, session_id: session_id} + %{state | claude_pid: pid, claude_opts: opts, session_id: nil} end end diff --git a/lib/el/session/handlers/call.ex b/lib/el/session/handlers/call.ex index 63bd1480..1ccd206b 100644 --- a/lib/el/session/handlers/call.ex +++ b/lib/el/session/handlers/call.ex @@ -10,6 +10,14 @@ defmodule El.Session.Handlers.Call do {:noreply, ask_state} end + def handle({:probe_ask, message}, from, state) do + state = Driver.maybe_respawn_claude(state) + {ref, ask_state} = state.ask_module.prepare_probe(state, from, message) + routes = Router.detect_routes(message) + ask_state.ask_module.spawn_probe(ask_state, {from, message, ref}, routes, self()) + {:noreply, ask_state} + end + def handle(:agent, _from, state) do {:reply, Keyword.get(state.opts, :agent), state} end diff --git a/lib/el/session/handlers/cast.ex b/lib/el/session/handlers/cast.ex index b064266f..fbbb1e96 100644 --- a/lib/el/session/handlers/cast.ex +++ b/lib/el/session/handlers/cast.ex @@ -1,6 +1,6 @@ defmodule El.Session.Handlers.Cast do alias El.Session.Claude.Driver - alias El.Session.Commands.Tell + alias El.Session.Handlers.Tell alias El.Session.Store alias El.Session.Handlers.Router @@ -30,6 +30,20 @@ defmodule El.Session.Handlers.Cast do {:noreply, updated_state} end + def handle({:complete_probe, from, _message, response, ref, _model, nil}, state) do + probe = %{from: from, ref: ref, response: response} + finalized = state.ask_module.finalize_probe(state, probe) + {:noreply, finalized} + end + + def handle({:complete_probe, from, _message, response, ref, model, session_id}, state) do + probe = %{from: from, ref: ref, response: response} + finalized = state.ask_module.finalize_probe(state, probe) + updated_state = %{finalized | session_id: session_id} + persist_session_meta(updated_state, session_id, model) + {:noreply, updated_state} + end + def handle({:cast_store_relay, message, response}, state) do entry = {"relay", message, response, %{from: state.name}} state.store_module.store_message(state.name, entry, message_store: state.opts[:message_store]) diff --git a/lib/el/session/commands/tell.ex b/lib/el/session/handlers/tell.ex similarity index 96% rename from lib/el/session/commands/tell.ex rename to lib/el/session/handlers/tell.ex index d7890418..9edbaa00 100644 --- a/lib/el/session/commands/tell.ex +++ b/lib/el/session/handlers/tell.ex @@ -1,4 +1,4 @@ -defmodule El.Session.Commands.Tell do +defmodule El.Session.Handlers.Tell do alias El.Session.Handlers.Router alias El.Session.Store diff --git a/lib/el/session/id.ex b/lib/el/session/id.ex deleted file mode 100644 index 6fd03317..00000000 --- a/lib/el/session/id.ex +++ /dev/null @@ -1,20 +0,0 @@ -defmodule El.Session.Id do - def generate_session_id do - <> = :crypto.strong_rand_bytes(16) - uuid_bytes = <> - Base.encode16(uuid_bytes, case: :lower) |> format_uuid() - end - - def format_uuid(hex) do - <> = hex - Enum.join([a, b, c, d, e], "-") - end - - def session_id(nil), do: generate_session_id() - def session_id(id), do: id - - def extract_resume_or_id(opts) do - {resume, rest} = Keyword.pop(opts, :resume) - {resume, rest} - end -end diff --git a/lib/el/session/meta.ex b/lib/el/session/meta.ex index 541cbc05..cfaf0a32 100644 --- a/lib/el/session/meta.ex +++ b/lib/el/session/meta.ex @@ -18,6 +18,7 @@ defmodule El.Session.Meta do end defp match_session([{_name, session_id, agent, model}]), do: {:ok, session_id, agent, model} + defp match_session([{_name, session_id, agent, model} | _]), do: {:ok, session_id, agent, model} defp match_session([]), do: {:error, :not_found} def delete(name, deps \\ []) do diff --git a/lib/el/session/state.ex b/lib/el/session/state.ex index 1df12f1a..9a799dd9 100644 --- a/lib/el/session/state.ex +++ b/lib/el/session/state.ex @@ -3,7 +3,7 @@ defmodule El.Session.State do claude_module: El.ClaudePort, claude_session: El.Session.Claude.Driver, task_module: Task, - ask_module: El.Session.Commands.Ask, + ask_module: El.Session.Handlers.Ask, alive_fn: &El.Session.Api.alive?/1, registry_module: El.Session.Registry, store_module: El.MessageStore.Facade, diff --git a/mix.exs b/mix.exs index 7620ca84..972a51a5 100644 --- a/mix.exs +++ b/mix.exs @@ -52,6 +52,7 @@ defmodule El.MixProject do defp deps do [ {:claude_code, "~> 0.36"}, + {:jason, "~> 1.4"}, {:mox, "~> 1.0", only: :test}, {:excoveralls, "~> 0.18", only: :test}, {:credo, "~> 1.7", runtime: false} diff --git a/specs/credo_checks/folder_size_spec.exs b/specs/credo_checks/folder_size_spec.exs new file mode 100644 index 00000000..23c38dc4 --- /dev/null +++ b/specs/credo_checks/folder_size_spec.exs @@ -0,0 +1,34 @@ +defmodule El.Credo.FolderSize.Spec do + use ExUnit.Case + + setup_all do + Code.require_file("credo_checks/folder_size.ex") + :ok + end + + test "module exports run_on_all_source_files/3" do + assert function_exported?(El.Credo.FolderSize, :run_on_all_source_files, 3) + end + + test "run_on_all?/0 returns true" do + assert El.Credo.FolderSize.run_on_all?() == true + end + + test "param_defaults includes max, min, exempt, and exempt_names" do + defaults = El.Credo.FolderSize.param_defaults() + assert Keyword.has_key?(defaults, :max) + assert Keyword.has_key?(defaults, :min) + assert Keyword.has_key?(defaults, :exempt) + assert Keyword.has_key?(defaults, :exempt_names) + assert defaults[:max] == 13 + assert defaults[:min] == 3 + assert defaults[:exempt] == [] + assert defaults[:exempt_names] == [] + end + + test "exempt_names matches any path segment, not just basename" do + folder = "lib/el/claude_port/behaviours/parser" + exempt_names = ["behaviours"] + assert Enum.any?(Path.split(folder), &(&1 in exempt_names)) + end +end diff --git a/specs/el/claude_port/connection/port_spawn_spec.exs b/specs/el/claude_port/connection/port_spawn_spec.exs new file mode 100644 index 00000000..52174274 --- /dev/null +++ b/specs/el/claude_port/connection/port_spawn_spec.exs @@ -0,0 +1,105 @@ +defmodule El.ClaudePort.Connection.PortSpawn.Spec do + use ExUnit.Case + import Mox + + setup_all do + Code.ensure_loaded!(El.ClaudePort.Connection.PortSpawn) + Code.ensure_loaded!(El.Infra.Behaviours.Env) + Code.ensure_loaded!(El.Infra.Behaviours.Executable) + :ok + end + + setup :verify_on_exit! + + describe "El.ClaudePort.Connection.PortSpawn.spawn" do + setup do + Application.put_env(:el, :executable_module, El.MockExecutable) + on_exit(fn -> Application.delete_env(:el, :executable_module) end) + :ok + end + + test "calls Env.get/0 to read environment variables" do + expect(El.MockEnv, :get, fn -> %{} end) + stub(El.MockPort, :open, fn _, _ -> {:ok, :port} end) + stub(El.MockExecutable, :find, fn _ -> '/bin/echo' end) + + Application.put_env(:el, :env_module, El.MockEnv) + state = %{cwd: "/tmp", port_module: El.MockPort} + El.ClaudePort.Connection.PortSpawn.spawn({:ok, {"echo", []}}, state) + Application.delete_env(:el, :env_module) + end + + test "returns ok port tuple from Port.open" do + stub(El.MockEnv, :get, fn -> %{} end) + expect(El.MockPort, :open, fn _, _ -> {:ok, :port} end) + stub(El.MockExecutable, :find, fn _ -> '/bin/echo' end) + + Application.put_env(:el, :env_module, El.MockEnv) + state = %{cwd: "/tmp", port_module: El.MockPort} + result = El.ClaudePort.Connection.PortSpawn.spawn({:ok, {"echo", []}}, state) + Application.delete_env(:el, :env_module) + + assert result == {:ok, :port} + end + + test "passes env vars to Port.open opts" do + env_map = %{"FOO" => "BAR", "BAZ" => "QUX"} + stub(El.MockEnv, :get, fn -> env_map end) + expect(El.MockPort, :open, fn _, opts -> + send(self(), {:port_opts, opts}) + {:ok, :port} + end) + stub(El.MockExecutable, :find, fn _ -> '/bin/echo' end) + + Application.put_env(:el, :env_module, El.MockEnv) + state = %{cwd: "/tmp", port_module: El.MockPort} + El.ClaudePort.Connection.PortSpawn.spawn({:ok, {"echo", []}}, state) + Application.delete_env(:el, :env_module) + + assert_received {:port_opts, opts} + assert Enum.any?(opts, fn + {:env, env_list} when is_list(env_list) -> true + _ -> false + end) + end + + test "calls Executable.find to locate the executable" do + expect(El.MockExecutable, :find, fn 'echo' -> '/bin/echo' end) + stub(El.MockEnv, :get, fn -> %{} end) + stub(El.MockPort, :open, fn _, _ -> {:ok, :port} end) + + Application.put_env(:el, :env_module, El.MockEnv) + state = %{cwd: "/tmp", port_module: El.MockPort} + El.ClaudePort.Connection.PortSpawn.spawn({:ok, {"echo", []}}, state) + Application.delete_env(:el, :env_module) + end + + test "returns error when Executable.find returns false" do + expect(El.MockExecutable, :find, fn 'notfound' -> false end) + stub(El.MockEnv, :get, fn -> %{} end) + + Application.put_env(:el, :env_module, El.MockEnv) + state = %{cwd: "/tmp", port_module: El.MockPort} + result = El.ClaudePort.Connection.PortSpawn.spawn({:ok, {"notfound", []}}, state) + Application.delete_env(:el, :env_module) + + assert result == {:error, "CLI executable not found: notfound"} + end + + test "passes found executable path to Port.open" do + expect(El.MockExecutable, :find, fn 'echo' -> '/bin/echo' end) + stub(El.MockEnv, :get, fn -> %{} end) + expect(El.MockPort, :open, fn {:spawn_executable, path}, _ -> + send(self(), {:exe_path, path}) + {:ok, :port} + end) + + Application.put_env(:el, :env_module, El.MockEnv) + state = %{cwd: "/tmp", port_module: El.MockPort} + El.ClaudePort.Connection.PortSpawn.spawn({:ok, {"echo", []}}, state) + Application.delete_env(:el, :env_module) + + assert_received {:exe_path, '/bin/echo'} + end + end +end diff --git a/specs/el/claude_port/state_spec.exs b/specs/el/claude_port/state_spec.exs index 8d25adac..9d791830 100644 --- a/specs/el/claude_port/state_spec.exs +++ b/specs/el/claude_port/state_spec.exs @@ -1,5 +1,14 @@ defmodule El.ClaudePort.State.Spec do use ExUnit.Case + import Mox + + setup_all do + Code.ensure_loaded!(El.ClaudePort.State) + Code.ensure_loaded!(El.Infra.Behaviours.FileSystem) + :ok + end + + setup :verify_on_exit! describe "El.ClaudePort.State.build/1" do test "defaults connection_module to El.ClaudePort.Connection" do @@ -20,4 +29,28 @@ defmodule El.ClaudePort.State.Spec do assert state.cli_resolver_module == El.ClaudePort.Connection.CliResolver end end + + describe "El.ClaudePort.State.build cwd routing" do + test "gets cwd from mocked FileSystem when cwd not provided" do + Application.put_env(:el, :fs_module, El.MockFileSystem) + on_exit(fn -> Application.delete_env(:el, :fs_module) end) + expect(El.MockFileSystem, :cwd!, fn -> "/mocked/cwd" end) + + state = El.ClaudePort.State.build([]) + + assert state.cwd == "/mocked/cwd" + end + + test "uses provided cwd instead of calling fs_module" do + state = El.ClaudePort.State.build(cwd: "/provided/cwd") + + assert state.cwd == "/provided/cwd" + end + + test "uses default FileSystem when fs_module not configured" do + state = El.ClaudePort.State.build([]) + + assert is_binary(state.cwd) + end + end end diff --git a/specs/el/cli/daemon/connection_spec.exs b/specs/el/cli/daemon/connection_spec.exs new file mode 100644 index 00000000..a6459942 --- /dev/null +++ b/specs/el/cli/daemon/connection_spec.exs @@ -0,0 +1,89 @@ +defmodule El.CLI.Daemon.Connection.Spec do + use ExUnit.Case + import Mox + + setup_all do + Code.ensure_loaded!(El.CLI.Daemon.Connection) + Code.ensure_loaded!(El.CLI.Daemon.Behaviours.Connection) + Code.ensure_loaded!(El.Infra.Behaviours.System) + Code.ensure_loaded!(El.Infra.Behaviours.NodeConnector) + Code.ensure_loaded!(El.Infra.Behaviours.NetKernel) + Code.ensure_loaded!(El.CLI.Daemon.Behaviours.Env) + Code.ensure_loaded!(El.CLI.Daemon.Env) + :ok + end + + setup :verify_on_exit! + + describe "El.CLI.Daemon.Connection" do + test "declares @behaviour El.CLI.Daemon.Behaviours.Connection" do + assert El.CLI.Daemon.Behaviours.Connection in El.CLI.Daemon.Connection.module_info(:attributes)[:behaviour] || [] + end + end + + describe "El.CLI.Daemon.Connection.connect_to_daemon/1" do + setup do + stub(El.MockSystem, :cmd, fn _cmd, _args -> :ok end) + stub(El.MockNodeConnector, :connect, fn _ -> true end) + stub(El.MockNetKernel, :start, fn _args -> {:ok, :started} end) + stub(El.MockNodeConnector, :set_cookie, fn _ -> true end) + :ok + end + + test "starts epmd via system" do + expect(El.MockSystem, :cmd, fn "epmd", ["-daemon"] -> :ok end) + + El.CLI.Daemon.Connection.connect_to_daemon( + system: El.MockSystem, + node_connector: El.MockNodeConnector, + net_kernel: El.MockNetKernel + ) + end + + test "returns ok with daemon node when connected" do + result = El.CLI.Daemon.Connection.connect_to_daemon( + system: El.MockSystem, + node_connector: El.MockNodeConnector, + net_kernel: El.MockNetKernel + ) + + assert elem(result, 0) == :ok + end + end + + describe "El.CLI.Daemon.Connection.ensure_daemon/1" do + setup do + stub(El.MockSystem, :cmd, fn _cmd, _args -> :ok end) + stub(El.MockNodeConnector, :connect, fn _ -> true end) + :ok + end + + test "returns ok when daemon is connected" do + result = El.CLI.Daemon.Connection.ensure_daemon( + system: El.MockSystem, + node_connector: El.MockNodeConnector + ) + + assert result == :ok + end + end + + describe "El.CLI.Daemon.Connection.start_daemon_node/1" do + setup do + stub(El.MockSystem, :cmd, fn _cmd, _args -> :ok end) + stub(El.MockNetKernel, :start, fn _args -> {:ok, :started} end) + stub(El.MockNodeConnector, :set_cookie, fn _ -> true end) + :ok + end + + test "starts epmd then daemon node" do + expect(El.MockSystem, :cmd, fn "epmd", ["-daemon"] -> :ok end) + + El.CLI.Daemon.Connection.start_daemon_node( + system: El.MockSystem, + node_connector: El.MockNodeConnector, + net_kernel: El.MockNetKernel + ) + end + end +end diff --git a/specs/el/cli/daemon/env_spec.exs b/specs/el/cli/daemon/env_spec.exs new file mode 100644 index 00000000..90092c24 --- /dev/null +++ b/specs/el/cli/daemon/env_spec.exs @@ -0,0 +1,94 @@ +defmodule El.CLI.Daemon.Env.Spec do + use ExUnit.Case + import Mox + + setup_all do + Code.ensure_loaded!(El.CLI.Daemon.Env) + Code.ensure_loaded!(El.CLI.Daemon.Behaviours.Env) + Code.ensure_loaded!(El.Infra.Behaviours.Env) + :ok + end + + setup :verify_on_exit! + + describe "El.CLI.Daemon.Env" do + test "declares @behaviour El.CLI.Daemon.Behaviours.Env" do + assert El.CLI.Daemon.Behaviours.Env in El.CLI.Daemon.Env.module_info(:attributes)[:behaviour] || [] + end + end + + describe "El.CLI.Daemon.Env.dev?" do + test "returns true when DEV env is set via mocked Env" do + expect(El.MockEnv, :get, fn "DEV" -> "1" end) + Application.put_env(:el, :env_module, El.MockEnv) + + assert El.CLI.Daemon.Env.dev? == true + + Application.delete_env(:el, :env_module) + end + + test "returns true when DEV env is set to any non-nil value via mocked Env" do + expect(El.MockEnv, :get, fn "DEV" -> "yes" end) + Application.put_env(:el, :env_module, El.MockEnv) + + assert El.CLI.Daemon.Env.dev? == true + + Application.delete_env(:el, :env_module) + end + + test "returns false when DEV env is nil via mocked Env" do + expect(El.MockEnv, :get, fn "DEV" -> nil end) + Application.put_env(:el, :env_module, El.MockEnv) + + assert El.CLI.Daemon.Env.dev? == false + + Application.delete_env(:el, :env_module) + end + end + + describe "El.CLI.Daemon.Env.daemon_node" do + test "returns el_dev@127.0.0.1 when dev? is true" do + stub(El.MockEnv, :get, fn + "DEV" -> "1" + "EL_HOST" -> nil + end) + Application.put_env(:el, :env_module, El.MockEnv) + + assert El.CLI.Daemon.Env.daemon_node() == :"el_dev@127.0.0.1" + + Application.delete_env(:el, :env_module) + end + + test "returns el@127.0.0.1 when dev? is false" do + stub(El.MockEnv, :get, fn + "DEV" -> nil + "EL_HOST" -> nil + end) + Application.put_env(:el, :env_module, El.MockEnv) + + assert El.CLI.Daemon.Env.daemon_node() == :"el@127.0.0.1" + + Application.delete_env(:el, :env_module) + end + end + + describe "El.CLI.Daemon.Env.daemon_cookie" do + test "returns el_dev when dev? is true" do + expect(El.MockEnv, :get, fn "DEV" -> "1" end) + Application.put_env(:el, :env_module, El.MockEnv) + + assert El.CLI.Daemon.Env.daemon_cookie() == :el_dev + + Application.delete_env(:el, :env_module) + end + + test "returns el when dev? is false" do + expect(El.MockEnv, :get, fn "DEV" -> nil end) + Application.put_env(:el, :env_module, El.MockEnv) + + assert El.CLI.Daemon.Env.daemon_cookie() == :el + + Application.delete_env(:el, :env_module) + end + end +end diff --git a/specs/el/cli/daemon_connector_spec.exs b/specs/el/cli/daemon_connector_spec.exs index 05694cea..a4aa7639 100644 --- a/specs/el/cli/daemon_connector_spec.exs +++ b/specs/el/cli/daemon_connector_spec.exs @@ -36,4 +36,29 @@ defmodule El.CLI.DaemonConnector.Spec do assert El.CLI.DaemonConnector.wait_for_daemon(n, El.MockSleeper, El.MockNodeConnector) == {:error, :timeout} end end + + describe "wait_for_daemon with EL_NODE" do + test "uses remote node when EL_NODE is set" do + System.put_env("EL_NODE", "el@home.local") + Mox.expect(El.MockSleeper, :sleep, 1, fn _ -> :ok end) + Mox.expect(El.MockNodeConnector, :connect, 1, fn node -> + node == :"el@home.local" + end) + + assert El.CLI.DaemonConnector.wait_for_daemon(5, El.MockSleeper, El.MockNodeConnector) == :ok + System.delete_env("EL_NODE") + end + + test "uses local daemon node when EL_NODE is not set" do + System.delete_env("EL_NODE") + System.delete_env("EL_HOST") + System.delete_env("DEV") + Mox.expect(El.MockSleeper, :sleep, 1, fn _ -> :ok end) + Mox.expect(El.MockNodeConnector, :connect, 1, fn node -> + node == :"el@127.0.0.1" + end) + + assert El.CLI.DaemonConnector.wait_for_daemon(5, El.MockSleeper, El.MockNodeConnector) == :ok + end + end end diff --git a/specs/el/cli/daemon_spec.exs b/specs/el/cli/daemon_spec.exs index c2f23506..4bab451e 100644 --- a/specs/el/cli/daemon_spec.exs +++ b/specs/el/cli/daemon_spec.exs @@ -7,9 +7,19 @@ defmodule El.CLI.Daemon.Spec do Code.ensure_loaded!(El.Infra.Behaviours.System) Code.ensure_loaded!(El.Infra.Behaviours.NodeConnector) Code.ensure_loaded!(El.Infra.Behaviours.NetKernel) + Code.ensure_loaded!(El.Infra.Behaviours.RPC) + Code.ensure_loaded!(El.Infra.Behaviours.Sleeper) + Code.ensure_loaded!(El.Infra.Behaviours.NodeMonitor) Code.ensure_loaded!(El.Infra.System) Code.ensure_loaded!(El.Infra.NodeConnector) Code.ensure_loaded!(El.Infra.NetKernel) + Code.ensure_loaded!(El.Infra.RPC) + Code.ensure_loaded!(El.Infra.Sleeper) + Code.ensure_loaded!(El.Infra.NodeMonitor) + Code.ensure_loaded!(El.CLI.Daemon.Behaviours.Env) + Code.ensure_loaded!(El.CLI.Daemon.Env) + Code.ensure_loaded!(El.CLI.Daemon.Behaviours.Connection) + Code.ensure_loaded!(El.CLI.Daemon.Connection) :ok end @@ -32,4 +42,71 @@ defmodule El.CLI.Daemon.Spec do assert El.Infra.Behaviours.NetKernel in El.Infra.NetKernel.module_info(:attributes)[:behaviour] || [] end end + + describe "El.CLI.Daemon.Env" do + test "declares @behaviour El.CLI.Daemon.Behaviours.Env" do + assert El.CLI.Daemon.Behaviours.Env in El.CLI.Daemon.Env.module_info(:attributes)[:behaviour] || [] + end + end + + describe "El.CLI.Daemon.stop_daemon/0" do + setup do + stub(El.MockRPC, :call, fn _node, :init, :stop, [] -> :ok end) + stub(El.MockNodeMonitor, :list, fn -> [] end) + stub(El.MockSleeper, :sleep, fn _ms -> :ok end) + :ok + end + + test "calls :init.stop on the daemon node via RPC" do + expected = El.CLI.Daemon.daemon_node() + expect(El.MockRPC, :call, fn ^expected, :init, :stop, [] -> :ok end) + + El.CLI.Daemon.stop_daemon(rpc: El.MockRPC, sleeper: El.MockSleeper, node_monitor: El.MockNodeMonitor) + end + + test "invokes node monitor to check if node is still connected" do + expect(El.MockNodeMonitor, :list, fn -> [] end) + + El.CLI.Daemon.stop_daemon(rpc: El.MockRPC, sleeper: El.MockSleeper, node_monitor: El.MockNodeMonitor) + end + + test "accepts env parameter for dependency injection" do + stub(El.MockDaemonEnv, :daemon_node, fn -> :"el_dev@127.0.0.1" end) + expect(El.MockNodeMonitor, :list, fn -> [] end) + + El.CLI.Daemon.stop_daemon(rpc: El.MockRPC, sleeper: El.MockSleeper, node_monitor: El.MockNodeMonitor, env: El.MockDaemonEnv) + end + end + + describe "El.CLI.Daemon.restart_daemon/1" do + setup do + stub(El.MockRPC, :call, fn _node, :init, :stop, [] -> :ok end) + stub(El.MockNodeMonitor, :list, fn -> [] end) + stub(El.MockSleeper, :sleep, fn _ms -> :ok end) + stub(El.MockDaemonConnection, :connect_to_daemon, fn _opts -> {:ok, :"el_dev@127.0.0.1"} end) + :ok + end + + test "stops daemon then connects" do + expect(El.MockRPC, :call, fn _node, :init, :stop, [] -> :ok end) + + El.CLI.Daemon.restart_daemon( + rpc: El.MockRPC, + sleeper: El.MockSleeper, + node_monitor: El.MockNodeMonitor, + connection: El.MockDaemonConnection + ) + end + + test "returns ok when daemon restarts successfully" do + result = El.CLI.Daemon.restart_daemon( + rpc: El.MockRPC, + sleeper: El.MockSleeper, + node_monitor: El.MockNodeMonitor, + connection: El.MockDaemonConnection + ) + + assert result == :ok + end + end end diff --git a/specs/el/cli/info_spec.exs b/specs/el/cli/info_spec.exs new file mode 100644 index 00000000..20411ced --- /dev/null +++ b/specs/el/cli/info_spec.exs @@ -0,0 +1,52 @@ +defmodule El.CLI.Info.Spec do + use ExUnit.Case + import Mox + import ExUnit.CaptureIO + + setup :verify_on_exit! + + describe "El.CLI.Info.execute/2" do + test "outputs session name when alive" do + stub(El.MockSessionApi, :alive?, fn :session -> true end) + stub(El.MockSessionApi, :info, fn :session -> %{messages: 2, last_prompt: "who?", last_response: "me", model: "haiku", cwd: nil, id: nil} end) + stub(El.MockSessionApi, :agent, fn :session -> nil end) + + output = + capture_io(fn -> + El.CLI.Info.execute(["session"], [session_api: El.MockSessionApi]) + end) + + assert output =~ "session" + end + + test "outputs usage when session not alive" do + stub(El.MockSessionApi, :alive?, fn :session -> false end) + + output = + capture_io(fn -> + El.CLI.Info.execute(["session"], [session_api: El.MockSessionApi]) + end) + + assert output =~ "el ls" + end + end + + describe "El.CLI.Info.execute_json/2" do + test "outputs JSON for live session" do + stub(El.MockSessionApi, :alive?, fn :live -> true end) + stub(El.MockSessionApi, :info, fn :live -> + %{id: "abc-123", model: "sonnet", cwd: "/work", messages: 3, last_prompt: nil, last_response: nil} + end) + stub(El.MockSessionApi, :agent, fn :live -> "researcher" end) + + output = + capture_io(fn -> + El.CLI.Info.execute_json(["live"], [session_api: El.MockSessionApi]) + end) + + decoded = Jason.decode!(String.trim(output)) + assert decoded["name"] == "live" + assert decoded["alive"] == true + end + end +end diff --git a/specs/el/cli/output/json_spec.exs b/specs/el/cli/output/json_spec.exs new file mode 100644 index 00000000..184b0d58 --- /dev/null +++ b/specs/el/cli/output/json_spec.exs @@ -0,0 +1,36 @@ +defmodule El.CLI.OutputJson.Spec do + use ExUnit.Case + + describe "info/1" do + test "encodes alive session map to locked JSON shape" do + data = %{ + name: "myagent", + id: "abc-123", + agent: nil, + model: "sonnet", + cwd: "/work", + messages: 3, + last_prompt: nil, + last_response: nil, + alive: true + } + + assert El.CLI.OutputJson.info(data) |> Jason.decode!() == %{ + "name" => "myagent", + "id" => "abc-123", + "agent" => nil, + "model" => "sonnet", + "cwd" => "/work", + "messages" => 3, + "last_prompt" => nil, + "last_response" => nil, + "alive" => true + } + end + + test "encodes dead session to locked dead shape" do + assert El.CLI.OutputJson.info(%{name: "myagent", alive: false}) + |> Jason.decode!() == %{"name" => "myagent", "alive" => false} + end + end +end diff --git a/specs/el/cli/start/daemon_health_spec.exs b/specs/el/cli/start/daemon_health_spec.exs index 82331da2..5c931451 100644 --- a/specs/el/cli/start/daemon_health_spec.exs +++ b/specs/el/cli/start/daemon_health_spec.exs @@ -4,7 +4,7 @@ defmodule El.CLI.Start.DaemonHealth.Spec do setup :verify_on_exit! - describe "El.CLI.Start.DaemonHealth.ping_if_agent/3" do + describe "El.CLI.Start.DaemonHealth.ping_for_session_id/3" do setup do stub(El.MockGroupLeader, :open_null_device, fn -> :null_device end) stub(El.MockGroupLeader, :get, fn -> :original_leader end) @@ -16,26 +16,41 @@ defmodule El.CLI.Start.DaemonHealth.Spec do } end - test "is no-op when agent_atom is nil", %{name: name, base_deps: deps} do - stub(El.MockSessionApi, :info, fn _ -> %{messages: 0} end) + @tag timeout: :infinity + test "pings when agent_atom is nil and messages == 0", %{name: name, base_deps: deps} do + expect(El.MockSessionApi, :info, fn _ -> %{messages: 0} end) + expect(El.MockSessionApi, :agent, fn _ -> nil end) + expect(El.MockSessionApi, :probe_ask, fn _, _ -> "test response" end) opts = [agent: nil] - result = El.CLI.Start.DaemonHealth.ping_if_agent(name, opts, deps) - assert result == :ok + result = El.CLI.Start.DaemonHealth.ping_for_session_id(name, opts, deps) + assert result == "test response" + end + + @tag timeout: :infinity + test "pings when no agent key in opts and messages == 0", %{name: name, base_deps: deps} do + expect(El.MockSessionApi, :info, fn _ -> %{messages: 0} end) + expect(El.MockSessionApi, :agent, fn _ -> nil end) + expect(El.MockSessionApi, :probe_ask, fn _, _ -> "test response" end) + opts = [] + result = El.CLI.Start.DaemonHealth.ping_for_session_id(name, opts, deps) + assert result == "test response" end test "is no-op when messages > 0 in opts", %{name: name, base_deps: deps} do stub(El.MockSessionApi, :info, fn _ -> %{messages: 5} end) + stub(El.MockSessionApi, :agent, fn _ -> "kent" end) opts = [agent: :some_agent] - result = El.CLI.Start.DaemonHealth.ping_if_agent(name, opts, deps) + result = El.CLI.Start.DaemonHealth.ping_for_session_id(name, opts, deps) assert result == :ok end @tag timeout: :infinity test "executes ping when conditions met", %{name: name, base_deps: deps} do expect(El.MockSessionApi, :info, fn _ -> %{messages: 0} end) + expect(El.MockSessionApi, :agent, fn _ -> "kent" end) expect(El.MockSessionApi, :ask, fn _, _ -> "test response" end) opts = [agent: :some_agent] - result = El.CLI.Start.DaemonHealth.ping_if_agent(name, opts, deps) + result = El.CLI.Start.DaemonHealth.ping_for_session_id(name, opts, deps) assert result == "test response" end end diff --git a/specs/el/cli/start/session_card_spec.exs b/specs/el/cli/start/session_card_spec.exs index c28b6859..e0e4bf9b 100644 --- a/specs/el/cli/start/session_card_spec.exs +++ b/specs/el/cli/start/session_card_spec.exs @@ -212,5 +212,58 @@ defmodule El.CLI.Start.SessionCard.Spec do assert length(prompt_rows) == 1 assert String.starts_with?(hd(prompt_rows), "> ") end + + test "renders agent, model short form, and message count", %{info: info} do + opts = [agent: "kent"] + new_info = %{info | model: "claude-opus-4-7", messages: 2, last_prompt: nil, last_response: nil} + result = El.CLI.Start.SessionCard.build_card_rows("test_session", opts, new_info) + + assert Enum.any?(result, &String.contains?(&1, "agent: kent")) + assert Enum.any?(result, &String.contains?(&1, "model: opus")) + assert Enum.any?(result, &String.contains?(&1, "msgs: 2")) + end + + test "no double emit of model when opts model provided without agent", %{info: info} do + opts = [model: "claude-opus-4-7"] + new_info = %{info | model: "claude-opus-4-7"} + result = El.CLI.Start.SessionCard.build_card_rows("test_session", opts, new_info) + + model_lines = Enum.filter(result, &String.starts_with?(&1, "model:")) + assert Enum.count(model_lines) == 1 + end + + test "model emitted exactly once when no agent and info.model exists" do + opts = [] + info = %{ + id: "session-123", + model: "claude-haiku-4-5-20251001", + cwd: "/tmp", + messages: 0, + last_prompt: nil, + last_response: nil + } + result = El.CLI.Start.SessionCard.build_card_rows("test_session", opts, info) + + model_lines = Enum.filter(result, &String.contains?(&1, "model:")) + assert Enum.count(model_lines) == 1 + assert Enum.any?(result, &String.contains?(&1, "cwd:")) + end + + test "model emitted exactly once when opts model provided without agent" do + opts = [model: "haiku"] + info = %{ + id: "session-123", + model: "claude-sonnet-4-6", + cwd: "/tmp", + messages: 0, + last_prompt: nil, + last_response: nil + } + result = El.CLI.Start.SessionCard.build_card_rows("test_session", opts, info) + + model_lines = Enum.filter(result, &String.contains?(&1, "model:")) + assert Enum.count(model_lines) == 1 + assert Enum.any?(result, &String.contains?(&1, "cwd:")) + end end end diff --git a/specs/el/cli/start_spec.exs b/specs/el/cli/start_spec.exs index 8dad1255..4ce38788 100644 --- a/specs/el/cli/start_spec.exs +++ b/specs/el/cli/start_spec.exs @@ -65,6 +65,52 @@ defmodule El.CLI.Start.Spec do end end + describe "handle_find_daemon_for_start/4" do + setup do + stub(El.MockSessionApi, :info, fn _name_atom -> + %{ + id: "session-id", + model: "test-model", + cwd: "/path/to/working/dir", + messages: 0, + last_prompt: nil, + last_response: nil + } + end) + stub(El.MockGroupLeader, :open_null_device, fn -> :null_device end) + stub(El.MockGroupLeader, :get, fn -> :original_leader end) + stub(El.MockGroupLeader, :set, fn _, _ -> true end) + stub(El.MockGroupLeader, :close, fn _ -> :ok end) + + %{ + base_deps: [ + session_api: El.MockSessionApi, + group_leader: El.MockGroupLeader + ] + } + end + + test "invokes ping_for_session_id when no agent in opts", %{base_deps: deps} do + expect(El.MockSessionApi, :probe_ask, fn _, _ -> "test response" end) + expect(El.MockSessionApi, :agent, fn _ -> nil end) + opts = [] + expect(El.MockSessionApi, :info, 2, fn _name_atom -> + %{ + id: "session-id", + model: "test-model", + cwd: "/path/to/working/dir", + messages: 0, + last_prompt: nil, + last_response: nil + } + end) + + capture_io(fn -> + El.CLI.Start.handle_find_daemon_for_start("test", opts, MockElModule, deps) + end) + end + end + describe "handle_find_daemon_with_rest/5" do setup do stub(El.MockSessionApi, :info, fn _name_atom -> @@ -90,8 +136,9 @@ defmodule El.CLI.Start.Spec do } end - test "invokes ping_if_agent when agent in opts", %{base_deps: deps} do + test "invokes ping_for_session_id when agent in opts", %{base_deps: deps} do expect(El.MockSessionApi, :ask, fn _, _ -> "test response" end) + expect(El.MockSessionApi, :agent, fn _ -> "kent" end) opts = [agent: :some_agent] expect(El.MockSessionApi, :info, 2, fn _name_atom -> %{ diff --git a/specs/el/cli_spec.exs b/specs/el/cli_spec.exs index 9d9b21fd..9726b9df 100644 --- a/specs/el/cli_spec.exs +++ b/specs/el/cli_spec.exs @@ -14,8 +14,12 @@ defmodule El.CLI.Spec do assert El.CLI.Router.parse_route(["ls"]) == :ls end - test "returns start for single session name" do - assert El.CLI.Router.parse_route(["my_session"]) == :start + test "returns restart_daemon for [restart]" do + assert El.CLI.Router.parse_route(["restart"]) == :restart_daemon + end + + test "returns info for single session name" do + assert El.CLI.Router.parse_route(["my_session"]) == :info end test "returns start with -m flag" do @@ -26,6 +30,18 @@ defmodule El.CLI.Spec do assert El.CLI.Router.parse_route(["my_session", "-a", "kent"]) == :start end + test "returns :start for name start (bare)" do + assert El.CLI.Router.parse_route(["my_session", "start"]) == :start + end + + test "returns :start for name start -m model" do + assert El.CLI.Router.parse_route(["my_session", "start", "-m", "sonnet"]) == :start + end + + test "returns :start for name start -a agent" do + assert El.CLI.Router.parse_route(["my_session", "start", "-a", "kent"]) == :start + end + test "returns msg for name word message" do assert El.CLI.Router.parse_route(["session", "hello"]) == :msg end @@ -38,6 +54,18 @@ defmodule El.CLI.Spec do assert El.CLI.Router.parse_route(["bogus", "args"]) == :msg end + test "returns :info_json for name with -json" do + assert El.CLI.Router.parse_route(["myagent", "-json"]) == :info_json + end + + test "returns :log_json for [name, \"log\", \"-json\"]" do + assert El.CLI.Router.parse_route(["myagent", "log", "-json"]) == :log_json + end + + test "returns :log_n_json for name log n with -json" do + assert El.CLI.Router.parse_route(["myagent", "log", "5", "-json"]) == :log_n_json + end + test "returns log for name log" do assert El.CLI.Router.parse_route(["session", "log"]) == :log end @@ -58,6 +86,10 @@ defmodule El.CLI.Spec do assert El.CLI.Router.parse_route(["exit"]) == :exit_all end + test "returns clear_all for clear" do + assert El.CLI.Router.parse_route(["clear"]) == :clear_all + end + test "returns exit for dud* exit" do assert El.CLI.Router.parse_route(["dud*", "exit"]) == :exit end @@ -66,6 +98,10 @@ defmodule El.CLI.Spec do assert El.CLI.Router.parse_route(["session", "clear"]) == :clear end + test "returns restart for name restart" do + assert El.CLI.Router.parse_route(["my_session", "restart"]) == :restart + end + test "returns daemon for --daemon flag" do assert El.CLI.Router.parse_route(["--daemon", "my_session"]) == :daemon end @@ -92,6 +128,11 @@ defmodule El.CLI.Spec do System.delete_env("CLAUDE_CODE_SUBAGENT_MODEL") Application.put_env(:el, :file_system, El.MockFileSystem) stub(El.MockSessionApi, :info, fn _name -> %{messages: 0, last_prompt: nil, last_response: nil, model: nil, cwd: nil, id: nil} end) + stub(El.MockSessionApi, :agent, fn _name -> nil end) + stub(El.MockGroupLeader, :open_null_device, fn -> :null_device end) + stub(El.MockGroupLeader, :get, fn -> :original_leader end) + stub(El.MockGroupLeader, :set, fn _, _ -> true end) + stub(El.MockGroupLeader, :close, fn _ -> :ok end) on_exit(fn -> Application.delete_env(:el, :file_system) @@ -102,51 +143,147 @@ defmodule El.CLI.Spec do end test "execute :log_n with number calls El.log with count" do + stub(El.MockSessionApi, :alive?, fn :session -> true end) expect(El.MockEl, :log, fn :session, 5, _opts -> [] end) - capture_io(fn -> El.CLI.execute(:log_n, ["session", "log", "5"], [el_module: El.MockEl]) end) + capture_io(fn -> El.CLI.execute(:log_n, ["session", "log", "5"], [el_module: El.MockEl, session_api: El.MockSessionApi]) end) end test "execute :log_n with number prints result" do + stub(El.MockSessionApi, :alive?, fn :session -> true end) expect(El.MockEl, :log, fn :session, 5, _opts -> [{"ask", "hello", "world", %{}}] end) output = - capture_io(fn -> El.CLI.execute(:log_n, ["session", "log", "5"], [el_module: El.MockEl]) end) + capture_io(fn -> El.CLI.execute(:log_n, ["session", "log", "5"], [el_module: El.MockEl, session_api: El.MockSessionApi]) end) assert output =~ "> hello" end test "execute :log_n with 'all' calls El.log with :all" do + stub(El.MockSessionApi, :alive?, fn :session -> true end) expect(El.MockEl, :log, fn :session, :all, _opts -> [] end) - capture_io(fn -> El.CLI.execute(:log_n, ["session", "log", "all"], [el_module: El.MockEl]) end) + capture_io(fn -> El.CLI.execute(:log_n, ["session", "log", "all"], [el_module: El.MockEl, session_api: El.MockSessionApi]) end) end test "execute :log_n with 'all' prints result" do + stub(El.MockSessionApi, :alive?, fn :session -> true end) expect(El.MockEl, :log, fn :session, :all, _opts -> [{"tell", "goodbye", "see ya", %{}}] end) output = - capture_io(fn -> El.CLI.execute(:log_n, ["session", "log", "all"], [el_module: El.MockEl]) end) + capture_io(fn -> El.CLI.execute(:log_n, ["session", "log", "all"], [el_module: El.MockEl, session_api: El.MockSessionApi]) end) assert output =~ "> goodbye" end test "execute :log calls El.log with count 1" do + stub(El.MockSessionApi, :alive?, fn :session -> true end) expect(El.MockEl, :log, fn :session, 1, _opts -> [] end) - capture_io(fn -> El.CLI.execute(:log, ["session", "log"], [el_module: El.MockEl]) end) + capture_io(fn -> El.CLI.execute(:log, ["session", "log"], [el_module: El.MockEl, session_api: El.MockSessionApi]) end) end test "execute :log prints result" do + stub(El.MockSessionApi, :alive?, fn :session -> true end) expect(El.MockEl, :log, fn :session, 1, _opts -> [{"ask", "hi", "reply", %{}}] end) - output = capture_io(fn -> El.CLI.execute(:log, ["session", "log"], [el_module: El.MockEl]) end) + output = capture_io(fn -> El.CLI.execute(:log, ["session", "log"], [el_module: El.MockEl, session_api: El.MockSessionApi]) end) assert output =~ "> hi" end + test "execute :log_json outputs messages as JSON array" do + stub(El.MockSessionApi, :alive?, fn :session -> true end) + expect(El.MockEl, :log, fn :session, 1, _opts -> + [{"ask", "hi", "reply", %{}}] + end) + + output = + capture_io(fn -> + El.CLI.execute( + :log_json, + ["session", "log"], + [el_module: El.MockEl, session_api: El.MockSessionApi] + ) + end) + + assert Jason.decode!(String.trim(output)) == [ + %{"type" => "ask", "message" => "hi", "response" => "reply", "metadata" => %{}} + ] + end + + test "execute :log_n_json with number outputs messages as JSON array" do + stub(El.MockSessionApi, :alive?, fn :session -> true end) + expect(El.MockEl, :log, fn :session, 5, _opts -> + [{"ask", "hi", "reply", %{}}] + end) + + output = + capture_io(fn -> + El.CLI.execute( + :log_n_json, + ["session", "log", "5", "-json"], + [el_module: El.MockEl, session_api: El.MockSessionApi] + ) + end) + + assert Jason.decode!(String.trim(output)) == [ + %{"type" => "ask", "message" => "hi", "response" => "reply", "metadata" => %{}} + ] + end + + test "execute :log_n_json with 'all' calls El.log with :all and outputs JSON" do + stub(El.MockSessionApi, :alive?, fn :session -> true end) + expect(El.MockEl, :log, fn :session, :all, _opts -> + [{"ask", "hi", "reply", %{}}] + end) + + output = + capture_io(fn -> + El.CLI.execute( + :log_n_json, + ["session", "log", "all", "-json"], + [el_module: El.MockEl, session_api: El.MockSessionApi] + ) + end) + + assert Jason.decode!(String.trim(output)) == [ + %{"type" => "ask", "message" => "hi", "response" => "reply", "metadata" => %{}} + ] + end + + test "execute :log_json outputs empty array when session not alive" do + stub(El.MockSessionApi, :alive?, fn :ghost -> false end) + + output = + capture_io(fn -> + El.CLI.execute( + :log_json, + ["ghost", "log"], + [el_module: El.MockEl, session_api: El.MockSessionApi] + ) + end) + + assert String.trim(output) == "[]" + end + + test "execute :log prints not-found message when session not alive" do + stub(El.MockSessionApi, :alive?, fn :ghost -> false end) + + output = + capture_io(fn -> + El.CLI.execute( + :log, + ["ghost", "log"], + [el_module: El.MockEl, session_api: El.MockSessionApi] + ) + end) + + assert output =~ "No sessions running" + end + test "execute :clear calls El.clear with name" do expect(El.MockEl, :clear, fn :session, _opts -> "cleared" end) @@ -171,6 +308,21 @@ defmodule El.CLI.Spec do assert output =~ "exited all" end + test "execute :clear_all calls El.clear_pattern with '*'" do + expect(El.MockEl, :clear_pattern, fn "*", _opts -> :ok end) + + capture_io(fn -> El.CLI.execute(:clear_all, ["clear"], [el_module: El.MockEl]) end) + end + + test "execute :clear_all prints confirmation" do + stub(El.MockEl, :clear_pattern, fn "*", _opts -> :ok end) + + output = + capture_io(fn -> El.CLI.execute(:clear_all, ["clear"], [el_module: El.MockEl]) end) + + assert output =~ "cleared all" + end + test "execute :exit with glob pattern calls El.exit_pattern" do expect(El.MockEl, :exit_pattern, fn "dud*", _opts -> :ok end) @@ -208,9 +360,10 @@ defmodule El.CLI.Spec do end test "execute :log with session name calls El.log" do + stub(El.MockSessionApi, :alive?, fn :session -> true end) expect(El.MockEl, :log, fn :session, 1, _opts -> [] end) - capture_io(fn -> El.CLI.execute(:log, ["session", "log"], [el_module: El.MockEl]) end) + capture_io(fn -> El.CLI.execute(:log, ["session", "log"], [el_module: El.MockEl, session_api: El.MockSessionApi]) end) end test "execute :log_n with glob pattern calls El.log_pattern" do @@ -220,9 +373,16 @@ defmodule El.CLI.Spec do end test "execute :log_n with session name calls El.log" do + stub(El.MockSessionApi, :alive?, fn :session -> true end) expect(El.MockEl, :log, fn :session, 5, _opts -> [] end) - capture_io(fn -> El.CLI.execute(:log_n, ["session", "log", "5"], [el_module: El.MockEl]) end) + capture_io(fn -> El.CLI.execute(:log_n, ["session", "log", "5"], [el_module: El.MockEl, session_api: El.MockSessionApi]) end) + end + + test "execute :log_n with glob and \"all\" calls El.log_pattern with :all" do + expect(El.MockEl, :log_pattern, fn "dud*", :all, _opts -> [] end) + + capture_io(fn -> El.CLI.execute(:log_n, ["dud*", "log", "all"], el_module: El.MockEl) end) end test "execute :msg auto-starts session with agent detection" do @@ -300,9 +460,25 @@ defmodule El.CLI.Spec do assert opts[:model] == "opus" end + test "execute :start with bare 'start' literal calls El.start" do + expect(El.MockEl, :start, fn :dude, _opts -> :created end) + stub(El.MockSessionApi, :info, fn :dude -> %{messages: 0, last_prompt: nil, last_response: nil, model: nil, cwd: nil, id: nil} end) + stub(El.MockSessionApi, :probe_ask, fn :dude, "who are you?" -> "response" end) + expect(El.MockSessionApi, :agent, fn :dude -> nil end) + + capture_io(fn -> + El.CLI.execute( + :start, + ["dude", "start"], + [agent_detector: NilAgentDetectorStub, el_module: El.MockEl, session_api: El.MockSessionApi] + ) + end) + end + test "execute :start uses merge_session_opts to combine agent and model" do expect(El.MockEl, :start, fn :my_session, opts when is_list(opts) -> :ok end) expect(El.MockSessionApi, :ask, fn :my_session, "who are you?" -> "response" end) + expect(El.MockSessionApi, :agent, fn _ -> "kent" end) expect(El.MockGroupLeader, :open_null_device, fn -> self() end) expect(El.MockGroupLeader, :get, fn -> self() end) expect(El.MockGroupLeader, :set, fn _, _ -> true end) @@ -318,6 +494,7 @@ defmodule El.CLI.Spec do expect(El.MockEl, :start, fn :my_session, opts when is_list(opts) -> :ok end) expect(El.MockSessionApi, :info, 2, fn :my_session -> %{messages: 0, last_prompt: nil, last_response: nil, model: nil, cwd: nil, id: nil} end) expect(El.MockSessionApi, :ask, fn :my_session, "who are you?" -> "response" end) + expect(El.MockSessionApi, :agent, fn :my_session -> "kent" end) expect(El.MockGroupLeader, :open_null_device, fn -> :null_device end) expect(El.MockGroupLeader, :get, fn -> :original_leader end) expect(El.MockGroupLeader, :set, 2, fn _, _ -> true end) @@ -332,6 +509,7 @@ defmodule El.CLI.Spec do expect(El.MockEl, :start, fn :my_session, opts when is_list(opts) -> :ok end) expect(El.MockSessionApi, :info, 2, fn :my_session -> %{messages: 0, last_prompt: nil, last_response: nil, model: nil, cwd: nil, id: nil} end) expect(El.MockSessionApi, :ask, fn :my_session, "who are you?" -> "response" end) + expect(El.MockSessionApi, :agent, fn :my_session -> "explicit" end) expect(El.MockGroupLeader, :open_null_device, fn -> :null_device end) expect(El.MockGroupLeader, :get, fn -> :original_leader end) expect(El.MockGroupLeader, :set, 2, fn _, _ -> true end) @@ -345,46 +523,55 @@ defmodule El.CLI.Spec do test "execute :start when no agent detected does not merge agent into opts" do expect(El.MockEl, :start, fn :my_session, opts when is_list(opts) -> :ok end) expect(El.MockSessionApi, :info, 2, fn :my_session -> %{messages: 0, last_prompt: nil, last_response: nil, model: nil, cwd: nil, id: nil} end) + expect(El.MockSessionApi, :probe_ask, fn :my_session, "who are you?" -> nil end) + expect(El.MockSessionApi, :agent, fn :my_session -> nil end) capture_io(fn -> - El.CLI.execute(:start, ["my_session"], [agent_detector: NilAgentDetectorStub, session_api: El.MockSessionApi, el_module: El.MockEl]) + El.CLI.execute(:start, ["my_session"], [agent_detector: NilAgentDetectorStub, session_api: El.MockSessionApi, el_module: El.MockEl, group_leader: El.MockGroupLeader]) end) end test "execute :start with -m model when no agent detected does not merge agent" do expect(El.MockEl, :start, fn :my_session, opts when is_list(opts) -> :ok end) expect(El.MockSessionApi, :info, 2, fn :my_session -> %{messages: 0, last_prompt: nil, last_response: nil, model: nil, cwd: nil, id: nil} end) + expect(El.MockSessionApi, :probe_ask, fn :my_session, "who are you?" -> nil end) + expect(El.MockSessionApi, :agent, fn :my_session -> nil end) capture_io(fn -> - El.CLI.execute(:start, ["my_session", "-m", "haiku"], [agent_detector: NilAgentDetectorStub, session_api: El.MockSessionApi, el_module: El.MockEl]) + El.CLI.execute(:start, ["my_session", "-m", "haiku"], [agent_detector: NilAgentDetectorStub, session_api: El.MockSessionApi, el_module: El.MockEl, group_leader: El.MockGroupLeader]) end) end test "execute :start uses env model when no model or agent" do expect(El.MockEl, :start, fn :my_session, opts when is_list(opts) -> :ok end) expect(El.MockSessionApi, :info, 2, fn :my_session -> %{messages: 0, last_prompt: nil, last_response: nil, model: nil, cwd: nil, id: nil} end) + expect(El.MockSessionApi, :probe_ask, fn :my_session, "who are you?" -> nil end) + expect(El.MockSessionApi, :agent, fn :my_session -> nil end) System.put_env("CLAUDE_CODE_SUBAGENT_MODEL", "sonnet") capture_io(fn -> - El.CLI.execute(:start, ["my_session"], [agent_detector: NilAgentDetectorStub, session_api: El.MockSessionApi, el_module: El.MockEl]) + El.CLI.execute(:start, ["my_session"], [agent_detector: NilAgentDetectorStub, session_api: El.MockSessionApi, el_module: El.MockEl, group_leader: El.MockGroupLeader]) end) end test "execute :start ignores env model when model provided" do expect(El.MockEl, :start, fn :my_session, opts when is_list(opts) -> :ok end) expect(El.MockSessionApi, :info, 2, fn :my_session -> %{messages: 0, last_prompt: nil, last_response: nil, model: nil, cwd: nil, id: nil} end) + expect(El.MockSessionApi, :probe_ask, fn :my_session, "who are you?" -> nil end) + expect(El.MockSessionApi, :agent, fn :my_session -> nil end) System.put_env("CLAUDE_CODE_SUBAGENT_MODEL", "sonnet") capture_io(fn -> - El.CLI.execute(:start, ["my_session", "-m", "opus"], [agent_detector: NilAgentDetectorStub, session_api: El.MockSessionApi, el_module: El.MockEl]) + El.CLI.execute(:start, ["my_session", "-m", "opus"], [agent_detector: NilAgentDetectorStub, session_api: El.MockSessionApi, el_module: El.MockEl, group_leader: El.MockGroupLeader]) end) end test "execute :start ignores env model when agent detected" do expect(El.MockEl, :start, fn :my_session, opts when is_list(opts) -> :ok end) expect(El.MockSessionApi, :ask, fn :my_session, "who are you?" -> "response" end) + expect(El.MockSessionApi, :agent, fn _ -> "my_session" end) expect(El.MockGroupLeader, :open_null_device, fn -> self() end) expect(El.MockGroupLeader, :get, fn -> self() end) expect(El.MockGroupLeader, :set, fn _, _ -> true end) @@ -400,10 +587,114 @@ defmodule El.CLI.Spec do test "execute :start ignores nil env model" do expect(El.MockEl, :start, fn :my_session, opts when is_list(opts) -> :ok end) + expect(El.MockSessionApi, :probe_ask, fn :my_session, "who are you?" -> nil end) + expect(El.MockSessionApi, :agent, fn :my_session -> nil end) capture_io(fn -> - El.CLI.execute(:start, ["my_session"], [agent_detector: NilAgentDetectorStub, el_module: El.MockEl]) + El.CLI.execute(:start, ["my_session"], [agent_detector: NilAgentDetectorStub, session_api: El.MockSessionApi, el_module: El.MockEl, group_leader: El.MockGroupLeader]) + end) + end + + test "execute :info outputs session name" do + stub(El.MockSessionApi, :alive?, fn :session -> true end) + stub(El.MockSessionApi, :info, fn :session -> %{messages: 2, last_prompt: "who?", last_response: "me", model: "haiku", cwd: nil, id: nil} end) + stub(El.MockSessionApi, :agent, fn :session -> nil end) + + output = + capture_io(fn -> + El.CLI.execute(:info, ["session"], [session_api: El.MockSessionApi]) + end) + + assert output =~ "session" + end + + test "execute :info falls back to usage when session absent" do + stub(El.MockSessionApi, :alive?, fn :session -> false end) + + output = + capture_io(fn -> + El.CLI.execute(:info, ["session"], [session_api: El.MockSessionApi]) + end) + + assert output =~ "el ls" + end + + test "execute :restart calls El.restart on the named session" do + expect(El.MockEl, :restart, fn :session, _opts -> :ok end) + stub(El.MockSessionApi, :info, fn :session -> %{messages: 1, last_prompt: "who?", last_response: "me", model: "haiku", cwd: nil, id: nil} end) + + capture_io(fn -> + El.CLI.execute(:restart, ["session"], [el_module: El.MockEl, session_api: El.MockSessionApi]) + end) + end + + test "execute :restart prints the session card" do + stub(El.MockEl, :restart, fn :session, _opts -> :ok end) + stub(El.MockSessionApi, :info, fn :session -> %{messages: 1, last_prompt: "who?", last_response: "me", model: "haiku", cwd: nil, id: nil} end) + + output = + capture_io(fn -> + El.CLI.execute(:restart, ["session"], [el_module: El.MockEl, session_api: El.MockSessionApi]) + end) + + assert output =~ "session" + end + + test "execute :restart with glob pattern prints confirmation" do + expect(El.MockEl, :restart_pattern, fn "foo*", _opts -> :ok end) + + output = + capture_io(fn -> El.CLI.execute(:restart, ["foo*", "restart"], [el_module: El.MockEl]) end) + + assert output =~ "restarted sessions matching foo*" + end + + test "execute :restart_daemon calls Daemon.restart_daemon" do + expect(El.MockDaemon, :restart_daemon, fn _opts -> :ok end) + + capture_io(fn -> El.CLI.execute(:restart_daemon, ["restart"], [daemon_module: El.MockDaemon]) end) + end + + test "execute :restart_daemon prints status" do + stub(El.MockDaemon, :restart_daemon, fn _opts -> :ok end) + + output = + capture_io(fn -> El.CLI.execute(:restart_daemon, ["restart"], [daemon_module: El.MockDaemon]) end) + + assert output =~ "daemon restarted" + end + + test "execute :info_json outputs dead JSON when session not alive" do + stub(El.MockSessionApi, :alive?, fn :ghost -> false end) + output = capture_io(fn -> + El.CLI.execute(:info_json, ["ghost"], [session_api: El.MockSessionApi]) end) + assert Jason.decode!(String.trim(output)) == %{"name" => "ghost", "alive" => false} + end + + test "execute :info_json outputs alive JSON with all locked fields when session alive" do + stub(El.MockSessionApi, :alive?, fn :live -> true end) + stub(El.MockSessionApi, :info, fn :live -> + %{id: "abc-123", model: "sonnet", cwd: "/work", messages: 3, last_prompt: nil, last_response: nil} + end) + stub(El.MockSessionApi, :agent, fn :live -> "researcher" end) + + output = + capture_io(fn -> + El.CLI.execute(:info_json, ["live"], [session_api: El.MockSessionApi]) + end) + + assert Jason.decode!(String.trim(output)) == %{ + "name" => "live", + "id" => "abc-123", + "agent" => "researcher", + "model" => "sonnet", + "cwd" => "/work", + "messages" => 3, + "last_prompt" => nil, + "last_response" => nil, + "alive" => true + } end end @@ -432,24 +723,33 @@ defmodule El.CLI.Spec do assert String.starts_with?(String.trim(output), "v0.1.") end - test "usage message contains el ls" do - output = capture_io(fn -> El.CLI.dispatch([], []) end) - assert String.contains?(output, "el ls") - end + test "usage_message returns the block from features/help.feature" do + vsn = El.CLI.Output.format_version(Application.spec(:el, :vsn)) - test "usage message contains el -v" do - output = capture_io(fn -> El.CLI.dispatch([], []) end) - assert String.contains?(output, "el -v") - end + expected = """ + el #{vsn} - test "usage message contains el exit" do - output = capture_io(fn -> El.CLI.dispatch([], []) end) - assert String.contains?(output, "el exit") - end + el -v version + el ls list names + el [-json] info + el log [n|all] [-json] view log (default: last 1) - test "usage message contains el exit" do - output = capture_io(fn -> El.CLI.dispatch([], []) end) - assert String.contains?(output, "el exit") + el start [args] start session + args: + -m model + -a agent + + el send a msg + + el apply command to one or many + el apply command to all + cmds: + clear start new session + exit exit session + restart restart session + """ + + assert El.CLI.Output.usage_message() == String.trim_trailing(expected) end test "version does not contain usage info" do @@ -570,16 +870,21 @@ defmodule El.CLI.Spec do describe "El.CLI.Start.handle_find_daemon_for_start/4" do setup do stub(El.MockSessionApi, :info, fn :session -> %{messages: 0, last_prompt: nil, last_response: nil, model: nil, cwd: nil, id: nil} end) + stub(El.MockSessionApi, :agent, fn :session -> nil end) + stub(El.MockGroupLeader, :open_null_device, fn -> :null_device end) + stub(El.MockGroupLeader, :get, fn -> :original_leader end) + stub(El.MockGroupLeader, :set, fn _, _ -> true end) + stub(El.MockGroupLeader, :close, fn _ -> :ok end) :ok end test "renders boxed output with name in first row" do expect(El.MockEl, :start, fn :session, opts when is_list(opts) -> :ok end) - stub(El.MockSessionApi, :info, fn :session -> %{messages: 0, last_prompt: nil, last_response: nil, model: nil, cwd: nil, id: nil} end) + expect(El.MockSessionApi, :probe_ask, fn :session, "who are you?" -> nil end) output = capture_io(fn -> - El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi]) + El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi, group_leader: El.MockGroupLeader]) end) assert output =~ "name: session" @@ -588,6 +893,7 @@ defmodule El.CLI.Spec do test "renders boxed output with agent when present in opts" do expect(El.MockEl, :start, fn :session, opts when is_list(opts) -> :ok end) expect(El.MockSessionApi, :ask, fn :session, "who are you?" -> "response" end) + expect(El.MockSessionApi, :agent, fn :session -> "kent" end) stub(El.MockSessionApi, :info, fn :session -> %{messages: 0, last_prompt: nil, last_response: nil, model: nil, cwd: nil, id: nil} end) output = @@ -600,11 +906,11 @@ defmodule El.CLI.Spec do test "renders boxed output with model when present in opts" do expect(El.MockEl, :start, fn :session, opts when is_list(opts) -> :ok end) - stub(El.MockSessionApi, :info, fn :session -> %{messages: 0, last_prompt: nil, last_response: nil, model: nil, cwd: nil, id: nil} end) + expect(El.MockSessionApi, :probe_ask, fn :session, "who are you?" -> nil end) output = capture_io(fn -> - El.CLI.Start.handle_find_daemon_for_start("session", [model: "opus"], El.MockEl, [session_api: El.MockSessionApi]) + El.CLI.Start.handle_find_daemon_for_start("session", [model: "opus"], El.MockEl, [session_api: El.MockSessionApi, group_leader: El.MockGroupLeader]) end) assert output =~ "model: opus" @@ -613,10 +919,11 @@ defmodule El.CLI.Spec do test "renders boxed output with msgs count" do expect(El.MockEl, :start, fn :session, opts when is_list(opts) -> :ok end) stub(El.MockSessionApi, :info, fn :session -> %{messages: 5, last_prompt: nil, last_response: nil, model: nil, cwd: nil, id: nil} end) + stub(El.MockSessionApi, :agent, fn :session -> nil end) output = capture_io(fn -> - El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi]) + El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi, group_leader: El.MockGroupLeader]) end) assert output =~ "msgs: 5" @@ -625,10 +932,11 @@ defmodule El.CLI.Spec do test "renders boxed output with prompt when present" do expect(El.MockEl, :start, fn :session, opts when is_list(opts) -> :ok end) stub(El.MockSessionApi, :info, fn :session -> %{messages: 1, last_prompt: "who are you?", last_response: nil, model: nil, cwd: nil, id: nil} end) + stub(El.MockSessionApi, :agent, fn :session -> nil end) output = capture_io(fn -> - El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi]) + El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi, group_leader: El.MockGroupLeader]) end) assert output =~ "> who are you?" @@ -637,10 +945,11 @@ defmodule El.CLI.Spec do test "renders boxed output with response when present" do expect(El.MockEl, :start, fn :session, opts when is_list(opts) -> :ok end) stub(El.MockSessionApi, :info, fn :session -> %{messages: 1, last_prompt: "who are you?", last_response: "I am an agent", model: nil, cwd: nil, id: nil} end) + stub(El.MockSessionApi, :agent, fn :session -> nil end) output = capture_io(fn -> - El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi]) + El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi, group_leader: El.MockGroupLeader]) end) assert output =~ "I am an agent" @@ -649,10 +958,12 @@ defmodule El.CLI.Spec do test "omits agent row when agent not in opts" do expect(El.MockEl, :start, fn :session, opts when is_list(opts) -> :ok end) stub(El.MockSessionApi, :info, fn :session -> %{messages: 0, last_prompt: nil, last_response: nil, model: nil, cwd: nil, id: nil} end) + expect(El.MockSessionApi, :probe_ask, fn :session, "who are you?" -> nil end) + expect(El.MockSessionApi, :agent, fn :session -> nil end) output = capture_io(fn -> - El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi]) + El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi, group_leader: El.MockGroupLeader]) end) refute output =~ "agent:" @@ -661,10 +972,12 @@ defmodule El.CLI.Spec do test "omits model row when model not in opts" do expect(El.MockEl, :start, fn :session, opts when is_list(opts) -> :ok end) stub(El.MockSessionApi, :info, fn :session -> %{messages: 0, last_prompt: nil, last_response: nil, model: nil, cwd: nil, id: nil} end) + expect(El.MockSessionApi, :probe_ask, fn :session, "who are you?" -> nil end) + expect(El.MockSessionApi, :agent, fn :session -> nil end) output = capture_io(fn -> - El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi]) + El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi, group_leader: El.MockGroupLeader]) end) refute output =~ "model:" @@ -673,10 +986,12 @@ defmodule El.CLI.Spec do test "shows model from info when opts model is nil but info.model exists" do expect(El.MockEl, :start, fn :session, opts when is_list(opts) -> :ok end) stub(El.MockSessionApi, :info, fn :session -> %{messages: 0, last_prompt: nil, last_response: nil, model: "haiku", cwd: nil, id: nil} end) + expect(El.MockSessionApi, :probe_ask, fn :session, "who are you?" -> nil end) + expect(El.MockSessionApi, :agent, fn :session -> nil end) output = capture_io(fn -> - El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi]) + El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi, group_leader: El.MockGroupLeader]) end) assert output =~ "model: haiku" @@ -685,10 +1000,12 @@ defmodule El.CLI.Spec do test "omits prompt separator and prompt when last_prompt is nil" do expect(El.MockEl, :start, fn :session, opts when is_list(opts) -> :ok end) stub(El.MockSessionApi, :info, fn :session -> %{messages: 0, last_prompt: nil, last_response: nil, model: nil, cwd: nil, id: nil} end) + expect(El.MockSessionApi, :probe_ask, fn :session, "who are you?" -> nil end) + expect(El.MockSessionApi, :agent, fn :session -> nil end) output = capture_io(fn -> - El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi]) + El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi, group_leader: El.MockGroupLeader]) end) refute output =~ ">" @@ -698,10 +1015,11 @@ defmodule El.CLI.Spec do expect(El.MockEl, :start, fn :session, opts when is_list(opts) -> :ok end) long_response = "I'm Dude, man. The rug that ties this whole stack together." stub(El.MockSessionApi, :info, fn :session -> %{messages: 1, last_prompt: "who are you?", last_response: long_response, model: nil, cwd: nil, id: nil} end) + stub(El.MockSessionApi, :agent, fn :session -> nil end) output = capture_io(fn -> - El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi]) + El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi, group_leader: El.MockGroupLeader]) end) assert output =~ "stack together." @@ -711,10 +1029,11 @@ defmodule El.CLI.Spec do expect(El.MockEl, :start, fn :session, opts when is_list(opts) -> :ok end) long_response = "This is a very long response that will definitely wrap across multiple lines when formatted with word awareness at 46 characters per line" stub(El.MockSessionApi, :info, fn :session -> %{messages: 1, last_prompt: "who are you?", last_response: long_response, model: nil, cwd: nil, id: nil} end) + stub(El.MockSessionApi, :agent, fn :session -> nil end) output = capture_io(fn -> - El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi]) + El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi, group_leader: El.MockGroupLeader]) end) lines = String.split(output, "\n") @@ -726,19 +1045,21 @@ defmodule El.CLI.Spec do stub(El.MockEl, :start, fn :session, opts when is_list(opts) -> :ok end) stub(El.MockSessionApi, :info, fn :session -> %{messages: 0, last_prompt: nil, last_response: nil, model: nil, cwd: nil, id: nil} end) expect(El.MockSessionApi, :ask, fn :session, "who are you?" -> "response" end) + expect(El.MockSessionApi, :agent, fn :session -> "kent" end) capture_io(fn -> El.CLI.Start.handle_find_daemon_for_start("session", [agent: "kent"], El.MockEl, [session_api: El.MockSessionApi]) end) end - test "does not send ping when no agent in opts" do + test "pings even when no agent in opts if messages == 0" do stub(El.MockEl, :start, fn :session, opts when is_list(opts) -> :ok end) - stub(El.MockSessionApi, :info, fn :session -> %{messages: 0, last_prompt: nil, last_response: nil, model: nil, cwd: nil, id: nil} end) - expect(El.MockSessionApi, :ask, 0, fn _, _ -> "response" end) + expect(El.MockSessionApi, :info, 2, fn :session -> %{messages: 0, last_prompt: nil, last_response: nil, model: nil, cwd: nil, id: nil} end) + expect(El.MockSessionApi, :probe_ask, fn _, _ -> "response" end) + expect(El.MockSessionApi, :agent, fn :session -> nil end) capture_io(fn -> - El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi]) + El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi, group_leader: El.MockGroupLeader]) end) end @@ -746,6 +1067,7 @@ defmodule El.CLI.Spec do stub(El.MockEl, :start, fn :session, opts when is_list(opts) -> :ok end) stub(El.MockSessionApi, :info, fn :session -> %{messages: 5, last_prompt: nil, last_response: nil, model: nil, cwd: nil, id: nil} end) expect(El.MockSessionApi, :ask, 0, fn _, _ -> "response" end) + stub(El.MockSessionApi, :agent, fn :session -> "kent" end) capture_io(fn -> El.CLI.Start.handle_find_daemon_for_start("session", [agent: "kent"], El.MockEl, [session_api: El.MockSessionApi]) @@ -755,10 +1077,12 @@ defmodule El.CLI.Spec do test "omits msgs row when messages count is zero" do expect(El.MockEl, :start, fn :session, opts when is_list(opts) -> :ok end) stub(El.MockSessionApi, :info, fn :session -> %{messages: 0, last_prompt: nil, last_response: nil, model: nil, cwd: nil, id: nil} end) + expect(El.MockSessionApi, :probe_ask, fn :session, "who are you?" -> nil end) + expect(El.MockSessionApi, :agent, fn :session -> nil end) output = capture_io(fn -> - El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi]) + El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi, group_leader: El.MockGroupLeader]) end) refute output =~ "msgs:" @@ -767,6 +1091,8 @@ defmodule El.CLI.Spec do defp setup_cwd_id_session do expect(El.MockEl, :start, fn :session, opts when is_list(opts) -> :ok end) stub(El.MockSessionApi, :info, fn :session -> %{messages: 0, last_prompt: nil, last_response: nil, model: nil, cwd: "/abc/def", id: "abc123def456"} end) + stub(El.MockSessionApi, :probe_ask, fn :session, "who are you?" -> nil end) + stub(El.MockSessionApi, :agent, fn :session -> nil end) end test "renders name in two-column format" do @@ -774,7 +1100,7 @@ defmodule El.CLI.Spec do output = capture_io(fn -> - El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi]) + El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi, group_leader: El.MockGroupLeader]) end) assert output =~ "name: session" @@ -785,10 +1111,10 @@ defmodule El.CLI.Spec do output = capture_io(fn -> - El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi]) + El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi, group_leader: El.MockGroupLeader]) end) - refute output =~ "cwd:" + assert output =~ "cwd:" end test "renders id in two-column format" do @@ -796,7 +1122,7 @@ defmodule El.CLI.Spec do output = capture_io(fn -> - El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi]) + El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi, group_leader: El.MockGroupLeader]) end) assert output =~ "id: …23def456" @@ -805,6 +1131,8 @@ defmodule El.CLI.Spec do defp setup_anom_case do expect(El.MockEl, :start, fn :anom, opts when is_list(opts) -> :ok end) stub(El.MockSessionApi, :info, fn :anom -> %{messages: 0, last_prompt: nil, last_response: nil, model: nil, cwd: "/a/b/c/d/e/f/g/h", id: "xyz789abc123"} end) + stub(El.MockSessionApi, :probe_ask, fn :anom, "who are you?" -> nil end) + stub(El.MockSessionApi, :agent, fn :anom -> nil end) end test "renders name with cwd in two-column first row" do @@ -812,7 +1140,7 @@ defmodule El.CLI.Spec do output = capture_io(fn -> - El.CLI.Start.handle_find_daemon_for_start("anom", [], El.MockEl, [session_api: El.MockSessionApi]) + El.CLI.Start.handle_find_daemon_for_start("anom", [], El.MockEl, [session_api: El.MockSessionApi, group_leader: El.MockGroupLeader]) end) assert output =~ "name: anom" @@ -823,10 +1151,10 @@ defmodule El.CLI.Spec do output = capture_io(fn -> - El.CLI.Start.handle_find_daemon_for_start("anom", [], El.MockEl, [session_api: El.MockSessionApi]) + El.CLI.Start.handle_find_daemon_for_start("anom", [], El.MockEl, [session_api: El.MockSessionApi, group_leader: El.MockGroupLeader]) end) - refute output =~ "cwd:" + assert output =~ "cwd:" end test "renders truncated id in anom case" do @@ -834,7 +1162,7 @@ defmodule El.CLI.Spec do output = capture_io(fn -> - El.CLI.Start.handle_find_daemon_for_start("anom", [], El.MockEl, [session_api: El.MockSessionApi]) + El.CLI.Start.handle_find_daemon_for_start("anom", [], El.MockEl, [session_api: El.MockSessionApi, group_leader: El.MockGroupLeader]) end) assert output =~ "id: …89abc123" @@ -845,7 +1173,7 @@ defmodule El.CLI.Spec do output = capture_io(fn -> - El.CLI.Start.handle_find_daemon_for_start("anom", [], El.MockEl, [session_api: El.MockSessionApi]) + El.CLI.Start.handle_find_daemon_for_start("anom", [], El.MockEl, [session_api: El.MockSessionApi, group_leader: El.MockGroupLeader]) end) refute output =~ "agent:" @@ -856,7 +1184,7 @@ defmodule El.CLI.Spec do output = capture_io(fn -> - El.CLI.Start.handle_find_daemon_for_start("anom", [], El.MockEl, [session_api: El.MockSessionApi]) + El.CLI.Start.handle_find_daemon_for_start("anom", [], El.MockEl, [session_api: El.MockSessionApi, group_leader: El.MockGroupLeader]) end) refute output =~ "model:" @@ -867,7 +1195,7 @@ defmodule El.CLI.Spec do output = capture_io(fn -> - El.CLI.Start.handle_find_daemon_for_start("anom", [], El.MockEl, [session_api: El.MockSessionApi]) + El.CLI.Start.handle_find_daemon_for_start("anom", [], El.MockEl, [session_api: El.MockSessionApi, group_leader: El.MockGroupLeader]) end) refute output =~ "msgs:" @@ -876,6 +1204,7 @@ defmodule El.CLI.Spec do defp setup_agent_kent_session do expect(El.MockEl, :start, fn :kent, opts when is_list(opts) -> :ok end) expect(El.MockSessionApi, :ask, fn :kent, "who are you?" -> "response" end) + expect(El.MockSessionApi, :agent, fn :kent -> "kent" end) stub(El.MockSessionApi, :info, fn :kent -> %{messages: 0, last_prompt: nil, last_response: nil, model: "opus", cwd: "/verylong/path/name", id: "kent1234567890"} end) end @@ -939,7 +1268,7 @@ defmodule El.CLI.Spec do output = capture_io(fn -> - El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi]) + El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi, group_leader: El.MockGroupLeader]) end) lines = String.split(output, "\n") @@ -952,7 +1281,7 @@ defmodule El.CLI.Spec do output = capture_io(fn -> - El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi]) + El.CLI.Start.handle_find_daemon_for_start("session", [], El.MockEl, [session_api: El.MockSessionApi, group_leader: El.MockGroupLeader]) end) lines = String.split(output, "\n") @@ -989,50 +1318,52 @@ defmodule El.CLI.Spec do test "renders model in model/cwd pairing when no agent" do expect(El.MockEl, :start, fn :anom, opts when is_list(opts) -> :ok end) stub(El.MockSessionApi, :info, fn :anom -> %{messages: 0, last_prompt: nil, last_response: nil, model: "haiku", cwd: "/abc/def", id: "xyz789abc123"} end) + stub(El.MockSessionApi, :probe_ask, fn :anom, "who are you?" -> nil end) + stub(El.MockSessionApi, :agent, fn :anom -> nil end) output = capture_io(fn -> - El.CLI.Start.handle_find_daemon_for_start("anom", [], El.MockEl, [session_api: El.MockSessionApi]) + El.CLI.Start.handle_find_daemon_for_start("anom", [], El.MockEl, [session_api: El.MockSessionApi, group_leader: El.MockGroupLeader]) end) - lines = String.split(output, "\n") - third_line = Enum.at(lines, 2) - assert third_line =~ "model: haiku" + assert output =~ "model: haiku" end test "renders cwd in model/cwd pairing when no agent" do expect(El.MockEl, :start, fn :anom, opts when is_list(opts) -> :ok end) stub(El.MockSessionApi, :info, fn :anom -> %{messages: 0, last_prompt: nil, last_response: nil, model: "haiku", cwd: "/abc/def", id: "xyz789abc123"} end) + stub(El.MockSessionApi, :probe_ask, fn :anom, "who are you?" -> nil end) + stub(El.MockSessionApi, :agent, fn :anom -> nil end) output = capture_io(fn -> - El.CLI.Start.handle_find_daemon_for_start("anom", [], El.MockEl, [session_api: El.MockSessionApi]) + El.CLI.Start.handle_find_daemon_for_start("anom", [], El.MockEl, [session_api: El.MockSessionApi, group_leader: El.MockGroupLeader]) end) - lines = String.split(output, "\n") - third_line = Enum.at(lines, 2) - assert third_line =~ "cwd: /abc/def" + assert output =~ "cwd: /abc/def" end - test "drops cwd row when only name exists" do + test "renders output with name and cwd when no model" do expect(El.MockEl, :start, fn :anom, opts when is_list(opts) -> :ok end) stub(El.MockSessionApi, :info, fn :anom -> %{messages: 0, last_prompt: nil, last_response: nil, model: nil, cwd: "/abc/def", id: "xyz789abc123"} end) + stub(El.MockSessionApi, :probe_ask, fn :anom, "who are you?" -> nil end) + stub(El.MockSessionApi, :agent, fn :anom -> nil end) output = capture_io(fn -> - El.CLI.Start.handle_find_daemon_for_start("anom", [], El.MockEl, [session_api: El.MockSessionApi]) + El.CLI.Start.handle_find_daemon_for_start("anom", [], El.MockEl, [session_api: El.MockSessionApi, group_leader: El.MockGroupLeader]) end) - lines = String.split(output, "\n") - box_lines = Enum.filter(lines, fn line -> String.starts_with?(line, "│") end) - assert length(box_lines) == 1 + assert output =~ "anom" + assert output =~ "cwd:" end end describe "El.CLI.Start.handle_find_daemon_with_rest/5" do setup do stub(El.MockSessionApi, :info, fn :kenny -> %{messages: 0, last_prompt: nil, last_response: nil, model: nil, cwd: nil, id: nil} end) - stub(El.MockSessionApi, :ask, fn _, _ -> "test response" end) + stub(El.MockSessionApi, :probe_ask, fn _, _ -> "test response" end) + stub(El.MockSessionApi, :agent, fn :kenny -> nil end) stub(El.MockGroupLeader, :open_null_device, fn -> :null_device end) stub(El.MockGroupLeader, :get, fn -> :original_leader end) stub(El.MockGroupLeader, :set, fn _, _ -> true end) @@ -1042,6 +1373,8 @@ defmodule El.CLI.Spec do test "renders boxed output with agent when provided" do expect(El.MockEl, :start, fn :kenny, opts when is_list(opts) -> :ok end) + expect(El.MockSessionApi, :ask, fn _, _ -> "test response" end) + expect(El.MockSessionApi, :agent, fn :kenny -> "kent" end) output = capture_io(fn -> @@ -1059,6 +1392,9 @@ defmodule El.CLI.Spec do end) expect(El.MockEl, :start, fn :kenny, opts when is_list(opts) -> :ok end) + expect(El.MockSessionApi, :ask, fn _, _ -> "test response" end) + expect(El.MockSessionApi, :agent, fn :kenny -> "kent" end) + stub(El.MockSessionApi, :info, fn :kenny -> %{messages: 0, last_prompt: nil, last_response: nil, model: nil, cwd: nil, id: nil} end) output = capture_io(fn -> diff --git a/specs/el/lifecycle_spec.exs b/specs/el/lifecycle_spec.exs index 0d542baf..837d64cb 100644 --- a/specs/el/lifecycle_spec.exs +++ b/specs/el/lifecycle_spec.exs @@ -35,4 +35,17 @@ defmodule El.Lifecycle.Spec do verify!(El.MockSessionMeta) end + + test "session_meta survives restart" do + expect(El.MockRegistry, :lookup, fn El.Registry, :restarted_session -> [] end) + stub(El.MockMonitor, :wait_for_down, fn _, _, _ -> :ok end) + + El.Lifecycle.exit(:restarted_session, :restart, [ + registry: El.MockRegistry, + session_meta: El.MockSessionMeta, + monitor: El.MockMonitor + ]) + + verify!(El.MockSessionMeta) + end end diff --git a/specs/el/pattern_spec.exs b/specs/el/pattern_spec.exs new file mode 100644 index 00000000..4029ef6a --- /dev/null +++ b/specs/el/pattern_spec.exs @@ -0,0 +1,81 @@ +defmodule El.Pattern.Spec do + use ExUnit.Case + import Mox + + setup_all do + Code.ensure_loaded!(El.Pattern) + Code.ensure_loaded!(El.Behaviours.Pattern) + :ok + end + + setup :verify_on_exit! + + describe "El.Pattern" do + test "declares @behaviour El.Behaviours.Pattern" do + assert El.Behaviours.Pattern in El.Pattern.module_info(:attributes)[:behaviour] || [] + end + end + + describe "El.Pattern.restart/2" do + setup do + stub(El.MockEl, :restart, fn _name, _opts -> :ok end) + stub(El.MockSessionRegistry, :list, fn _opts -> [:agent1] end) + :ok + end + + test "restarts sessions matching pattern" do + expect(El.MockEl, :restart, fn :agent1, _opts -> :ok end) + + El.Pattern.restart("agent*", el: El.MockEl, session_api: El.MockSessionApi) + end + end + + describe "El.Pattern.exit/2" do + setup do + stub(El.MockEl, :exit, fn _name, _opts -> :ok end) + stub(El.MockSessionRegistry, :list, fn _opts -> [:agent1] end) + :ok + end + + test "exits sessions matching pattern" do + expect(El.MockEl, :exit, fn :agent1, _opts -> :ok end) + + El.Pattern.exit("agent*", el: El.MockEl, session_api: El.MockSessionApi) + end + end + + describe "El.Pattern.clear/2" do + setup do + stub(El.MockEl, :clear, fn _name, _opts -> :ok end) + stub(El.MockSessionRegistry, :list, fn _opts -> [:agent1] end) + :ok + end + + test "clears sessions matching pattern" do + expect(El.MockEl, :clear, fn :agent1, _opts -> :ok end) + + El.Pattern.clear("agent*", el: El.MockEl, session_api: El.MockSessionApi) + end + end + + describe "El.Pattern.log/3" do + setup do + stub(El.MockSessionRegistry, :list, fn _opts -> [:dude, :dude_two] end) + :ok + end + + test "forwards :all to session_api per matching session" do + expect(El.MockSessionApi, :log, 2, fn name, :all when name in [:dude, :dude_two] -> [] end) + + El.Pattern.log("dude*", :all, session_api: El.MockSessionApi) + end + + test "flattens log entries from each matching session" do + stub(El.MockSessionApi, :log, fn _, _ -> [{"ask", "hi", "yo", %{}}] end) + + result = El.Pattern.log("dude*", :all, session_api: El.MockSessionApi) + + assert length(result) == 2 + end + end +end diff --git a/specs/el/platform/code_spec.exs b/specs/el/platform_spec.exs similarity index 76% rename from specs/el/platform/code_spec.exs rename to specs/el/platform_spec.exs index 191ecae4..6358ef51 100644 --- a/specs/el/platform/code_spec.exs +++ b/specs/el/platform_spec.exs @@ -1,4 +1,4 @@ -defmodule El.Platform.Code.Spec do +defmodule El.Platform.Spec do use ExUnit.Case import Mox @@ -16,7 +16,7 @@ defmodule El.Platform.Code.Spec do {:ok, self()} end) - El.Platform.Code.start_link(session_id: "test-session-id", session_module: El.MockClaudeCodeSession) + El.Platform.start_link(session_id: "test-session-id", session_module: El.MockClaudeCodeSession) end test "passes adapter configuration tuple" do @@ -25,7 +25,7 @@ defmodule El.Platform.Code.Spec do {:ok, self()} end) - El.Platform.Code.start_link(session_module: El.MockClaudeCodeSession) + El.Platform.start_link(session_module: El.MockClaudeCodeSession) end test "passes dangerously_skip_permissions flag as true" do @@ -34,7 +34,7 @@ defmodule El.Platform.Code.Spec do {:ok, self()} end) - El.Platform.Code.start_link(session_module: El.MockClaudeCodeSession) + El.Platform.start_link(session_module: El.MockClaudeCodeSession) end test "includes model when provided" do @@ -43,7 +43,7 @@ defmodule El.Platform.Code.Spec do {:ok, self()} end) - El.Platform.Code.start_link( + El.Platform.start_link( model: "claude-3-5-haiku", session_module: El.MockClaudeCodeSession ) @@ -55,7 +55,7 @@ defmodule El.Platform.Code.Spec do {:ok, self()} end) - El.Platform.Code.start_link(session_module: El.MockClaudeCodeSession) + El.Platform.start_link(session_module: El.MockClaudeCodeSession) end test "passes configured cli_path from options" do @@ -65,7 +65,7 @@ defmodule El.Platform.Code.Spec do {:ok, self()} end) - El.Platform.Code.start_link(cli_path: "/custom/path", session_module: El.MockClaudeCodeSession) + El.Platform.start_link(cli_path: "/custom/path", session_module: El.MockClaudeCodeSession) end test "defaults cli_path to :global when not configured" do @@ -77,7 +77,7 @@ defmodule El.Platform.Code.Spec do {:ok, self()} end) - El.Platform.Code.start_link(session_module: El.MockClaudeCodeSession) + El.Platform.start_link(session_module: El.MockClaudeCodeSession) end test "passes resume option when provided" do @@ -86,7 +86,7 @@ defmodule El.Platform.Code.Spec do {:ok, self()} end) - El.Platform.Code.start_link( + El.Platform.start_link( resume: "abc-123-def", session_module: El.MockClaudeCodeSession ) @@ -98,7 +98,7 @@ defmodule El.Platform.Code.Spec do {:ok, self()} end) - El.Platform.Code.start_link(session_module: El.MockClaudeCodeSession) + El.Platform.start_link(session_module: El.MockClaudeCodeSession) end test "start_link/1 passes :resume to session_module when given in opts" do @@ -107,7 +107,7 @@ defmodule El.Platform.Code.Spec do {:ok, self()} end) - El.Platform.Code.start_link(session_id: "abc", session_module: El.MockClaudeCodeSession, resume: "abc") + El.Platform.start_link(session_id: "abc", session_module: El.MockClaudeCodeSession, resume: "abc") end test "includes setting_sources in session options" do @@ -116,7 +116,7 @@ defmodule El.Platform.Code.Spec do {:ok, self()} end) - El.Platform.Code.start_link(session_module: El.MockClaudeCodeSession) + El.Platform.start_link(session_module: El.MockClaudeCodeSession) end test "passes :resume to session module from env hook" do @@ -125,7 +125,7 @@ defmodule El.Platform.Code.Spec do {:ok, self()} end) - El.Platform.Code.start_link(session_id: "session-abc", resume: "session-abc", session_module: El.MockClaudeCodeSession) + El.Platform.start_link(session_id: "session-abc", resume: "session-abc", session_module: El.MockClaudeCodeSession) end test "omits session_id when not provided" do @@ -134,7 +134,7 @@ defmodule El.Platform.Code.Spec do {:ok, self()} end) - El.Platform.Code.start_link(session_module: El.MockClaudeCodeSession) + El.Platform.start_link(session_module: El.MockClaudeCodeSession) end end @@ -146,7 +146,7 @@ defmodule El.Platform.Code.Spec do {:ok, "streamed"} end) - result = El.Platform.Code.stream(:test_pid, "test prompt", session_module: El.MockClaudeCodeSession) + result = El.Platform.stream(:test_pid, "test prompt", session_module: El.MockClaudeCodeSession) assert result == {:ok, "streamed"} end end diff --git a/specs/el/restart_spec.exs b/specs/el/restart_spec.exs new file mode 100644 index 00000000..fe707426 --- /dev/null +++ b/specs/el/restart_spec.exs @@ -0,0 +1,91 @@ +defmodule El.RestartSpec do + use ExUnit.Case, async: false + import Mox + + setup :verify_on_exit! + + describe "restart/2" do + setup do + session_id = "abc-123-def" + + expect(El.MockRegistry, :lookup, fn El.Registry, :kent -> + [{:pid, :meta}] + end) + + stub(El.MockSupervisor, :terminate_child, fn El.SessionSupervisor, :pid -> :ok end) + stub(El.MockMonitor, :wait_for_down, fn _ref, :kent, _opts -> :ok end) + + expect(El.MockSessionMeta, :lookup, fn :kent -> + {:ok, session_id, "dude", "claude"} + end) + + expect(El.MockRegistry, :lookup, fn El.Registry, :kent -> + [] + end) + + stub(El.MockSessionDeletion, :delete_session_messages, fn _ -> :ok end) + + opts = [ + registry: El.MockRegistry, + supervisor: El.MockSupervisor, + session_meta: El.MockSessionMeta, + monitor: El.MockMonitor, + app: El.MockSessionDeletion + ] + + {:ok, session_id: session_id, restart_opts: opts} + end + + test "restart calls Lifecycle.exit with :restart reason" do + _session_id = "abc-123-def" + + expect(El.MockSupervisor, :start_child, fn El.SessionSupervisor, + %{start: {El.Session.Api, :start_link, [{:kent, _opts}]}} -> + {:ok, :pid} + end) + + opts = [ + registry: El.MockRegistry, + supervisor: El.MockSupervisor, + session_meta: El.MockSessionMeta, + monitor: El.MockMonitor, + app: El.MockSessionDeletion + ] + + El.restart(:kent, opts) + end + + test "restart starts session with resume from meta", context do + expect(El.MockSupervisor, :start_child, fn El.SessionSupervisor, + %{start: {El.Session.Api, :start_link, [{:kent, opts}]}} -> + send(self(), {:captured_resume, Keyword.get(opts, :resume)}) + {:ok, :pid} + end) + + El.restart(:kent, context.restart_opts) + assert_receive {:captured_resume, session_id} when session_id == context.session_id + end + + test "restart preserves agent from meta", context do + expect(El.MockSupervisor, :start_child, fn El.SessionSupervisor, + %{start: {El.Session.Api, :start_link, [{:kent, opts}]}} -> + send(self(), {:captured_agent, Keyword.get(opts, :agent)}) + {:ok, :pid} + end) + + El.restart(:kent, context.restart_opts) + assert_receive {:captured_agent, agent} when agent == "dude" + end + + test "restart preserves model from meta", context do + expect(El.MockSupervisor, :start_child, fn El.SessionSupervisor, + %{start: {El.Session.Api, :start_link, [{:kent, opts}]}} -> + send(self(), {:captured_model, Keyword.get(opts, :model)}) + {:ok, :pid} + end) + + El.restart(:kent, context.restart_opts) + assert_receive {:captured_model, model} when model == "claude" + end + end +end diff --git a/specs/el/session/handlers/ask_probe_spec.exs b/specs/el/session/handlers/ask_probe_spec.exs new file mode 100644 index 00000000..5deaf935 --- /dev/null +++ b/specs/el/session/handlers/ask_probe_spec.exs @@ -0,0 +1,124 @@ +defmodule El.Session.Handlers.Ask.ProbeSpec do + use ExUnit.Case + import Mox + setup :verify_on_exit! + + setup do + Mox.stub(El.MockSessionMeta, :insert, fn _, _, _, _ -> :ok end) + Mox.stub(El.MockFileSystem, :cwd!, fn -> "/test/dir" end) + + state = %{ + name: :test_session, + claude_pid: nil, + session_id: nil, + cwd: "/test/dir", + messages: [], + pending_calls: [], + claude_module: MockSessionModule, + task_module: MockSessionModule, + alive_fn: fn _ -> false end, + registry_module: MockSessionModule, + store_module: MockProbeStore, + session_meta: El.MockSessionMeta, + ask_module: El.Session.Handlers.Ask, + session_api: El.MockSessionApi, + el_module: El.MockEl, + opts: [], + claude_opts: [] + } + + {:ok, state: state} + end + + describe "prepare_probe" do + test "does not store message to messages list", %{state: state} do + from = {self(), make_ref()} + {_ref, state_after} = El.Session.Handlers.Ask.prepare_probe(state, from, "who are you?") + assert state_after.messages == [] + end + + test "still adds from to pending_calls", %{state: state} do + from = {self(), make_ref()} + {_ref, state_after} = El.Session.Handlers.Ask.prepare_probe(state, from, "who are you?") + assert from in state_after.pending_calls + end + + test "returns a ref for GenServer reply", %{state: state} do + from = {self(), make_ref()} + {ref, _state_after} = El.Session.Handlers.Ask.prepare_probe(state, from, "who are you?") + assert is_reference(ref) + end + end + + describe "prepare_ask (normal ask)" do + test "stores message to messages list", %{state: state} do + from = {self(), make_ref()} + {_ref, state_after} = El.Session.Handlers.Ask.prepare_ask(state, from, "who are you?") + assert length(state_after.messages) == 1 + assert [{"ask", "who are you?", "", %{ref: _ref}}] = state_after.messages + end + + test "adds from to pending_calls", %{state: state} do + from = {self(), make_ref()} + {_ref, state_after} = El.Session.Handlers.Ask.prepare_ask(state, from, "who are you?") + assert from in state_after.pending_calls + end + end + + describe "integration: probe flow with session_id capture" do + test "probe message does not appear in final messages", %{state: state} do + from = {self(), make_ref()} + {ref, state_after_prepare} = El.Session.Handlers.Ask.prepare_probe(state, from, "who are you?") + + assert state_after_prepare.messages == [] + + ask = %{from: from, ref: ref, message: "who are you?", response: "I am Claude", model: nil} + state_after_finalize = El.Session.Handlers.Ask.finalize_probe(state_after_prepare, ask) + + assert state_after_finalize.messages == [] + end + + test "normal ask stores message even if it is 'who are you?'", %{state: state} do + from = {self(), make_ref()} + {ref, state_after_prepare} = El.Session.Handlers.Ask.prepare_ask(state, from, "who are you?") + + assert length(state_after_prepare.messages) == 1 + + ask = %{from: from, ref: ref, message: "who are you?", response: "I am Claude", model: "claude-3-opus"} + state_after_finalize = El.Session.Handlers.Ask.finalize_ask(state_after_prepare, ask) + + assert length(state_after_finalize.messages) == 1 + assert [{"ask", "who are you?", "I am Claude", %{model: "claude-3-opus"}}] = state_after_finalize.messages + end + end +end + +defmodule MockProbeStore do + def store_message(_, _, _), do: :ok + def store_message(_, _), do: :ok + def load_messages(_, _opts \\ []), do: [] + def delete_message(_, _, _), do: :ok + def delete_message(_, _), do: :ok + def delete_session_messages(_), do: :ok + def delete_ask_entry(_state, _message, _ref), do: :ok + def store_ask_entry(_, _), do: :ok + def replace_ask(messages, ref, message, response, model) do + messages + |> Enum.split_while(&match_pending(&1, ref)) + |> complete(message, response, model) + end + + defp match_pending({_, _, "", %{ref: ref}}, ref), do: false + defp match_pending(_, _), do: true + + defp complete({before, [{_, _, _, _} | rest]}, message, response, model) do + before ++ [{"ask", message, response, metadata(model)} | rest] + end + + defp complete({messages, []}, message, response, model) do + messages ++ [{"ask", message, response, metadata(model)}] + end + + defp metadata(nil), do: %{} + defp metadata(model), do: %{model: model} +end diff --git a/specs/el/session/commands/ask_spec.exs b/specs/el/session/handlers/ask_spec.exs similarity index 81% rename from specs/el/session/commands/ask_spec.exs rename to specs/el/session/handlers/ask_spec.exs index 1d8034b2..ba20817c 100644 --- a/specs/el/session/commands/ask_spec.exs +++ b/specs/el/session/handlers/ask_spec.exs @@ -1,4 +1,4 @@ -defmodule El.Session.Commands.Ask.Spec do +defmodule El.Session.Handlers.Ask.Spec do use ExUnit.Case import Mox @@ -19,7 +19,7 @@ defmodule El.Session.Commands.Ask.Spec do expect(El.MockSessionClaude, :ask_work, fn _, _, _ -> {"test result", "test-model", "test-session-id"} end) - El.Session.Commands.Ask.spawn_ask(state, ask_info, [], server_pid) + El.Session.Handlers.Ask.spawn_ask(state, ask_info, [], server_pid) assert_receive {:"$gen_cast", {:complete_ask, _, "test message", "test result", _, @@ -48,7 +48,7 @@ defmodule El.Session.Commands.Ask.Spec do from = {self(), make_ref()} ref = make_ref() - El.Session.Commands.Ask.finalize_ask(state, %{from: from, ref: ref, message: "question", response: "answer", model: "claude-3"}) + El.Session.Handlers.Ask.finalize_ask(state, %{from: from, ref: ref, message: "question", response: "answer", model: "claude-3"}) end test "calls store with nil when model is nil" do @@ -63,7 +63,7 @@ defmodule El.Session.Commands.Ask.Spec do from = {self(), make_ref()} ref = make_ref() - El.Session.Commands.Ask.finalize_ask(state, %{from: from, ref: ref, message: "question", response: "answer", model: nil}) + El.Session.Handlers.Ask.finalize_ask(state, %{from: from, ref: ref, message: "question", response: "answer", model: nil}) end test "replies to caller with response" do @@ -78,7 +78,7 @@ defmodule El.Session.Commands.Ask.Spec do caller_ref = make_ref() from = {self(), caller_ref} - El.Session.Commands.Ask.finalize_ask(state, %{from: from, ref: make_ref(), message: "test", response: "the answer", model: "claude-3"}) + El.Session.Handlers.Ask.finalize_ask(state, %{from: from, ref: make_ref(), message: "test", response: "the answer", model: "claude-3"}) assert_receive {^caller_ref, "the answer"} end @@ -95,7 +95,7 @@ defmodule El.Session.Commands.Ask.Spec do from = {self(), make_ref()} - returned_state = El.Session.Commands.Ask.finalize_ask(state, %{from: from, ref: pending_ref, message: "question", response: "answer", model: "claude-3"}) + returned_state = El.Session.Handlers.Ask.finalize_ask(state, %{from: from, ref: pending_ref, message: "question", response: "answer", model: "claude-3"}) assert returned_state.messages == [{"ask", "question", "answer", %{model: "claude-3"}}] end @@ -121,7 +121,7 @@ defmodule El.Session.Commands.Ask.Spec do from = {self(), make_ref()} - {_ref, new_state} = El.Session.Commands.Ask.prepare_ask(state, from, "test question") + {_ref, new_state} = El.Session.Handlers.Ask.prepare_ask(state, from, "test question") assert [{"ask", "test question", "", %{ref: _}}] = new_state.messages end @@ -137,7 +137,7 @@ defmodule El.Session.Commands.Ask.Spec do from = {self(), make_ref()} - {_ref, new_state} = El.Session.Commands.Ask.prepare_ask(state, from, "test question") + {_ref, new_state} = El.Session.Handlers.Ask.prepare_ask(state, from, "test question") ref = new_state.messages |> hd() |> elem(3) |> Map.fetch!(:ref) assert is_reference(ref) @@ -159,7 +159,7 @@ defmodule El.Session.Commands.Ask.Spec do from = {self(), make_ref()} - {_ref, new_state} = El.Session.Commands.Ask.prepare_ask(state, from, "@target> routed question") + {_ref, new_state} = El.Session.Handlers.Ask.prepare_ask(state, from, "@target> routed question") assert new_state.messages == [] end @@ -188,18 +188,20 @@ defmodule El.Session.Commands.Ask.Spec do {:ok, state: state} end - test "reset_session generates a session_id different from previous", %{state: state} do - new_state = El.Session.Commands.Ask.reset_session(state) - assert new_state.session_id != "test-session-id" + test "reset_session leaves session_id as nil", %{state: state} do + new_state = El.Session.Handlers.Ask.reset_session(state) + assert new_state.session_id == nil end - test "reset_session generates a binary session_id", %{state: state} do - new_state = El.Session.Commands.Ask.reset_session(state) - assert is_binary(new_state.session_id) + test "reset_session strips :session_id from claude_opts", %{state: state} do + state_with_session_id = %{state | opts: [session_id: "some-id", other: "value"]} + new_state = El.Session.Handlers.Ask.reset_session(state_with_session_id) + assert Keyword.get(new_state.claude_opts, :session_id) == nil + assert Keyword.get(new_state.claude_opts, :other) == "value" end test "clears state.messages to empty list", %{state: state} do - new_state = El.Session.Commands.Ask.reset_session(state) + new_state = El.Session.Handlers.Ask.reset_session(state) assert new_state.messages == [] end @@ -210,13 +212,13 @@ defmodule El.Session.Commands.Ask.Spec do :ok end) - El.Session.Commands.Ask.reset_session(state) + El.Session.Handlers.Ask.reset_session(state) assert_receive {:delete_session_messages, :test_session} end test "sets claude_pid via claude_module", %{state: state} do - new_state = El.Session.Commands.Ask.reset_session(state) + new_state = El.Session.Handlers.Ask.reset_session(state) assert new_state.claude_pid == :mock_pid end diff --git a/specs/el/session/handlers/cast_spec.exs b/specs/el/session/handlers/cast_spec.exs index 15f22073..b58f36fb 100644 --- a/specs/el/session/handlers/cast_spec.exs +++ b/specs/el/session/handlers/cast_spec.exs @@ -84,5 +84,54 @@ defmodule El.Session.Handlers.Cast.Spec do assert_receive {:call, "session_name", "test_agent", "sid", "model"} end + + test "complete_ask cast handler populates session_id from Claude response" do + pid = self() + ref = make_ref() + + stub(El.MockSessionMeta, :insert, fn _name, _agent, _session_id, _model -> + :ok + end) + + stub(El.MockSessionAsk, :finalize_ask, fn state, _ask -> + state + end) + + state = %{ + name: "session_name", + session_id: nil, + opts: [agent: "test_agent"], + session_meta: El.MockSessionMeta, + ask_module: El.MockSessionAsk, + messages: [] + } + + {:noreply, new_state} = + Cast.handle({:complete_ask, pid, "any message", "any response", ref, "haiku", "new-uuid-from-claude"}, state) + + assert new_state.session_id == "new-uuid-from-claude" + end + + test "complete_ask cast handler with nil session_id leaves state.session_id unchanged" do + pid = self() + ref = make_ref() + + stub(El.MockSessionAsk, :finalize_ask, fn state, _ask -> + state + end) + + state = %{ + name: "session_name", + session_id: "existing-uuid", + opts: [agent: "test_agent"], + ask_module: El.MockSessionAsk, + messages: [] + } + + {:noreply, new_state} = + Cast.handle({:complete_ask, pid, "msg", "resp", ref, "haiku", nil}, state) + + assert new_state.session_id == "existing-uuid" + end end end diff --git a/specs/el/session/commands/tell_spec.exs b/specs/el/session/handlers/tell_spec.exs similarity index 86% rename from specs/el/session/commands/tell_spec.exs rename to specs/el/session/handlers/tell_spec.exs index ce71a63c..f01f823d 100644 --- a/specs/el/session/commands/tell_spec.exs +++ b/specs/el/session/handlers/tell_spec.exs @@ -1,4 +1,4 @@ -defmodule El.Session.Commands.Tell.Spec do +defmodule El.Session.Handlers.Tell.Spec do use ExUnit.Case import Mox @@ -21,7 +21,7 @@ defmodule El.Session.Commands.Tell.Spec do } ref = make_ref() - El.Session.Commands.Tell.process_tell(state, "hello", ref, []) + El.Session.Handlers.Tell.process_tell(state, "hello", ref, []) assert_receive {:asked, :fake_pid, "hello"} end @@ -38,7 +38,7 @@ defmodule El.Session.Commands.Tell.Spec do } ref = make_ref() - El.Session.Commands.Tell.process_tell(state, "hello", ref, []) + El.Session.Handlers.Tell.process_tell(state, "hello", ref, []) assert_receive {:"$gen_cast", {:store_tell, ^ref, "hello", "the answer"}} end diff --git a/specs/el/session_spec.exs b/specs/el/session_spec.exs index 45ebf5d0..8154b8de 100644 --- a/specs/el/session_spec.exs +++ b/specs/el/session_spec.exs @@ -5,7 +5,7 @@ defmodule El.Session.Spec do setup do Mox.stub(El.MockSessionMeta, :insert, fn _, _, _, _ -> :ok end) - Mox.stub(El.MockFileSystem, :cwd, fn -> "/test/dir" end) + Mox.stub(El.MockFileSystem, :cwd!, fn -> "/test/dir" end) Mox.stub(El.MockSessionAsk, :prepare_ask, fn state, _from, _message -> {make_ref(), state} end) diff --git a/specs/test_helper.exs b/specs/test_helper.exs index 837462fa..9d7622b3 100644 --- a/specs/test_helper.exs +++ b/specs/test_helper.exs @@ -46,7 +46,7 @@ Mox.defmock(El.MockSessionDeletion, for: El.MessageStore.Behaviours.SessionDelet Mox.defmock(El.MockMonitor, for: El.Infra.Behaviours.Monitor) Mox.defmock(El.MockEl, for: El.Behaviours.El) Mox.defmock(El.MockFileSystem, for: El.Infra.Behaviours.FileSystem) -Mox.stub(El.MockFileSystem, :cwd, fn -> "/tmp/test" end) +Mox.stub(El.MockFileSystem, :cwd!, fn -> "/tmp/test" end) Mox.stub(El.MockFileSystem, :mkdir_p!, fn _path -> :ok end) defmodule ClaudeCode.SessionStub do @@ -114,19 +114,26 @@ Mox.defmock(El.MockSessionRestorer, for: El.Session.Behaviours.Restorer) Mox.defmock(El.MockAgentDetector, for: El.Agent.Behaviours.Detector) Mox.defmock(El.MockAgentMetadata, for: El.Agent.Behaviours.Metadata) Mox.defmock(El.MockEnv, for: El.Infra.Behaviours.Env) -Mox.defmock(El.MockCCParser, for: El.Platform.Behaviours.Parser) +Mox.defmock(El.MockExecutable, for: El.Infra.Behaviours.Executable) +Mox.defmock(El.MockCCParser, for: El.ClaudePort.Behaviours.Parser) Mox.defmock(El.MockJSONDecoder, for: El.Infra.Behaviours.JSONDecoder) Mox.defmock(El.MockCardBox, for: El.CLI.Behaviours.CardBox) Mox.defmock(El.MockTextFormatter, for: El.CLI.Behaviours.TextFormatter) -Mox.defmock(El.MockParser, for: El.ClaudePort.Behaviours.Parser) -Mox.defmock(El.MockParserEventSchema, for: El.ClaudePort.Behaviours.Parser.EventSchema) -Mox.defmock(El.MockParserResult, for: El.ClaudePort.Behaviours.Parser.Result) +Mox.defmock(El.MockParser, for: El.ClaudePort.Behaviours.ParserExtract) +Mox.defmock(El.MockParserEventSchema, for: El.ClaudePort.Behaviours.ParserExtract.EventSchema) +Mox.defmock(El.MockParserResult, for: El.ClaudePort.Behaviours.ParserExtract.Result) Mox.defmock(El.MockClaudePortConnection, for: El.ClaudePort.Behaviours.Connection) Mox.defmock(El.MockSessionBootstrap, for: El.Session.Behaviours.Bootstrap) Mox.defmock(El.MockClaudePortCliResolver, for: El.ClaudePort.Behaviours.CliResolver) Mox.defmock(El.MockClaudePortPortSpawn, for: El.ClaudePort.Behaviours.PortSpawn) Mox.defmock(El.MockClaudePortCloser, for: El.ClaudePort.Behaviours.Closer) Mox.defmock(El.MockSessionRegistry, for: El.Session.Behaviours.Registry) +Mox.defmock(El.MockRPC, for: El.Infra.Behaviours.RPC) +Mox.defmock(El.MockNodeMonitor, for: El.Infra.Behaviours.NodeMonitor) +Mox.defmock(El.MockDaemon, for: El.CLI.Behaviours.Daemon) +Mox.defmock(El.MockDaemonEnv, for: El.CLI.Daemon.Behaviours.Env) +Mox.defmock(El.MockDaemonConnection, for: El.CLI.Daemon.Behaviours.Connection) +Mox.defmock(El.MockPattern, for: El.Behaviours.Pattern) Mox.stub(El.MockNodeConnector, :connect, fn _ -> false end) Mox.stub(El.MockNodeConnector, :set_cookie, fn _ -> true end) @@ -134,6 +141,7 @@ Mox.stub(El.MockGroupLeader, :open_null_device, fn -> self() end) Mox.stub(El.MockGroupLeader, :close, fn _ -> :ok end) Mox.stub(El.MockGroupLeader, :get, fn -> self() end) Mox.stub(El.MockGroupLeader, :set, fn _, _ -> true end) +Mox.stub(El.MockSessionApi, :probe_ask, fn _, _ -> :ok end) defmodule MockClaudeCodeSession do def stream(_pid, _message) do