Skip to content
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/3code
tests/test_*
!tests/test_*.nim
!tests/test_http_nonstream.nims
tests/output/
nimcache/
config.local.nims
Expand Down
51 changes: 48 additions & 3 deletions src/threecode/api.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -1053,7 +1098,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:
Expand Down
2 changes: 1 addition & 1 deletion src/threecode/compact.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
85 changes: 72 additions & 13 deletions src/threecode/fatprompt/runtime.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -559,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;
Expand All @@ -577,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"
Expand Down Expand Up @@ -1319,6 +1365,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
Expand Down Expand Up @@ -1386,7 +1439,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:
Expand Down
60 changes: 60 additions & 0 deletions src/threecode/util.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading