From fbe33be4de1efa0d42bf88d886056b82761b2067 Mon Sep 17 00:00:00 2001 From: Carlo Capocasa Date: Thu, 25 Jun 2026 21:32:34 +0200 Subject: [PATCH 1/7] tests: sync with session index, always-on ticker row, streaming cap, tty goldens --- .gitignore | 1 + tests/fixtures/tty/harness_commands.txt | 350 ++++++++++++------------ tests/fixtures/tty/multiline.txt | 32 +-- tests/fixtures/tty/other_tools.txt | 12 +- tests/test_cli_args.nim | 8 +- tests/test_display.nim | 3 + tests/test_fatprompt.nim | 23 +- tests/test_http_nonstream.nims | 6 + tests/test_streamexec.nim | 48 ++-- tests/test_tty_functional.nim | 2 +- 10 files changed, 250 insertions(+), 235 deletions(-) create mode 100644 tests/test_http_nonstream.nims diff --git a/.gitignore b/.gitignore index b331fb8..ef5e800 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /3code tests/test_* !tests/test_*.nim +!tests/test_http_nonstream.nims tests/output/ nimcache/ config.local.nims diff --git a/tests/fixtures/tty/harness_commands.txt b/tests/fixtures/tty/harness_commands.txt index a6934fb..200c2bf 100644 --- a/tests/fixtures/tty/harness_commands.txt +++ b/tests/fixtures/tty/harness_commands.txt @@ -1,4 +1,4 @@ -===== 153 ===== +===== 768 ===== ╭─╮ ─┤ 3code v0.4.0 the economical coding agent @@ -10,8 +10,7 @@ type a prompt. :help for commands. :q or Ctrl-D to exit. ❯ :help -===== 970 ===== -❯ :help +===== 468 ===== : help 3code the economical coding agent @@ -29,11 +28,12 @@ :provider rm X remove provider X :reasoning list reasoning levels for current model (* marks active) :reasoning X switch reasoning level (low / medium / high) + :streaming show streaming mode (on = live output, off = request/response) + :streaming on|off toggle SSE streaming (off is the reliable fallback for flaky SSE) :prompt show the active system prompt :show [N] show full output of tool call N (default: last) :log list all tool calls this session - :sessions list sessions saved in the current directory - :sessions all list every saved session (any directory) + :sessions list recent sessions saved in this directory (max 20) :summarize collapse old turns into a synthetic recap (meta model call) :q :quit exit (also Ctrl-D) @@ -51,8 +51,7 @@ @path inline file contents (e.g. @src/foo.nim) ❯ -===== 345 ===== -❯ :help +===== 744 ===== : help 3code the economical coding agent @@ -70,11 +69,12 @@ :provider rm X remove provider X :reasoning list reasoning levels for current model (* marks active) :reasoning X switch reasoning level (low / medium / high) + :streaming show streaming mode (on = live output, off = request/response) + :streaming on|off toggle SSE streaming (off is the reliable fallback for flaky SSE) :prompt show the active system prompt :show [N] show full output of tool call N (default: last) :log list all tool calls this session - :sessions list sessions saved in the current directory - :sessions all list every saved session (any directory) + :sessions list recent sessions saved in this directory (max 20) :summarize collapse old turns into a synthetic recap (meta model call) :q :quit exit (also Ctrl-D) @@ -92,8 +92,7 @@ @path inline file contents (e.g. @src/foo.nim) ❯ :provider -===== 717 ===== - :help show this message +===== 468 ===== :tokens show token usage for this session :clear reset conversation (keeps system prompt) :model list models for current provider (current marked with *) @@ -105,11 +104,12 @@ :provider rm X remove provider X :reasoning list reasoning levels for current model (* marks active) :reasoning X switch reasoning level (low / medium / high) + :streaming show streaming mode (on = live output, off = request/response) + :streaming on|off toggle SSE streaming (off is the reliable fallback for flaky SSE) :prompt show the active system prompt :show [N] show full output of tool call N (default: last) :log list all tool calls this session - :sessions list sessions saved in the current directory - :sessions all list every saved session (any directory) + :sessions list recent sessions saved in this directory (max 20) :summarize collapse old turns into a synthetic recap (meta model call) :q :quit exit (also Ctrl-D) @@ -133,8 +133,7 @@ alt ❯ -===== 367 ===== - :help show this message +===== 954 ===== :tokens show token usage for this session :clear reset conversation (keeps system prompt) :model list models for current provider (current marked with *) @@ -146,11 +145,12 @@ :provider rm X remove provider X :reasoning list reasoning levels for current model (* marks active) :reasoning X switch reasoning level (low / medium / high) + :streaming show streaming mode (on = live output, off = request/response) + :streaming on|off toggle SSE streaming (off is the reliable fallback for flaky SSE) :prompt show the active system prompt :show [N] show full output of tool call N (default: last) :log list all tool calls this session - :sessions list sessions saved in the current directory - :sessions all list every saved session (any directory) + :sessions list recent sessions saved in this directory (max 20) :summarize collapse old turns into a synthetic recap (meta model call) :q :quit exit (also Ctrl-D) @@ -174,18 +174,18 @@ alt ❯ :model -===== 380 ===== - :provider X switch to provider X (model defaults to first in its list) +===== 567 ===== :provider add add a new provider (interactive, verified) :provider edit X edit provider X (url, key, models) :provider rm X remove provider X :reasoning list reasoning levels for current model (* marks active) :reasoning X switch reasoning level (low / medium / high) + :streaming show streaming mode (on = live output, off = request/response) + :streaming on|off toggle SSE streaming (off is the reliable fallback for flaky SSE) :prompt show the active system prompt :show [N] show full output of tool call N (default: last) :log list all tool calls this session - :sessions list sessions saved in the current directory - :sessions all list every saved session (any directory) + :sessions list recent sessions saved in this directory (max 20) :summarize collapse old turns into a synthetic recap (meta model call) :q :quit exit (also Ctrl-D) @@ -215,18 +215,18 @@ stub-large ❯ -===== 265 ===== - :provider X switch to provider X (model defaults to first in its list) +===== 726 ===== :provider add add a new provider (interactive, verified) :provider edit X edit provider X (url, key, models) :provider rm X remove provider X :reasoning list reasoning levels for current model (* marks active) :reasoning X switch reasoning level (low / medium / high) + :streaming show streaming mode (on = live output, off = request/response) + :streaming on|off toggle SSE streaming (off is the reliable fallback for flaky SSE) :prompt show the active system prompt :show [N] show full output of tool call N (default: last) :log list all tool calls this session - :sessions list sessions saved in the current directory - :sessions all list every saved session (any directory) + :sessions list recent sessions saved in this directory (max 20) :summarize collapse old turns into a synthetic recap (meta model call) :q :quit exit (also Ctrl-D) @@ -256,11 +256,12 @@ stub-large ❯ :reasoning -===== 883 ===== +===== 821 ===== + :streaming on|off toggle SSE streaming (off is the reliable fallback for flaky SSE) + :prompt show the active system prompt :show [N] show full output of tool call N (default: last) :log list all tool calls this session - :sessions list sessions saved in the current directory - :sessions all list every saved session (any directory) + :sessions list recent sessions saved in this directory (max 20) :summarize collapse old turns into a synthetic recap (meta model call) :q :quit exit (also Ctrl-D) @@ -292,16 +293,16 @@ ❯ :reasoning : reasoning - low - medium - high + reasoning: (none) + experimental: level is free-form, type any value ❯ -===== 733 ===== +===== 801 ===== + :streaming on|off toggle SSE streaming (off is the reliable fallback for flaky SSE) + :prompt show the active system prompt :show [N] show full output of tool call N (default: last) :log list all tool calls this session - :sessions list sessions saved in the current directory - :sessions all list every saved session (any directory) + :sessions list recent sessions saved in this directory (max 20) :summarize collapse old turns into a synthetic recap (meta model call) :q :quit exit (also Ctrl-D) @@ -333,12 +334,12 @@ ❯ :reasoning : reasoning - low - medium - high + reasoning: (none) + experimental: level is free-form, type any value ❯ :model stub-large -===== 833 ===== +===== 689 ===== + :summarize collapse old turns into a synthetic recap (meta model call) :q :quit exit (also Ctrl-D) input: @@ -369,9 +370,8 @@ ❯ :reasoning : reasoning - low - medium - high + reasoning: (none) + experimental: level is free-form, type any value ❯ :model stub-large @@ -379,7 +379,8 @@ provider stub model stub-large ❯ -===== 230 ===== +===== 929 ===== + :summarize collapse old turns into a synthetic recap (meta model call) :q :quit exit (also Ctrl-D) input: @@ -410,9 +411,8 @@ model stub-large ❯ :reasoning : reasoning - low - medium - high + reasoning: (none) + experimental: level is free-form, type any value ❯ :model stub-large @@ -420,7 +420,8 @@ provider stub model stub-large ❯ :reasoning high -===== 132 ===== +===== 841 ===== + arrows full cursor navigation across lines and visual wraps ctrl+arrow word-by-word jumps (also crosses logical lines) home / end jump to start / end of the current logical line ctrl+u clear the buffer @@ -445,9 +446,8 @@ model stub-large ❯ :reasoning : reasoning - low - medium - high + reasoning: (none) + experimental: level is free-form, type any value ❯ :model stub-large @@ -461,7 +461,8 @@ model stub-large reasoning high ❯ -===== 808 ===== +===== 600 ===== + arrows full cursor navigation across lines and visual wraps ctrl+arrow word-by-word jumps (also crosses logical lines) home / end jump to start / end of the current logical line ctrl+u clear the buffer @@ -486,9 +487,8 @@ reasoning high ❯ :reasoning : reasoning - low - medium - high + reasoning: (none) + experimental: level is free-form, type any value ❯ :model stub-large @@ -502,7 +502,8 @@ model stub-large reasoning high ❯ :provider alt -===== 917 ===== +===== 523 ===== + up / down visual-row up/down inside the buffer; on the top/bottom row recalls history tab complete :commands, provider names, model names ctrl+l clear the screen @path inline file contents (e.g. @src/foo.nim) @@ -522,9 +523,8 @@ reasoning high ❯ :reasoning : reasoning - low - medium - high + reasoning: (none) + experimental: level is free-form, type any value ❯ :model stub-large @@ -543,7 +543,8 @@ provider alt model alt-model ❯ -===== 266 ===== +===== 167 ===== + up / down visual-row up/down inside the buffer; on the top/bottom row recalls history tab complete :commands, provider names, model names ctrl+l clear the screen @path inline file contents (e.g. @src/foo.nim) @@ -563,9 +564,8 @@ model alt-model ❯ :reasoning : reasoning - low - medium - high + reasoning: (none) + experimental: level is free-form, type any value ❯ :model stub-large @@ -584,7 +584,8 @@ provider alt model alt-model ❯ :tokens -===== 474 ===== +===== 836 ===== +❯ :provider : providers * stub [stub-model] @@ -599,9 +600,8 @@ model alt-model ❯ :reasoning : reasoning - low - medium - high + reasoning: (none) + experimental: level is free-form, type any value ❯ :model stub-large @@ -625,7 +625,8 @@ model alt-model no tokens used yet ❯ -===== 132 ===== +===== 403 ===== +❯ :provider : providers * stub [stub-model] @@ -640,9 +641,8 @@ model alt-model ❯ :reasoning : reasoning - low - medium - high + reasoning: (none) + experimental: level is free-form, type any value ❯ :model stub-large @@ -666,7 +666,8 @@ model alt-model no tokens used yet ❯ :clear -===== 591 ===== +===== 500 ===== + alt ❯ :model @@ -677,9 +678,8 @@ model alt-model ❯ :reasoning : reasoning - low - medium - high + reasoning: (none) + experimental: level is free-form, type any value ❯ :model stub-large @@ -707,7 +707,8 @@ model alt-model ════════════════════════════════════════ ❯ -===== 522 ===== +===== 631 ===== + alt ❯ :model @@ -718,9 +719,8 @@ model alt-model ❯ :reasoning : reasoning - low - medium - high + reasoning: (none) + experimental: level is free-form, type any value ❯ :model stub-large @@ -748,15 +748,15 @@ model alt-model ════════════════════════════════════════ ❯ :sessions -===== 896 ===== +===== 616 ===== + * stub-model stub-large ❯ :reasoning : reasoning - low - medium - high + reasoning: (none) + experimental: level is free-form, type any value ❯ :model stub-large @@ -786,18 +786,18 @@ model alt-model ❯ :sessions : sessions - no saved sessions for this directory (try `:sessions all`) + no saved sessions for this directory ❯ -===== 533 ===== +===== 613 ===== + * stub-model stub-large ❯ :reasoning : reasoning - low - medium - high + reasoning: (none) + experimental: level is free-form, type any value ❯ :model stub-large @@ -827,13 +827,10 @@ model alt-model ❯ :sessions : sessions - no saved sessions for this directory (try `:sessions all`) + no saved sessions for this directory ❯ :sessions all -===== 182 ===== - low - medium - high +===== 455 ===== ❯ :model stub-large @@ -863,18 +860,18 @@ model alt-model ❯ :sessions : sessions - no saved sessions for this directory (try `:sessions all`) + no saved sessions for this directory ❯ :sessions all : sessions - no saved sessions + no saved sessions for this directory + + + listing is scoped to this directory — run from ~/data/3code/sessions for all ❯ -===== 308 ===== - low - medium - high +===== 478 ===== ❯ :model stub-large @@ -904,18 +901,18 @@ model alt-model ❯ :sessions : sessions - no saved sessions for this directory (try `:sessions all`) + no saved sessions for this directory ❯ :sessions all : sessions - no saved sessions + no saved sessions for this directory -❯ :log -===== 495 ===== -provider stub -model stub-large + listing is scoped to this directory — run from ~/data/3code/sessions for all + +❯ :log +===== 974 ===== ❯ :reasoning high @@ -940,12 +937,15 @@ model alt-model ❯ :sessions : sessions - no saved sessions for this directory (try `:sessions all`) + no saved sessions for this directory ❯ :sessions all : sessions - no saved sessions + no saved sessions for this directory + + + listing is scoped to this directory — run from ~/data/3code/sessions for all ❯ :log @@ -953,10 +953,7 @@ model alt-model no tool calls yet ❯ -===== 113 ===== - -provider stub -model stub-large +===== 187 ===== ❯ :reasoning high @@ -981,12 +978,15 @@ model alt-model ❯ :sessions : sessions - no saved sessions for this directory (try `:sessions all`) + no saved sessions for this directory ❯ :sessions all : sessions - no saved sessions + no saved sessions for this directory + + + listing is scoped to this directory — run from ~/data/3code/sessions for all ❯ :log @@ -994,10 +994,7 @@ model alt-model no tool calls yet ❯ :show -===== 505 ===== - -provider stub -model stub-large +===== 210 ===== reasoning high ❯ :provider alt @@ -1017,12 +1014,15 @@ model alt-model ❯ :sessions : sessions - no saved sessions for this directory (try `:sessions all`) + no saved sessions for this directory ❯ :sessions all : sessions - no saved sessions + no saved sessions for this directory + + + listing is scoped to this directory — run from ~/data/3code/sessions for all ❯ :log @@ -1035,10 +1035,7 @@ model alt-model no tool calls yet ❯ -===== 131 ===== - -provider stub -model stub-large +===== 211 ===== reasoning high ❯ :provider alt @@ -1058,12 +1055,15 @@ model alt-model ❯ :sessions : sessions - no saved sessions for this directory (try `:sessions all`) + no saved sessions for this directory ❯ :sessions all : sessions - no saved sessions + no saved sessions for this directory + + + listing is scoped to this directory — run from ~/data/3code/sessions for all ❯ :log @@ -1076,10 +1076,7 @@ model alt-model no tool calls yet ❯ :compact -===== 883 ===== -❯ :provider alt - -provider alt +===== 203 ===== model alt-model ❯ :tokens @@ -1094,12 +1091,15 @@ model alt-model ❯ :sessions : sessions - no saved sessions for this directory (try `:sessions all`) + no saved sessions for this directory ❯ :sessions all : sessions - no saved sessions + no saved sessions for this directory + + + listing is scoped to this directory — run from ~/data/3code/sessions for all ❯ :log @@ -1117,10 +1117,7 @@ model alt-model unknown command: :compact (try :help) ❯ -===== 315 ===== -❯ :provider alt - -provider alt +===== 267 ===== model alt-model ❯ :tokens @@ -1135,12 +1132,15 @@ model alt-model ❯ :sessions : sessions - no saved sessions for this directory (try `:sessions all`) + no saved sessions for this directory ❯ :sessions all : sessions - no saved sessions + no saved sessions for this directory + + + listing is scoped to this directory — run from ~/data/3code/sessions for all ❯ :log @@ -1158,10 +1158,7 @@ model alt-model unknown command: :compact (try :help) ❯ :summarize -===== 716 ===== -❯ :tokens - -: tokens +===== 430 ===== no tokens used yet ❯ :clear @@ -1171,12 +1168,15 @@ model alt-model ❯ :sessions : sessions - no saved sessions for this directory (try `:sessions all`) + no saved sessions for this directory ❯ :sessions all : sessions - no saved sessions + no saved sessions for this directory + + + listing is scoped to this directory — run from ~/data/3code/sessions for all ❯ :log @@ -1199,10 +1199,7 @@ model alt-model failed or not worth it ❯ -===== 139 ===== -❯ :tokens - -: tokens +===== 981 ===== no tokens used yet ❯ :clear @@ -1212,12 +1209,15 @@ model alt-model ❯ :sessions : sessions - no saved sessions for this directory (try `:sessions all`) + no saved sessions for this directory ❯ :sessions all : sessions - no saved sessions + no saved sessions for this directory + + + listing is scoped to this directory — run from ~/data/3code/sessions for all ❯ :log @@ -1240,7 +1240,7 @@ model alt-model failed or not worth it ❯ :prompt -===== 878 ===== +===== 443 ===== Act freely on local, reversible work. Pause and explain before: destructive actions (`rm -rf` outside cwd, dropping ta bles), hard-to-reverse actions (force-push, amending published commits, removing deps), or anything externally visible ( @@ -1268,12 +1268,12 @@ If searches don't turn up a clear answer, say so — don't guess. Before using unfamiliar tools, `cat` a matching skill file from the list below. Available: - - /home/carlo/3w/1line/tests/output/tty//data/3code/skills/role-conversational.md - - /home/carlo/3w/1line/tests/output/tty//data/3code/skills/role-sysadmin.md - - /home/carlo/3w/1line/tests/output/tty//data/3code/skills/role-thinking-partner.md - - /home/carlo/3w/1line/tests/output/tty//data/3code/skills/role-writing.md - - /home/carlo/3w/1line/tests/output/tty//data/3code/skills/task-chunked-implementation.md - - /home/carlo/3w/1line/tests/output/tty//data/3code/skills/task-debug-systematic.md + - /home/carlo/p/3code/tests/output/tty//data/3code/skills/role-conversational.md + - /home/carlo/p/3code/tests/output/tty//data/3code/skills/role-sysadmin.md + - /home/carlo/p/3code/tests/output/tty//data/3code/skills/role-thinking-partner.md + - /home/carlo/p/3code/tests/output/tty//data/3code/skills/role-writing.md + - /home/carlo/p/3code/tests/output/tty//data/3code/skills/task-chunked-implementation.md + - /home/carlo/p/3code/tests/output/tty//data/3code/skills/task-debug-systematic.md # Tone @@ -1281,7 +1281,7 @@ If searches don't turn up a clear answer, say so — don't guess. n what's next. No emoji, no forced cheer. Code refs as `path:line`. If the task was already done, say so and stop. ❯ -===== 969 ===== +===== 236 ===== Act freely on local, reversible work. Pause and explain before: destructive actions (`rm -rf` outside cwd, dropping ta bles), hard-to-reverse actions (force-push, amending published commits, removing deps), or anything externally visible ( @@ -1309,12 +1309,12 @@ If searches don't turn up a clear answer, say so — don't guess. Before using unfamiliar tools, `cat` a matching skill file from the list below. Available: - - /home/carlo/3w/1line/tests/output/tty//data/3code/skills/role-conversational.md - - /home/carlo/3w/1line/tests/output/tty//data/3code/skills/role-sysadmin.md - - /home/carlo/3w/1line/tests/output/tty//data/3code/skills/role-thinking-partner.md - - /home/carlo/3w/1line/tests/output/tty//data/3code/skills/role-writing.md - - /home/carlo/3w/1line/tests/output/tty//data/3code/skills/task-chunked-implementation.md - - /home/carlo/3w/1line/tests/output/tty//data/3code/skills/task-debug-systematic.md + - /home/carlo/p/3code/tests/output/tty//data/3code/skills/role-conversational.md + - /home/carlo/p/3code/tests/output/tty//data/3code/skills/role-sysadmin.md + - /home/carlo/p/3code/tests/output/tty//data/3code/skills/role-thinking-partner.md + - /home/carlo/p/3code/tests/output/tty//data/3code/skills/role-writing.md + - /home/carlo/p/3code/tests/output/tty//data/3code/skills/task-chunked-implementation.md + - /home/carlo/p/3code/tests/output/tty//data/3code/skills/task-debug-systematic.md # Tone @@ -1322,7 +1322,7 @@ If searches don't turn up a clear answer, say so — don't guess. n what's next. No emoji, no forced cheer. Code refs as `path:line`. If the task was already done, say so and stop. ❯ :toknes -===== 788 ===== +===== 910 ===== # Git Prefer new commits over amending. Never skip hooks unless explicitly asked. Stage specific files; avoid `git add -A`. @@ -1345,12 +1345,12 @@ If searches don't turn up a clear answer, say so — don't guess. Before using unfamiliar tools, `cat` a matching skill file from the list below. Available: - - /home/carlo/3w/1line/tests/output/tty//data/3code/skills/role-conversational.md - - /home/carlo/3w/1line/tests/output/tty//data/3code/skills/role-sysadmin.md - - /home/carlo/3w/1line/tests/output/tty//data/3code/skills/role-thinking-partner.md - - /home/carlo/3w/1line/tests/output/tty//data/3code/skills/role-writing.md - - /home/carlo/3w/1line/tests/output/tty//data/3code/skills/task-chunked-implementation.md - - /home/carlo/3w/1line/tests/output/tty//data/3code/skills/task-debug-systematic.md + - /home/carlo/p/3code/tests/output/tty//data/3code/skills/role-conversational.md + - /home/carlo/p/3code/tests/output/tty//data/3code/skills/role-sysadmin.md + - /home/carlo/p/3code/tests/output/tty//data/3code/skills/role-thinking-partner.md + - /home/carlo/p/3code/tests/output/tty//data/3code/skills/role-writing.md + - /home/carlo/p/3code/tests/output/tty//data/3code/skills/task-chunked-implementation.md + - /home/carlo/p/3code/tests/output/tty//data/3code/skills/task-debug-systematic.md # Tone @@ -1363,7 +1363,7 @@ n what's next. No emoji, no forced cheer. Code refs as `path:line`. If the task unknown command: :toknes did you mean :tokens? ❯ -===== 976 ===== +===== 590 ===== # Git Prefer new commits over amending. Never skip hooks unless explicitly asked. Stage specific files; avoid `git add -A`. @@ -1386,12 +1386,12 @@ If searches don't turn up a clear answer, say so — don't guess. Before using unfamiliar tools, `cat` a matching skill file from the list below. Available: - - /home/carlo/3w/1line/tests/output/tty//data/3code/skills/role-conversational.md - - /home/carlo/3w/1line/tests/output/tty//data/3code/skills/role-sysadmin.md - - /home/carlo/3w/1line/tests/output/tty//data/3code/skills/role-thinking-partner.md - - /home/carlo/3w/1line/tests/output/tty//data/3code/skills/role-writing.md - - /home/carlo/3w/1line/tests/output/tty//data/3code/skills/task-chunked-implementation.md - - /home/carlo/3w/1line/tests/output/tty//data/3code/skills/task-debug-systematic.md + - /home/carlo/p/3code/tests/output/tty//data/3code/skills/role-conversational.md + - /home/carlo/p/3code/tests/output/tty//data/3code/skills/role-sysadmin.md + - /home/carlo/p/3code/tests/output/tty//data/3code/skills/role-thinking-partner.md + - /home/carlo/p/3code/tests/output/tty//data/3code/skills/role-writing.md + - /home/carlo/p/3code/tests/output/tty//data/3code/skills/task-chunked-implementation.md + - /home/carlo/p/3code/tests/output/tty//data/3code/skills/task-debug-systematic.md # Tone diff --git a/tests/fixtures/tty/multiline.txt b/tests/fixtures/tty/multiline.txt index 09fbd9c..b2359d3 100644 --- a/tests/fixtures/tty/multiline.txt +++ b/tests/fixtures/tty/multiline.txt @@ -1,4 +1,4 @@ -===== 865 ===== +===== 716 ===== ╭─╮ ─┤ 3code v0.4.0 the economical coding agent @@ -10,7 +10,7 @@ type a prompt. :help for commands. :q or Ctrl-D to exit. ❯ first line -===== 732 ===== +===== 827 ===== ╭─╮ ─┤ 3code v0.4.0 the economical coding agent @@ -23,7 +23,7 @@ ❯ first line -===== 987 ===== +===== 666 ===== ╭─╮ ─┤ 3code v0.4.0 the economical coding agent @@ -36,7 +36,7 @@ ❯ first line second line -===== 795 ===== +===== 110 ===== ╭─╮ ─┤ 3code v0.4.0 the economical coding agent @@ -52,7 +52,7 @@ ⣿ ○0% 0s ❯ queued line one -===== 662 ===== +===== 533 ===== ╭─╮ ─┤ 3code v0.4.0 the economical coding agent @@ -69,7 +69,7 @@ ⣿ ○0% 0s ❯ queued line one -===== 704 ===== +===== 403 ===== ╭─╮ ─┤ 3code v0.4.0 the economical coding agent @@ -86,7 +86,7 @@ ⣿ ⧖ 0s ❯ queued line one queued line two -===== 576 ===== +===== 208 ===== ╭─╮ ─┤ 3code v0.4.0 the economical coding agent @@ -101,10 +101,9 @@ second line ⣿ ⧖ 0s -⣿ ⧖ 0s -❯ ⧖ - -===== 664 ===== +❯ queued line one + queued line two ⧖ +===== 938 ===== ╭─╮ ─┤ 3code v0.4.0 the economical coding agent @@ -118,11 +117,10 @@ ❯ first line second line -⣿ ⧖ 0s ⣿ ○0% ↓6 0s -❯ ⧖ - -===== 995 ===== +❯ queued line one + queued line two ⧖ +===== 685 ===== ╭─╮ ─┤ 3code v0.4.0 the economical coding agent @@ -136,7 +134,6 @@ ❯ first line second line -⣿ ⧖ 0s ● First multiline response. ○1% ↑120 ↓24 0s @@ -145,7 +142,7 @@ ⣿ ○1% ↓6 0s ❯ -===== 348 ===== +===== 693 ===== ╭─╮ ─┤ 3code v0.4.0 the economical coding agent @@ -159,7 +156,6 @@ ❯ first line second line -⣿ ⧖ 0s ● First multiline response. ○1% ↑120 ↓24 0s diff --git a/tests/fixtures/tty/other_tools.txt b/tests/fixtures/tty/other_tools.txt index 1734715..3e3ce64 100644 --- a/tests/fixtures/tty/other_tools.txt +++ b/tests/fixtures/tty/other_tools.txt @@ -1,4 +1,4 @@ -===== 594 ===== +===== 797 ===== ╭─╮ ─┤ 3code v0.4.0 the economical coding agent @@ -10,7 +10,7 @@ type a prompt. :help for commands. :q or Ctrl-D to exit. ❯ run other tool checks -===== 953 ===== +===== 218 ===== ╭─╮ ─┤ 3code v0.4.0 the economical coding agent @@ -25,7 +25,7 @@ ⣿ ○0% ↓6 0s ❯ -===== 921 ===== +===== 272 ===== ╭─╮ ─┤ 3code v0.4.0 the economical coding agent @@ -46,7 +46,7 @@ r notes.txt read-two read-three -w /tmp/notes.txt +w notes.txt --- /tmp/notes.txt +++ /tmp/notes.txt +new notes @@ -81,7 +81,7 @@ p --- /tmp/applied.txt ⣿ ○0% ↓4 0s ❯ -===== 423 ===== +===== 891 ===== ╭─╮ ─┤ 3code v0.4.0 the economical coding agent @@ -102,7 +102,7 @@ r notes.txt read-two read-three -w /tmp/notes.txt +w notes.txt --- /tmp/notes.txt +++ /tmp/notes.txt +new notes diff --git a/tests/test_cli_args.nim b/tests/test_cli_args.nim index 0474ee7..84a163e 100644 --- a/tests/test_cli_args.nim +++ b/tests/test_cli_args.nim @@ -1,4 +1,5 @@ import std/[os, osproc, strutils, times, unittest] +import threecode/session const binName = when defined(windows): "3code.exe" else: "3code" @@ -51,13 +52,18 @@ suite "cli --list cap and short-flag stacking": return (outp.strip(), code) proc seedSession(stamp: string) = - # Minimal valid .3log under the isolated sessions dir. + # Minimal valid .3log under the isolated sessions dir, plus a cwd-index + # entry so the binary's O(1) `listSessionPathsForCwd` finds it without + # scanning. saveSession does both; the test must mirror that. The index + # is written directly under the isolated tmp root (not via the test + # process's own XDG_DATA_HOME, which is the developer's real one). let dir = tmp / "3code" / "sessions" createDir(dir) let path = dir / (stamp & ".3log") writeFile(path, "session " & stamp & " profile=stub cwd=" & tmp & "\n\n" & "system\n sys\n\n" & "user\n session " & stamp & "\n\n") + appendIndexAt(tmp / "3code" / "session-paths", tmp, stamp) test "-l reports no sessions for an empty directory": let r = runIn(tmp, "-l") diff --git a/tests/test_display.nim b/tests/test_display.nim index 8758d78..e1199ee 100644 --- a/tests/test_display.nim +++ b/tests/test_display.nim @@ -59,6 +59,9 @@ suite "display: printSessionList cap": let msgs = %*[{"role": "system", "content": "sys"}, {"role": "user", "content": body}] writeFile(dir / (stamp & SessionExt), renderSession(sess, msgs)) + # Mirror saveSession: index the new file under its cwd so + # listSessionPathsForCwd (which reads the index, not the dir) finds it. + appendSessionIndex(cwd, stamp) proc captureList(paths: seq[string]): string = # Mirror the `captureStdout` helper in test_streaming_view.nim: swap diff --git a/tests/test_fatprompt.nim b/tests/test_fatprompt.nim index e80da36..e9d9927 100644 --- a/tests/test_fatprompt.nim +++ b/tests/test_fatprompt.nim @@ -37,7 +37,10 @@ suite "fat prompt frame model": p.setTokenBar(Usage(promptTokens: 20, totalTokens: 20), window = 1000) p.setEditor "hello" - p.checkFrame ["one", "two", "three", "", "○2% ↑20", "❯ hello"] + # The ticker row is always reserved now (an empty gap between + # scrollback and the bar reads better than flush adjacency), so the + # bottom scrollback line drops out of view and the gap is two rows. + p.checkFrame ["two", "three", "", "", "○2% ↑20", "❯ hello"] test "ticker reserves its own row above token bar": var p = initFatPrompt(width = 30, height = 6, window = 1000) @@ -52,7 +55,7 @@ suite "fat prompt frame model": "◐ ○2% ↑20 7s", "❯ hello"] p.setTicker "" - p.checkFrame ["two", "three", "four", "", "◐ ○2% ↑20 7s", + p.checkFrame ["three", "four", "", "", "◐ ○2% ↑20 7s", "❯ hello"] test "multiline editor grows reserved area and shrinking reveals scrollback": @@ -62,11 +65,11 @@ suite "fat prompt frame model": p.setTokenBar(Usage(promptTokens: 20, totalTokens: 20), window = 1000) p.setEditor "alpha\nbeta" - p.checkFrame ["three", "four", "five", "", "○2% ↑20", "❯ alpha", + p.checkFrame ["four", "five", "", "", "○2% ↑20", "❯ alpha", " beta"] p.setEditor "short" - p.checkFrame ["two", "three", "four", "five", "", "○2% ↑20", + p.checkFrame ["three", "four", "five", "", "", "○2% ↑20", "❯ short"] test "wrapped editor height reserves every visual row": @@ -76,7 +79,7 @@ suite "fat prompt frame model": p.setTokenBar(Usage(promptTokens: 20, totalTokens: 20), window = 1000) p.setEditor "abcdefghijk" - p.checkFrame ["three", "four", "five", "", "○2% ↑20", "❯ abcdefgh", + p.checkFrame ["four", "five", "", "", "○2% ↑20", "❯ abcdefgh", " ijk"] test "wrapping keeps unicode runes intact": @@ -85,7 +88,7 @@ suite "fat prompt frame model": p.setTokenBar(Usage(promptTokens: 20, totalTokens: 20), window = 1000) p.setEditor "abédefg" - p.checkFrame ["one", "", "○2% ↑20", "❯ abédef", " g"] + p.checkFrame ["", "", "○2% ↑20", "❯ abédef", " g"] test "token bar always keeps context and only shows nonzero token slots": var p = initFatPrompt(width = 40, height = 3, window = 128000) @@ -114,10 +117,10 @@ suite "fat prompt frame model": for i in 1 .. 9: p.pushBashOutput "bash-line-" & $i - p.checkFrame ["❯ run command", "", "$ ... 2 lines omitted :show 4 for full", + p.checkFrame ["", "$ ... 2 lines omitted :show 4 for full", "$ bash-line-3", "$ bash-line-4", "$ bash-line-5", "$ bash-line-6", "$ bash-line-7", "$ bash-line-8", - "$ bash-line-9", "", "○1% ↑10", "❯ next"] + "$ bash-line-9", "", "", "○1% ↑10", "❯ next"] test "finished bash commits once and clears live viewport": var p = initFatPrompt(width = 50, height = 12, window = 1000) @@ -140,8 +143,8 @@ suite "fat prompt frame model": p.setTokenBar(Usage(promptTokens: 100, totalTokens: 100), window = 1000) p.setEditor "" - p.checkFrame ["", "", "● answer", "○10% ↑100 ↓7", "", - "r src/file.nim", "", "○10% ↑100", "❯ "] + p.checkFrame ["", "● answer", "○10% ↑100 ↓7", "", + "r src/file.nim", "", "", "○10% ↑100", "❯ "] suite "fat prompt: unicode wrapping": proc wrappedRows(body: string): seq[string] = diff --git a/tests/test_http_nonstream.nims b/tests/test_http_nonstream.nims new file mode 100644 index 0000000..575cdd3 --- /dev/null +++ b/tests/test_http_nonstream.nims @@ -0,0 +1,6 @@ +## This test exercises the non-streaming transport via the `callHttpStub` +## defined in `tests/stub/http.nim`, which is `include`d into `api.nim` +## only under `-d:httpStub`. The define is test-local (it replaces the real +## `callHttp`), so it lives here rather than in the top-level `config.nims`, +## which would otherwise build a stubbed main binary. +switch("define", "httpStub") diff --git a/tests/test_streamexec.nim b/tests/test_streamexec.nim index 3a92520..bc88a9c 100644 --- a/tests/test_streamexec.nim +++ b/tests/test_streamexec.nim @@ -5,7 +5,7 @@ suite "streamexec: basic streaming": test "streams stdout lines": var lines: seq[string] let act = Action(kind: akBash, body: "echo hello && echo world") - let (rawOut, code) = runStreamingBash(act, nil, + let (rawOut, code, _) = runStreamingBash(act, nil, proc(line: string) = lines.add(line)) check code == 0 check lines == @["hello", "world"] @@ -14,7 +14,7 @@ suite "streamexec: basic streaming": test "handles empty output": var lines: seq[string] let act = Action(kind: akBash, body: "true") - let (rawOut, code) = runStreamingBash(act, nil, + let (rawOut, code, _) = runStreamingBash(act, nil, proc(line: string) = lines.add(line)) check code == 0 check lines.len == 0 @@ -23,7 +23,7 @@ suite "streamexec: basic streaming": test "handles single line without trailing newline": var lines: seq[string] let act = Action(kind: akBash, body: "printf 'no newline'") - let (rawOut, code) = runStreamingBash(act, nil, + let (rawOut, code, _) = runStreamingBash(act, nil, proc(line: string) = lines.add(line)) check code == 0 check lines == @["no newline"] @@ -33,7 +33,7 @@ suite "streamexec: basic streaming": var lines: seq[string] let act = Action(kind: akBash, body: "printf 'Prompt: waiting'; sleep 1; printf '\\nDone\\n'") - let (rawOut, code) = runStreamingBash(act, nil, + let (rawOut, code, _) = runStreamingBash(act, nil, proc(line: string) = lines.add(line)) check code == 0 check lines == @["Prompt: waiting", "Done"] @@ -43,7 +43,7 @@ suite "streamexec: basic streaming": var lines: seq[string] let act = Action(kind: akBash, body: "python3 -c \"print('x' * 200000, end='')\"") - let (rawOut, code) = runStreamingBash(act, nil, + let (rawOut, code, _) = runStreamingBash(act, nil, proc(line: string) = lines.add(line)) check code == 0 check rawOut.len == 200001 @@ -53,12 +53,12 @@ suite "streamexec: basic streaming": test "preserves exit code": let act = Action(kind: akBash, body: "exit 42") - let (_, code) = runStreamingBash(act, nil, nil) + let (_, code, _) = runStreamingBash(act, nil, nil) check code == 42 test "exit code 1": let act = Action(kind: akBash, body: "false") - let (_, code) = runStreamingBash(act, nil, nil) + let (_, code, _) = runStreamingBash(act, nil, nil) check code == 1 test "cancelActiveTool stops streamed bash process tree promptly": @@ -66,7 +66,7 @@ suite "streamexec: basic streaming": var lines: seq[string] let act = Action(kind: akBash, body: "echo ready; sh -c 'sleep 30 & wait'") - let (rawOut, code) = runStreamingBash(act, nil, + let (rawOut, code, _) = runStreamingBash(act, nil, proc(line: string) = lines.add(line) if line == "ready": @@ -80,7 +80,7 @@ suite "streamexec: stderr handling": test "stderr appears inline in stdout": var lines: seq[string] let act = Action(kind: akBash, body: "echo out; echo err >&2") - let (rawOut, code) = runStreamingBash(act, nil, + let (rawOut, code, _) = runStreamingBash(act, nil, proc(line: string) = lines.add(line)) check code == 0 check "out" in rawOut @@ -88,7 +88,7 @@ suite "streamexec: stderr handling": test "stderr-only command": let act = Action(kind: akBash, body: "echo only_stderr >&2") - let (rawOut, code) = runStreamingBash(act, nil, nil) + let (rawOut, code, _) = runStreamingBash(act, nil, nil) check code == 0 check rawOut.contains("only_stderr") @@ -96,33 +96,33 @@ suite "streamexec: stdin piping": test "pipes stdin to command": let act = Action(kind: akBash, body: "cat", stdin: "hello from stdin\n") var lines: seq[string] - let (rawOut, code) = runStreamingBash(act, nil, + let (rawOut, code, _) = runStreamingBash(act, nil, proc(line: string) = lines.add(line)) check code == 0 check "hello from stdin" in rawOut test "empty stdin does not hang": let act = Action(kind: akBash, body: "echo done", stdin: "") - let (rawOut, code) = runStreamingBash(act, nil, nil) + let (rawOut, code, _) = runStreamingBash(act, nil, nil) check code == 0 check rawOut.contains("done") suite "streamexec: env vars": test "PAGER is set to cat": let act = Action(kind: akBash, body: "echo $PAGER") - let (rawOut, code) = runStreamingBash(act, nil, nil) + let (rawOut, code, _) = runStreamingBash(act, nil, nil) check code == 0 check rawOut.strip == "cat" test "TERM is set to dumb": let act = Action(kind: akBash, body: "echo $TERM") - let (rawOut, code) = runStreamingBash(act, nil, nil) + let (rawOut, code, _) = runStreamingBash(act, nil, nil) check code == 0 check rawOut.strip == "dumb" test "NO_COLOR is set": let act = Action(kind: akBash, body: "echo $NO_COLOR") - let (rawOut, code) = runStreamingBash(act, nil, nil) + let (rawOut, code, _) = runStreamingBash(act, nil, nil) check code == 0 check rawOut.strip == "1" @@ -130,7 +130,7 @@ suite "streamexec: multi-line output": test "streams many lines": var lines: seq[string] let act = Action(kind: akBash, body: "seq 1 10") - let (rawOut, code) = runStreamingBash(act, nil, + let (rawOut, code, _) = runStreamingBash(act, nil, proc(line: string) = lines.add(line)) check code == 0 check lines.len == 10 @@ -142,7 +142,7 @@ suite "streamexec: multi-line output": var lines: seq[string] let act = Action(kind: akBash, body: "for i in 1 2 3; do echo \"line $i\"; sleep 0.1; done") - let (_, code) = runStreamingBash(act, nil, + let (_, code, _) = runStreamingBash(act, nil, proc(line: string) = lines.add(line)) check code == 0 check lines == @["line 1", "line 2", "line 3"] @@ -151,7 +151,7 @@ suite "streamexec: special characters": test "handles output with special shell chars": var lines: seq[string] let act = Action(kind: akBash, body: "echo 'hello world' && echo 'a|b>c'") - let (_, code) = runStreamingBash(act, nil, + let (_, code, _) = runStreamingBash(act, nil, proc(line: string) = lines.add(line)) check code == 0 check "hello world" in lines @@ -160,7 +160,7 @@ suite "streamexec: special characters": test "handles empty lines in output": var lines: seq[string] let act = Action(kind: akBash, body: "echo 'a'; echo ''; echo 'b'") - let (_, code) = runStreamingBash(act, nil, + let (_, code, _) = runStreamingBash(act, nil, proc(line: string) = lines.add(line)) check code == 0 check lines == @["a", "", "b"] @@ -168,7 +168,7 @@ suite "streamexec: special characters": test "handles unicode output": var lines: seq[string] let act = Action(kind: akBash, body: "echo '● ○ ◔ ◑ ◕'") - let (_, code) = runStreamingBash(act, nil, + let (_, code, _) = runStreamingBash(act, nil, proc(line: string) = lines.add(line)) check code == 0 check lines[0].contains("●") @@ -177,7 +177,7 @@ suite "streamexec: binary output suppression": test "suppresses streaming callback after NUL byte": var lines: seq[string] let act = Action(kind: akBash, body: "printf 'before\\n\\x00binary\\x00garbage\\nafter\\n'") - let (rawOut, code) = runStreamingBash(act, nil, + let (rawOut, code, _) = runStreamingBash(act, nil, proc(line: string) = lines.add(line)) check code == 0 check "before" in lines @@ -189,13 +189,13 @@ suite "streamexec: binary output suppression": suite "streamexec: callback is optional": test "nil callback works": let act = Action(kind: akBash, body: "echo hello") - let (rawOut, code) = runStreamingBash(act, nil, nil) + let (rawOut, code, _) = runStreamingBash(act, nil, nil) check code == 0 check rawOut == "hello\n" test "default callback is nil": let act = Action(kind: akBash, body: "echo hello") - let (rawOut, code) = runStreamingBash(act, nil) + let (rawOut, code, _) = runStreamingBash(act, nil) check code == 0 check rawOut == "hello\n" @@ -206,7 +206,7 @@ suite "streamexec: file mutation snapshot": let filePath = tmpDir / "target.txt" writeFile(filePath, "original content\n") let act = Action(kind: akBash, body: "echo 'new content' > " & filePath) - let (_, code) = runStreamingBash(act, nil, nil) + let (_, code, _) = runStreamingBash(act, nil, nil) check code == 0 let content = readFile(filePath) check content == "new content\n" diff --git a/tests/test_tty_functional.nim b/tests/test_tty_functional.nim index 7b02faa..794a40a 100644 --- a/tests/test_tty_functional.nim +++ b/tests/test_tty_functional.nim @@ -1001,7 +1001,7 @@ suite "terminal visual contract": tty.expectInHistory "Exercising non-bash tools." tty.expectInHistory "r notes.txt" tty.expectInHistory "read-three" - tty.expectInHistory "w /tmp/notes.txt" + tty.expectInHistory "w notes.txt" tty.expectInHistory "+new notes" tty.expectInHistory "p --- /tmp/notes.txt" tty.expectInHistory "+patched notes" From ce58de1aefbd5c354367b27d0dd021f43bfeb48d Mon Sep 17 00:00:00 2001 From: Carlo Capocasa Date: Thu, 25 Jun 2026 22:20:46 +0200 Subject: [PATCH 2/7] fix: idle-Enter freeze by unparking input thread inherently The idle input thread parked itself on inputIdleSubmitted even for empty text, but empty text sets no queuedText, so the controller never unparked. Both threads spun in sleep-loops forever. Three layers, in order of importance: - onSubmit only parks when there's real text to consume (root cause) - consumeQueuedInput clears the park when nothing was found, so polling IS releasing - no call site can forget (withFile-style) - existing explicit releaseIdleSubmittedInput calls kept as belt-and-suspenders Regression test drives the stub under a PTY: fails against unpatched binary, passes with the fix. --- src/threecode/fatprompt/runtime.nim | 20 ++++++- tests/test_empty_enter_freeze.nim | 82 +++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 tests/test_empty_enter_freeze.nim diff --git a/src/threecode/fatprompt/runtime.nim b/src/threecode/fatprompt/runtime.nim index 52167f5..4aadddd 100644 --- a/src/threecode/fatprompt/runtime.nim +++ b/src/threecode/fatprompt/runtime.nim @@ -287,6 +287,16 @@ proc consumeQueuedInput*(line: var string; echoRows: var int; cmdWasQuit: var bool): bool = ## Consume the next submitted editor line, regardless of whether it was ## entered while the controller was idle or while a turn was active. + ## + ## Inherent-unpark: the idle input thread parks itself in `getCh` (returns + ## -1 -> EOFError) when `inputIdleSubmitted` is set. That park must be + ## released by *this* controller thread. Rather than rely on every call + ## site remembering `releaseIdleSubmittedInput()`, the release is folded + ## into polling: if we found nothing to consume yet the flag is still set, + ## the submitted line must already have been consumed on a prior pass (or + ## was never real text) — clear the park so the input thread stops + ## spinning in its EOFError busy-wait and resumes reading keystrokes. + ## Missing this means a frozen prompt: both threads sleep-loop forever. acquire inputStateLock try: cmdWasQuit = inputState.cmdWasQuit @@ -309,6 +319,8 @@ proc consumeQueuedInput*(line: var string; echoRows: var int; inputState.queuedEchoRows = 0 inputState.autoSend = false return true + if inputIdleSubmitted.load(moAcquire): + inputIdleSubmitted.store(false, moRelease) finally: release inputStateLock @@ -1386,7 +1398,13 @@ proc inputThreadProc() {.thread.} = ed.pendingCaret = true else: ed.line.position = ed.line.text.len - if not inputTurnActive.load(moAcquire): + # Only park for a real line with text. An empty idle Enter sets + # neither queuedText nor autoSend, so the controller has nothing + # to consume and would never release the park. Parking here would + # wedge the input thread forever (getCh returns -1 -> EOFError -> + # busy-wait) and freeze the prompt. With no park, readLineWith + # just continues its loop for the next keystroke. + if not inputTurnActive.load(moAcquire) and ed.line.text.len > 0: inputIdleSubmitted.store(true, moRelease) ed.renderSuffix = if inputTurnActive.load(moAcquire) and inputState.autoSend: diff --git a/tests/test_empty_enter_freeze.nim b/tests/test_empty_enter_freeze.nim new file mode 100644 index 0000000..2009837 --- /dev/null +++ b/tests/test_empty_enter_freeze.nim @@ -0,0 +1,82 @@ +## Targeted regression: empty Enter at the idle prompt must not freeze the +## input thread. Before the fix, onSubmit parked the thread on +## inputIdleSubmitted even for empty text, and the controller had nothing to +## consume, so both threads spun in sleep-loops forever. +import std/[json, os, strutils, unittest] +import tty_expect + +const Root = "tests/output/tty/empty_enter_freeze" + +proc newFixture(name: string): string = + result = getCurrentDir() / "tests/output/tty" / (name & "_" & $getCurrentProcessId()) + if dirExists(result): removeDir(result) + createDir(result); createDir(result / "data"); createDir(result / "run") + +proc writeConfiguredProvider(root: string) = + createDir(root / "xdg" / "3code") + writeFile(root / "xdg" / "3code" / "config", """ +[settings] +current = "stub.stub-model" +search-url = "http://127.0.0.1:1/?q=" + +[provider] +name = "stub" +url = "stub://provider" +key = "stub" +family = "glm" +models = "stub-model" +""") + +proc stubEnv(root, responsesPath: string): seq[EnvVar] = + let data = root / "data" + @[ + (key: "XDG_DATA_HOME", val: root / "xdg"), + (key: "XDG_CONFIG_HOME", val: root / "xdg"), + (key: "XDG_CACHE_HOME", val: root / "xdg" / "cache"), + (key: "HOME", val: root), + (key: "THREECODE_STUB_RESPONSES", val: responsesPath), + (key: "THREECODE_STUB_STREAM", val: "1"), + ] + +suite "idle enter freeze regression": + test "empty Enter then a real prompt stays responsive": + let root = newFixture("empty_enter_freeze") + writeConfiguredProvider(root) + writeFile(root / "run" / "stub_responses.json", $(%*[ + {"role": "assistant", "preStreamDelayMs": 100, + "content": "ok.", "contentChunks": ["ok."], + "usage": {"promptTokens": 5, "completionTokens": 2, + "totalTokens": 7, "cachedTokens": 0}} + ])) + let tty = newTtySession("/tmp/3code_tty_stub", + args = ["-x", "-i"], + cwd = root / "run", + env = stubEnv(root, root / "run" / "stub_responses.json")) + defer: + tty.writeFrameArtifact(root / "frames.txt") + tty.close() + + # Idle prompt is up. + tty.expect "\u276f" + + # Empty Enter at idle -- the exact trigger that used to wedge the + # input thread. expect() has a 5s timeout, so a hang fails the test + # rather than blocking the suite. + tty.send "\n" + + # Process must still be responsive: send a real prompt. + tty.drain(200) + tty.expect "\u276f" + tty.send "hello model" + tty.expect "hello model" + tty.send "\n" + tty.expectInHistory "ok." + + # And a command after the turn. + tty.drain(200) + tty.expect "\u276f" + tty.send ":tokens" + tty.expect ":tokens" + tty.send "\n" + + echo " PASS: empty Enter did not freeze the prompt" From 5aff2846590b499c0998ef8b9df768b19c62ae6d Mon Sep 17 00:00:00 2001 From: Carlo Capocasa Date: Thu, 25 Jun 2026 22:37:43 +0200 Subject: [PATCH 3/7] Deflake tty visual frame comparison against transient blank separator rows --- tests/tty_expect.nim | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/tty_expect.nim b/tests/tty_expect.nim index 2f6e2e7..8ecfd77 100644 --- a/tests/tty_expect.nim +++ b/tests/tty_expect.nim @@ -594,6 +594,25 @@ proc normalizeFrameSeparators(text: string): string = else: result.add line +proc stripFrameBlanks(text: string): string = + ## Drop blank rows inside each frame for comparison. The separator row a + ## full repaint inserts between the prompt echo and arriving assistant + ## content is a transient grid state: depending on PTY byte scheduling it + ## lands in the captured frame as a blank row or not at all. That + ## 0-vs-1-blank difference is timing noise, not a content change. Content + ## rows are always non-blank, so stripping blanks cannot hide a missing or + ## altered row; multi-blank spacing regressions are covered by the dedicated + ## separators test, which inspects frames directly. + var inFrame = false + for line in text.splitLines(keepEol = true): + if line.startsWith("=====") and line.strip.endsWith("====="): + result.add "===== frame =====\n" + inFrame = true + elif inFrame and line.strip.len == 0: + discard + else: + result.add line + proc writeMeaningfulFrameArtifact*(s: TtySession; path: string) = let dir = path.splitPath.head if dir.len > 0: @@ -611,7 +630,8 @@ proc expectMeaningfulFrameArtifact*(s: TtySession; expectedPath, "missing expected full-frame artifact: " & expectedPath & "\nactual written to: " & actualPath let expected = readFile(expectedPath) - doAssert actual.normalizeFrameSeparators == expected.normalizeFrameSeparators, + doAssert actual.normalizeFrameSeparators.stripFrameBlanks == + expected.normalizeFrameSeparators.stripFrameBlanks, "full-frame recording differed from expected frames\nexpected: " & expectedPath & "\nactual: " & actualPath From 971d1ba4937a253c8f839d9effff8d0fa33c2d2b Mon Sep 17 00:00:00 2001 From: Carlo Capocasa Date: Fri, 26 Jun 2026 02:42:51 +0200 Subject: [PATCH 4/7] fix(fatprompt): reset editor row model atomically with transcript commit on submit --- src/threecode/fatprompt/runtime.nim | 56 +++++++++++++++++++++++------ tests/test_tty_functional.nim | 16 +++++++++ 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/src/threecode/fatprompt/runtime.nim b/src/threecode/fatprompt/runtime.nim index 4aadddd..9d6ac53 100644 --- a/src/threecode/fatprompt/runtime.nim +++ b/src/threecode/fatprompt/runtime.nim @@ -571,6 +571,18 @@ proc resetPromptInputAfterEmpty*(echoRows: int) = hideRealCaretBytes() & barFooterBytes(currentBarLabel, currentTermW())) +proc resetEditorRowModel(ed: ptr minline.LineEditor) = + ## Clear the live editor's text and row geometry so it presents as a single + ## empty row. The submit path calls this inside the terminal-write lock, + ## atomically with the transcript append, so background repainters cannot + ## observe a stale multi-row editor after a prompt is committed as scrollback. + if ed == nil: return + ed[].line = minline.Line(text: "", position: 0) + ed[].renderSuffix = "" + ed[].renderSuffixCursor = false + ed[].renderRow = 0 + ed[].echoRows = 0 + proc commitTranscriptBytes*(transcriptBytes: string; restoreEditor = true; beforeRepaint: proc() = nil; reserveFooter = true; @@ -589,17 +601,39 @@ proc commitTranscriptBytes*(transcriptBytes: string; restoreEditor = true; if beforeRepaint != nil: beforeRepaint() let newFooter = footerFrame(fatPromptState) - termengine.appendTranscript( - transcriptBytes, - liveEditorFooterAnchored(), - inputThreadRunning, - inputEditor, - oldFooter, - newFooter, - 0, - restoreEditor, - reserveFooter, - transcriptOwnsSpacing) + # The submit path (restoreEditor=false) commits the prompt as scrollback and + # then drops the editor chrome. Reset the editor's row model inside the same + # terminal-write critical section as the transcript append: without this, + # there is a window between appendTranscript (which reads the editor's + # pre-submit row model) and the controller's later reset where a background + # repainter (spinner/bar-tick) can observe a stale multi-row editor and + # over-walk its clear into the just-committed scrollback rows. + if not restoreEditor and inputEditor != nil: + withTerminalWriteLock: + termengine.appendTranscript( + transcriptBytes, + liveEditorFooterAnchored(), + inputThreadRunning, + inputEditor, + oldFooter, + newFooter, + 0, + restoreEditor, + reserveFooter, + transcriptOwnsSpacing) + resetEditorRowModel(inputEditor) + else: + termengine.appendTranscript( + transcriptBytes, + liveEditorFooterAnchored(), + inputThreadRunning, + inputEditor, + oldFooter, + newFooter, + 0, + restoreEditor, + reserveFooter, + transcriptOwnsSpacing) if reserveFooter and transcriptBytes.hasNonNewlineBytes and currentBarLabel.len > 0: emitFatPromptEvent setBarEvent(currentBarLabel, hasGap = true) debugOut "writeTranscriptWithFatPrompt exit" diff --git a/tests/test_tty_functional.nim b/tests/test_tty_functional.nim index 794a40a..9ea8a19 100644 --- a/tests/test_tty_functional.nim +++ b/tests/test_tty_functional.nim @@ -1050,6 +1050,22 @@ suite "terminal visual contract": tty.expectInHistory "Second reply line." tty.expectTokenBar(["○", "↑10", "↓5"]) tty.drain(300) + # The token bar is only painted while the turn is active. Sample the + # *stable idle* state — wait for the caret to reappear on the live `❯` + # prompt — so frames[^1] is the idle repaint, not a transient spinner + # tick that can sample a mid-turn frame and report a false maxRun>1. + # The stranded-gap bug persists into the idle frame (it is committed + # scrollback), so maxRun <= 1 still catches it. + let idleDeadline = epochTime() + 5.0 + block waitForIdle: + while epochTime() < idleDeadline: + tty.drain(20) + if tty.frames.len > 0: + let f = tty.frames[^1] + if not f.cursorHidden and f.cursorRow >= 0 and + f.cursorRow < f.rows.len and "❯" in f.rows[f.cursorRow]: + break waitForIdle + sleep 10 # The final frame must not have >1 consecutive blank rows anywhere in # scrollback: that is the visual symptom of the extra-line bug. let rows = if tty.frames.len > 0: tty.frames[^1].rows else: @[] From 5c6cd1ecb6100d2fb9cb411c8cb3200658842acb Mon Sep 17 00:00:00 2001 From: Carlo Capocasa Date: Fri, 26 Jun 2026 09:56:24 +0200 Subject: [PATCH 5/7] fix: sanitize wire body so invalid UTF-8 can't brick a session --- src/threecode/api.nim | 2 +- src/threecode/compact.nim | 2 +- src/threecode/util.nim | 60 +++++++++++++++++++++++++++++++++++++++ tests/test_util.nim | 49 +++++++++++++++++++++++++++++++- 4 files changed, 110 insertions(+), 3 deletions(-) diff --git a/src/threecode/api.nim b/src/threecode/api.nim index 7b0ef3a..06bebea 100644 --- a/src/threecode/api.nim +++ b/src/threecode/api.nim @@ -1053,7 +1053,7 @@ proc callModel*(p: Profile, messages: JsonNode, usage: var Usage, lastPromptToke applyGenerationDefaults(p, body) if p.reasoning.len > 0: applyReasoning(p, body) - let bodyStr = $body + let bodyStr = sanitizeUtf8($body) if "\"usage\"" in bodyStr: stderr.writeLine "3code: BUG: usage in wireMessages" for i, m in wireMessages: diff --git a/src/threecode/compact.nim b/src/threecode/compact.nim index 5ef787b..f9e5f1e 100644 --- a/src/threecode/compact.nim +++ b/src/threecode/compact.nim @@ -115,7 +115,7 @@ proc callSummarizer(p: Profile, messages: JsonNode): string = client.headers["Authorization"] = "Bearer " & p.key client.headers["Content-Type"] = "application/json" let resp = client.request(p.url & "/chat/completions", - httpMethod = HttpPost, body = $body) + httpMethod = HttpPost, body = sanitizeUtf8($body)) status = resp.code.int respBody = resp.body except CatchableError as e: diff --git a/src/threecode/util.nim b/src/threecode/util.nim index 9cab01a..37feb6b 100644 --- a/src/threecode/util.nim +++ b/src/threecode/util.nim @@ -116,6 +116,66 @@ proc utf8ByteCutEnd*(s: string, n: int): string = inc start s[start .. ^1] +proc sanitizeUtf8*(s: string): string = + ## Return a copy of `s` that is valid UTF-8: every invalid byte or + ## malformed sequence is replaced with a single U+FFFD, and every valid + ## codepoint (including multibyte) passes through untouched. + ## + ## Strings that cross a system boundary into a JSON request body must be + ## valid UTF-8. Tool output and resumed-session text can carry bytes that + ## aren't — e.g. a command that printed a truncated multi-byte rune left a + ## lone continuation byte in its result, which `std/json` emits verbatim + ## into the serialized body. The provider then rejects the whole body with + ## a 400, and since the offending message recurs in every subsequent + ## request (tool messages can't be dropped) the session is bricked until + ## the `.3log` is hand-edited. This is the boundary guard that stops that. + result = newStringOfCap(s.len) + var i = 0 + while i < s.len: + let b = s[i].uint8 + if b < 0x80: + result.add s[i]; inc i; continue + let seqLen = + if b shr 5 == 0b110: 2 + elif b shr 4 == 0b1110: 3 + elif b shr 3 == 0b11110: 4 + else: 0 # lone continuation byte, or a lead byte > 0xF4 + if seqLen == 0: + result.add "\uFFFD"; inc i; continue + var ok = true + if i + seqLen > s.len: ok = false + else: + case seqLen + of 2: + if (s[i + 1].uint8 and 0xC0'u8) != 0x80'u8: ok = false + elif b <= 0xC1'u8: ok = false # overlong (encodes < 0x80) + of 3: + if (s[i + 1].uint8 and 0xC0'u8) != 0x80'u8 or + (s[i + 2].uint8 and 0xC0'u8) != 0x80'u8: ok = false + elif b == 0xE0'u8 and (s[i + 1].uint8 and 0xE0'u8) == 0x80'u8: + ok = false # overlong + elif b == 0xED'u8 and (s[i + 1].uint8 and 0xE0'u8) == 0xA0'u8: + ok = false # surrogate (U+D800..U+DFFF) + of 4: + if (s[i + 1].uint8 and 0xC0'u8) != 0x80'u8 or + (s[i + 2].uint8 and 0xC0'u8) != 0x80'u8 or + (s[i + 3].uint8 and 0xC0'u8) != 0x80'u8: ok = false + elif b == 0xF0'u8 and (s[i + 1].uint8 and 0xF0'u8) == 0x80'u8: + ok = false # overlong + elif b == 0xF4'u8 and s[i + 1].uint8 > 0x8F'u8: + ok = false # > U+10FFFF + elif b > 0xF4'u8: ok = false + else: discard + if ok: + for k in 0 ..< seqLen: result.add s[i + k] + inc i, seqLen + else: + # Drop only the bad lead byte; any orphaned continuation bytes that + # follow get re-checked on the next iteration and each become its own + # U+FFFD, matching WHATWG/W3C decoder best practice (substituting one + # replacement char per maximal subpart of an ill-formed sequence). + result.add "\uFFFD"; inc i + proc clipMiddle*(s: string, head, tail: int): string = if s.len <= head + tail: s else: utf8ByteCut(s, head) & "\n... [truncated] ...\n" & utf8ByteCutEnd(s, tail) diff --git a/tests/test_util.nim b/tests/test_util.nim index 522f646..6c054f8 100644 --- a/tests/test_util.nim +++ b/tests/test_util.nim @@ -1,4 +1,4 @@ -import std/[strutils, unicode, unittest] +import std/[json, strutils, unicode, unittest] import threecode/util suite "util: utf8ByteCut": @@ -30,6 +30,53 @@ suite "util: utf8ByteCutEnd": test "handles empty string": check utf8ByteCutEnd("", 5) == "" +suite "util: sanitizeUtf8": + test "passes ASCII through unchanged": + check sanitizeUtf8("plain ascii") == "plain ascii" + + test "preserves valid multibyte codepoints": + check sanitizeUtf8("\u276F caf\u00E9") == "\u276F caf\u00E9" + + test "empty string stays empty": + check sanitizeUtf8("") == "" + + test "replaces a lone continuation byte with U+FFFD": + # The exact poison: a lone 0xAF (the final byte of ❯ = E2 9D AF) sitting + # mid-ASCII after its lead bytes were dropped from captured tool output. + var s = "editorText: " + s.add chr(0xAF) + s.add " this" + let r = sanitizeUtf8(s) + check r == "editorText: \uFFFD this" + + test "replaces a truncated multi-byte lead with U+FFFD": + # E2 9D without the AF tail — the truncated rune that produced this bug. + var s = "x" + s.add chr(0xE2) + s.add chr(0x9D) + let r = sanitizeUtf8(s) + check r == "x\uFFFD\uFFFD" + + test "result is valid UTF-8": + var s = "a" + s.add chr(0xAF) + s.add "b" + let r = sanitizeUtf8(s) + # U+FFFD round-trips through rune iteration without error. + check r.toRunes.len == 3 + + test "a serialized body with invalid UTF-8 parses as JSON after sanitize": + # Mirrors how both call sites use it: the body is serialized first, then + # sanitized, and the result must still be valid JSON the provider accepts. + var poison = "editorText: " + poison.add chr(0xAF) + poison.add " done" + let body = %*{"model": "m", + "messages": [%*{"role": "tool", "content": poison}]} + let wire = sanitizeUtf8($body) + let back = parseJson(wire) + check back{"messages"}[0]{"content"}.getStr == "editorText: \uFFFD done" + suite "util: clipMiddle": test "returns full string when within limit": check clipMiddle("hello", 3, 3) == "hello" From 22985cf7b94339f34fad5e140a0903585d8dc8a1 Mon Sep 17 00:00:00 2001 From: Carlo Capocasa Date: Fri, 26 Jun 2026 10:20:23 +0200 Subject: [PATCH 6/7] fix: redraw fat prompt on terminal resize The input thread used poll() with SA_RESTART on the SIGWINCH handler, so resize never caused EINTR and the editor/footer were never repainted at the new geometry. Now the input thread checks the shared resize flag on each poll cycle and surfaces it as IOError, reusing the existing redraw path in readLineWith. Adds a visual test for idle resize rewrap. --- src/threecode/fatprompt/runtime.nim | 9 +++++- tests/test_tty_functional.nim | 44 ++++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/threecode/fatprompt/runtime.nim b/src/threecode/fatprompt/runtime.nim index 52167f5..d9cde0e 100644 --- a/src/threecode/fatprompt/runtime.nim +++ b/src/threecode/fatprompt/runtime.nim @@ -9,7 +9,7 @@ when defined(posix): import std/posix except SocketHandle import posix/termios import ../types, ../util, ../compact, ../display, ../minline, - ../terminal as termui, ../session + ../signals, ../terminal as termui, ../session import ../engine as termengine import rendering from ../api import ApiStreamHooks, requestTurnInterrupt, setApiStreamHooks, @@ -1319,6 +1319,13 @@ proc inputThreadProc() {.thread.} = result = pendingInput[0] pendingInput.delete(0) return + # SIGWINCH is caught with SA_RESTART, so poll() is restarted + # rather than returning EINTR. Detect the resize via the shared + # flag on each poll cycle and surface it as IOError so readLineWith + # redraws the editor and footer at the new geometry. + if consumeResizePending(): + markResizePending() + raise newException(IOError, "terminal resized") if errno == EINTR: continue -1 diff --git a/tests/test_tty_functional.nim b/tests/test_tty_functional.nim index f09e9ea..3069e99 100644 --- a/tests/test_tty_functional.nim +++ b/tests/test_tty_functional.nim @@ -1,4 +1,4 @@ -import std/[json, os, osproc, strutils, times, unittest] +import std/[json, os, osproc, strutils, times, unicode, unittest] import posix except SocketHandle import tty_expect @@ -1144,6 +1144,48 @@ when false: ResizeStreamFrames, root / "resize_stream_actual.txt") + proc dropRunes(s: string; n: int): string = + var i = 0 + var cnt = 0 + while i < s.len and cnt < n: + i += max(1, runeLenAt(s, i)) + inc cnt + if i >= s.len: "" else: s[i..^1] + + test "resize at idle rewraps the editor prompt": + if getEnv("THREECODE_TTY_ONLY") notin ["", "resize_idle"]: + check true + else: + let root = newFixture("resize_idle") + writeConfiguredProvider(root) + let tty = startStub(root) + defer: + tty.writeFrameArtifact(root / "frames.txt") + tty.close() + tty.expect "❯" + # Type text long enough to wrap at 80 cols, then resize narrower so it + # rewraps, and verify the continuation rows appear. + tty.send "this is a long enough line of idle text to wrap when narrowed" + tty.drain(100) + tty.resize(40, 12) + tty.drain(300) + let narrow = tty.screenText() + # At 40 cols the single 80-col line rewraps onto multiple rows. + check "this is a long enough line of idle" in narrow + # Reassemble the editor rows (strip continuation prefix) and verify the + # typed text survived the rewrap with no duplicated or dropped runes. + var editorText = "" + var inEditor = false + for row in narrow.splitLines(): + if row.startsWith("❯ "): + editorText.add row.dropRunes(2) + inEditor = true + elif inEditor and row.startsWith(" "): + editorText.add row.dropRunes(2) + elif inEditor and row.strip().len == 0: + break + check editorText == "this is a long enough line of idle text to wrap when narrowed" + test "main visual test": if getEnv("THREECODE_TTY_ONLY").len > 0 and getEnv("THREECODE_TTY_ONLY") != "main_visual_test": From c297567ea11c7028e66d64b0de4c9ba5e82dfad6 Mon Sep 17 00:00:00 2001 From: Carlo Capocasa Date: Fri, 26 Jun 2026 11:48:11 +0200 Subject: [PATCH 7/7] fix: loop on StreamTimeoutError during head read so slow providers don't hang The response head read used the same 500ms wake-bounded recv as the streaming body loop, but treated StreamTimeoutError as a stale-conn failure. Providers that hold the connection for seconds while the model warms up (z.ai GLM, ~7s to first byte) burned both stale-conn retries and then failed with "recv timed out", hanging every request on macOS where the head arrives after the 500ms window. Loop on the timeout (re-checking interrupt/quiet) like the body loop already does. Applies to both streamHttp and callHttp. --- src/threecode/api.nim | 49 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/src/threecode/api.nim b/src/threecode/api.nim index 06bebea..c8bacc7 100644 --- a/src/threecode/api.nim +++ b/src/threecode/api.nim @@ -486,7 +486,34 @@ proc streamHttp(url, key, bodyStr: string, baseLabel: string, ("Accept", "text/event-stream")], body = bodyStr) hookProviderActivity() - resp = conn.readResponseHead() + # The head read uses the same QuietRecvWakeMs-bounded recv as the + # body loop, so it raises StreamTimeoutError periodically. Unlike + # the body loop, a slow head is the norm here: the provider holds + # the connection for several seconds while the model warms up + # before emitting even the HTTP status line. We must loop on the + # timeout (re-checking interrupt/quiet) rather than treating it as + # a stale connection; otherwise every request to a slow provider + # (z.ai GLM, ~7s to first byte) burns the two stale-conn retries + # and then fails with "recv timed out". + while true: + try: + resp = conn.readResponseHead() + break + except StreamTimeoutError: + if isInterrupted() or isNetworkQuiet(): + closeCachedStreamConn() + break + continue + if resp.status == 0 and resp.headers.len == 0: + # Head loop bailed on interrupt/quiet before any head bytes + # arrived. The outer except won't fire (no exception), so exit + # the attempt loop here. + if isInterrupted(): + result.errMsg = "interrupted by user" + else: + result.errMsg = "network quiet too long (no data for " & + $(QuietTooLongMs div 1000) & "s)" + return hookProviderActivity() break except CatchableError as e: @@ -761,7 +788,25 @@ proc callHttp(url, key, bodyStr: string; baseLabel: string; ("Accept", "application/json")], body = bodyStr) hookProviderActivity() - resp = conn.readResponseHead() + # Same head wake-loop as streamHttp: a slow head is normal (the + # model warms up before the status line arrives), so we loop on + # StreamTimeoutError rather than burning the stale-conn retry. + while true: + try: + resp = conn.readResponseHead() + break + except StreamTimeoutError: + if isInterrupted() or isNetworkQuiet(): + closeCachedStreamConn() + break + continue + if resp.status == 0 and resp.headers.len == 0: + if isInterrupted(): + result.errMsg = "interrupted by user" + else: + result.errMsg = "network quiet too long (no data for " & + $(QuietTooLongMs div 1000) & "s)" + return hookProviderActivity() break except CatchableError as e: