Skip to content

Commit 2e31b28

Browse files
committed
v0.56.2 — Terminal UX compression, trimToSurvival fix, and cross-platform test fixes
Terminal rendering (vertical space compression): - render.go: Start() no-op, remove blank lines from Iteration/FinalAnswer/Summary - loop.go: remove duplicate pre-LLM iteration header call - repl_editor.go: fix raw-mode cursor discipline (\r\n instead of bare \n) trimToSurvival ordering bug (400 Bad Request fix): - loop.go: fix group reversal that placed tool messages before assistant(tool_calls) by using [][]llm.Message sub-slices instead of flattening+reversing individual messages Cross-platform test fixes: - classifier.go: macOS /var/folders temp dirs classified as LocalWrite not SystemWrite (os.TempDir may include trailing slash on macOS; use filepath.Clean before prefix check) - main_test.go: dockerAvailable() now verifies daemon reachability (docker info) - restart_e2e_test.go: use t.TempDir() fixtures instead of hardcoded /root/.odek/skills
1 parent c5ef6db commit 2e31b28

7 files changed

Lines changed: 86 additions & 60 deletions

File tree

cmd/odek/main_test.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -755,8 +755,14 @@ func TestRun_WithProjectConfig(t *testing.T) {
755755

756756
// dockerAvailable returns true if the docker CLI is available.
757757
func dockerAvailable() bool {
758-
_, err := exec.LookPath("docker")
759-
return err == nil
758+
if _, err := exec.LookPath("docker"); err != nil {
759+
return false
760+
}
761+
// Verify the daemon is actually reachable (not just the CLI installed).
762+
cmd := exec.Command("docker", "info")
763+
cmd.Stdout = nil
764+
cmd.Stderr = nil
765+
return cmd.Run() == nil
760766
}
761767

762768
// ── Init Config Tests ─────────────────────────────────────────────────

cmd/odek/repl_editor.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ func (e *replEditor) ReadLine() (string, error) {
8484
}
8585
// Disable bracketed paste
8686
fmt.Fprint(os.Stderr, "\x1b[?2004l")
87-
fmt.Fprintln(os.Stderr)
87+
fmt.Fprint(os.Stderr, "\r\n")
8888
return result, nil
8989
}
9090
}

internal/danger/classifier.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,16 @@ func ClassifyPath(path string) RiskClass {
6868
return Destructive
6969
}
7070
}
71+
72+
// Temp directory paths are always local, not system. This handles
73+
// macOS where temp dirs live under /var/folders/, preventing false
74+
// SystemWrite classification (matching Linux /tmp behavior).
75+
// os.TempDir may include a trailing separator on some platforms;
76+
// Clean normalises it before the prefix check.
77+
if tmpDir := filepath.Clean(os.TempDir()); abs == tmpDir || strings.HasPrefix(abs, tmpDir+string(filepath.Separator)) {
78+
return LocalWrite
79+
}
80+
7181
for _, prefix := range []string{"/etc", "/root", "/var", "/run", "/lib", "/usr"} {
7282
if strings.HasPrefix(abs, prefix) {
7383
return SystemWrite

internal/loop/loop.go

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -422,41 +422,55 @@ func trimToSurvival(msgs []llm.Message) []llm.Message {
422422
}
423423

424424
// Collect the last 2 complete assistant→tool groups before the user msg.
425-
var groups []llm.Message
425+
// Each group is a sub-slice in correct internal order: [system*, assistant, tool*].
426+
var groups [][]llm.Message
426427
seen := 0
427428
for i := lastUserIdx - 1; i > start && seen < 2; i-- {
428429
if msgs[i].Role == "assistant" && len(msgs[i].ToolCalls) > 0 {
429-
// This assistant message has tool calls — collect it and
430-
// its following tool results.
431-
groupEnd := i + 1
432-
for groupEnd < len(msgs) && msgs[groupEnd].Role == "tool" {
433-
groupEnd++
434-
}
435-
groups = append(groups, msgs[i:groupEnd]...)
436-
// Also include any preceding system messages (corrections, warnings).
430+
var group []llm.Message
431+
432+
// Preceding system messages (corrections, warnings)
437433
preStart := i - 1
438434
for preStart > start && msgs[preStart].Role == "system" {
439435
preStart--
440436
}
441437
for k := preStart + 1; k < i; k++ {
442-
groups = append(groups, msgs[k])
438+
group = append(group, msgs[k])
439+
}
440+
441+
// Assistant message with tool calls
442+
group = append(group, msgs[i])
443+
444+
// Following tool results
445+
for j := i + 1; j < len(msgs) && msgs[j].Role == "tool"; j++ {
446+
group = append(group, msgs[j])
443447
}
448+
449+
groups = append(groups, group)
450+
i = preStart + 1 // skip past the group we just consumed
444451
seen++
445452
}
446453
}
447454

448455
// Build survival set: system + task + recent groups + last user
449-
survival := make([]llm.Message, 0, start+1+len(groups)+1)
456+
// Calculate total messages across all groups for capacity hint
457+
totalGroupMsgs := 0
458+
for _, g := range groups {
459+
totalGroupMsgs += len(g)
460+
}
461+
survival := make([]llm.Message, 0, start+1+totalGroupMsgs+1)
450462
if start > 0 {
451463
survival = append(survival, msgs[0]) // system message
452464
}
453465
// Add a context-warning system message
454466
warning := "[Context trimmed to survive: the conversation history exceeded the model's context window. Earlier turns have been dropped. If you need information from earlier in the conversation, the agent may ask for a summary.]"
455467
survival = append(survival, llm.Message{Role: "system", Content: warning})
456468

457-
// Add the recent groups (in chronological order, not reversed)
469+
// Add the recent groups in chronological order (groups were collected
470+
// from newest to oldest, so reverse them while preserving each group's
471+
// internal order: system* → assistant(tool_calls) → tool*).
458472
for i := len(groups) - 1; i >= 0; i-- {
459-
survival = append(survival, groups[i])
473+
survival = append(survival, groups[i]...)
460474
}
461475

462476
// Add the last user message
@@ -515,11 +529,6 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
515529
default:
516530
}
517531

518-
// Render iteration header (1-indexed for humans)
519-
if e.renderer != nil && e.interactionMode != "off" {
520-
e.renderer.Iteration(i+1, e.maxIter, 0, 0, 0, 0)
521-
}
522-
523532
// Trim context to stay within model's context window
524533
messages = e.trimContext(messages, tools)
525534

internal/render/render.go

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -123,20 +123,9 @@ func (r *Renderer) disable() bool {
123123

124124
// ── Rendering methods ─────────────────────────────────────────────────
125125

126-
// Start prints the session header with task preview.
127-
func (r *Renderer) Start(task string) {
128-
if r.disable() {
129-
return
130-
}
131-
preview := r.truncate(strings.ReplaceAll(task, "\n", " "), 80)
132-
prefix := "⚡ odek"
133-
if r.model != "" {
134-
prefix += " · " + r.model
135-
}
136-
fmt.Fprintln(r.w, r.style(blue, prefix))
137-
fmt.Fprintln(r.w, r.style(gray, " "+preview))
138-
fmt.Fprintln(r.w)
139-
}
126+
// Start is a no-op — session context is now shown via iteration headers
127+
// and session banners (REPL). Kept for API compatibility.
128+
func (r *Renderer) Start(task string) {}
140129

141130
// Iteration prints the cycle header with optional turn statistics and
142131
// turn number. When turn > 0, shows "Turn N" in the header.
@@ -164,7 +153,6 @@ func (r *Renderer) Iteration(n, maxN int, latency time.Duration, inTokens, outTo
164153
// Double-line rule framing
165154
rule := strings.Repeat("═", 3)
166155
line := fmt.Sprintf("%s %s %s%s", rule, prefix, rule, stats)
167-
fmt.Fprintln(r.w)
168156
fmt.Fprintln(r.w, r.style(blue, line))
169157
}
170158

@@ -402,9 +390,7 @@ func (r *Renderer) FinalAnswer(text string) {
402390
if r.disable() || text == "" {
403391
return
404392
}
405-
fmt.Fprintln(r.w)
406393
fmt.Fprintln(r.w, r.style(green, "✅ "+text))
407-
fmt.Fprintln(r.w)
408394
}
409395

410396
// Summary prints a run summary line with total token and cache statistics.
@@ -432,7 +418,6 @@ func (r *Renderer) Summary(inTokens, outTokens, cacheCreate, cacheRead, cached i
432418
parts = append(parts, fmt.Sprintf("%d cached", cached))
433419
}
434420
fmt.Fprintln(r.w, r.style(gray, "── "+strings.Join(parts, " · ")))
435-
fmt.Fprintln(r.w)
436421
}
437422

438423
// Error prints a non-fatal loop error with a cross emoji.

internal/render/render_test.go

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,8 @@ func TestRenderer_Start(t *testing.T) {
1515
r.Start("list all files in this directory")
1616

1717
out := buf.String()
18-
if !strings.Contains(out, "odek") {
19-
t.Errorf("Start() missing odek brand: %q", out)
20-
}
21-
if !strings.Contains(out, "deepseek-chat") {
22-
t.Errorf("Start() missing model name: %q", out)
23-
}
24-
if !strings.Contains(out, "list all files") {
25-
t.Errorf("Start() missing task preview: %q", out)
18+
if out != "" {
19+
t.Errorf("Start() should be a no-op, got output: %q", out)
2620
}
2721
}
2822

@@ -34,9 +28,8 @@ func TestRenderer_Start_LongTask(t *testing.T) {
3428
r.Start(longTask)
3529

3630
out := buf.String()
37-
// Task preview should be truncated to ~80 chars
38-
if strings.Count(out, "explain") > 6 {
39-
t.Errorf("Start() should truncate long task: %q", out)
31+
if out != "" {
32+
t.Errorf("Start() should be a no-op even for long tasks, got output: %q", out)
4033
}
4134
}
4235

internal/skills/restart_e2e_test.go

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,46 @@
11
package skills
22

33
import (
4+
"os"
5+
"path/filepath"
46
"strings"
57
"testing"
68
)
79

10+
// setupRestartSkill creates a temp skills directory containing the
11+
// restart-odek-telegram skill and returns a SkillManager pointed at it.
12+
func setupRestartSkill(t *testing.T) *SkillManager {
13+
t.Helper()
14+
15+
dir := t.TempDir()
16+
skillDir := filepath.Join(dir, "restart-odek-telegram")
17+
if err := os.MkdirAll(skillDir, 0755); err != nil {
18+
t.Fatalf("failed to create skill dir: %v", err)
19+
}
20+
21+
content := `---
22+
name: restart-odek-telegram
23+
description: Restart the odek Telegram bot
24+
odek:
25+
trigger:
26+
topic: odek, telegram, bot, restart, deploy, rebuild
27+
action: restart, redeploy, rebuild, bounce
28+
auto_load: false
29+
---
30+
Run build-and-restart-telegram.sh --restart-only with nohup. Do NOT use go build directly.
31+
`
32+
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0644); err != nil {
33+
t.Fatalf("failed to write SKILL.md: %v", err)
34+
}
35+
36+
return NewSkillManager("", dir)
37+
}
38+
839
// TestRestartSkill_Triggers_ScoredMatcher verifies the scored matcher
940
// correctly triggers the restart-odek-telegram skill for common user
1041
// inputs like "rebuild and restart", "restart the bot", etc.
1142
func TestRestartSkill_Triggers_ScoredMatcher(t *testing.T) {
12-
if testing.Short() {
13-
t.Skip("skipping E2E test in short mode (requires installed skills)")
14-
}
15-
sm := NewSkillManager("", "/root/.odek/skills")
16-
sm.Reload()
43+
sm := setupRestartSkill(t)
1744

1845
// The restart skill should be in the Lazy pool (auto_load: false)
1946
found := false
@@ -63,11 +90,7 @@ func TestRestartSkill_Triggers_ScoredMatcher(t *testing.T) {
6390
// actually directs the agent to use the build-and-restart script,
6491
// not manual build or kill commands.
6592
func TestRestartSkill_BodyContainsScript(t *testing.T) {
66-
if testing.Short() {
67-
t.Skip("skipping E2E test in short mode (requires installed skills)")
68-
}
69-
sm := NewSkillManager("", "/root/.odek/skills")
70-
sm.Reload()
93+
sm := setupRestartSkill(t)
7194

7295
matched := sm.ScoredMatcher.MatchSkills("rebuild and restart", 1)
7396
if len(matched) == 0 {

0 commit comments

Comments
 (0)