Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion core/agent/prompts.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ func PromptEmpty(screen string) bool {
if lastMatch(lines, emptyPlaceholder) >= 0 {
return !workingShown.MatchString(strings.Join(lines, "\n"))
}
return bareMark(lines) >= 0
return bareMark(lines) >= 0 || openCodeEmpty(lines)
}

// lastMatch is the last of lines re matches, -1 for none.
Expand Down
58 changes: 58 additions & 0 deletions core/agent/prompts_opencode.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package agent

import (
"regexp"
"strings"
)

// OpenCode's composer is a box whose rows start with a heavy bar, and has no prompt mark:
//
// ┃
// ┃ Ask anything… "Fix a TODO in the codebase" (the placeholder, on its first screen)
// ┃
// ┃ Build · Big Pickle OpenCode Zen (its agent and model)
// ╹▀▀▀▀▀▀▀▀
//
// In a conversation the placeholder isn't shown, and on Windows the last row may read as
// its commands' hint. Only that box counts: a bar alone isn't a composer for other agents.

// openCodePlaceholder is the composer's placeholder, OpenCode's exact words.
var openCodePlaceholder = regexp.MustCompile(`^┃\s*Ask anything(…|\.\.\.)(\s+"[^"]*")?$`)

// openCodeDialog is a dialog's title over the screen, its close key at the right: what is
// typed then goes to the dialog (an update's, a model's pick), not the composer.
var openCodeDialog = regexp.MustCompile(`\s{2,}esc$`)

// openCodeChrome is the box's last row: the agent and model it runs, or its commands' hint.
var openCodeChrome = regexp.MustCompile(`^┃\s+(\S.* · |.*ctrl\+p commands)`)

// openCodeEmpty says lines end on OpenCode's composer with nothing typed in it: the last
// box of bar rows, its last row OpenCode's, every other row bare or the placeholder, and
// nothing at work nor asking to allow something. A row with anything else is a draft.
func openCodeEmpty(lines []string) bool {
end := -1
for i := len(lines) - 1; i >= 0; i-- {
if strings.HasPrefix(lines[i], "┃") {
end = i
break
}
}
start := end
for start > 0 && strings.HasPrefix(lines[start-1], "┃") {
start--
}
if end < 0 || end-start < 2 || !openCodeChrome.MatchString(lines[end]) {
return false
}
if lastMatch(lines, openCodeDialog) >= 0 {
return false
}
for _, row := range lines[start:end] {
if row != "┃" && !openCodePlaceholder.MatchString(row) {
return false
}
}
// Under the box are its hints and a tip, whose words change: only a prompt to allow
// something counts there.
return !workingShown.MatchString(strings.Join(lines, "\n")) && !permissionPrompts.MatchString(strings.Join(lines[end+1:], "\n"))
}
65 changes: 65 additions & 0 deletions core/agent/prompts_opencode_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package agent

import (
"os"
"strings"
"testing"
)

// openCodeOnWindows is GOD of Agents on OpenCode 1.18 in a conversation, idle, as its screen
// read on Windows (issue #51): the composer's rows are bars alone, then its commands' hint.
func openCodeOnWindows(draft string) string {
row := " ┃"
if draft != "" {
row = " ┃ " + draft
}
return " Done: the worker's branch is merged.\n\n" +
" ▣ Goa · wz/deepseek-v4.1-flash · 2m 10s\n\n" +
" ┃\n" + row + "\n" +
" ┃ ctrl+p commands • OpenCode 1.18.35\n"
}

// OpenCode's composer with nothing typed reads as empty, so lines for GOD of Agents go in;
// with a draft it doesn't, nor while OpenCode works or asks to allow something.
func TestOpenCodeComposer(t *testing.T) {
read := func(name string) string {
t.Helper()
b, err := os.ReadFile("testdata/" + name)
if err != nil {
t.Fatal(err)
}
return string(b)
}
cases := []struct {
name string
screen string
empty bool
}{
{"Linux, its placeholder", read("opencode-1.18-linux-empty.txt"), true},
{"Linux, a draft", read("opencode-1.18-linux-draft.txt"), false},
{"Linux, an update's dialog over it", read("opencode-1.18-linux-dialog.txt"), false},
{"Windows, in a conversation", openCodeOnWindows(""), true},
{"Windows, a draft", openCodeOnWindows("sekalian cek routes-nya"), false},
{"Windows, a draft that starts as the placeholder", openCodeOnWindows("Ask anything about the routes"), false},
{"at work", strings.Replace(openCodeOnWindows(""), "ctrl+p commands", "esc interrupt", 1), false},
{"asking to allow something", openCodeOnWindows("") + "Permission required: bash rm -rf dist\n", false},
}
for _, c := range cases {
if got := PromptEmpty(c.screen); got != c.empty {
t.Errorf("%s: PromptEmpty = %v, want %v", c.name, got, c.empty)
}
}
}

// A bar alone, or a box of them that isn't OpenCode's, isn't an empty composer.
func TestABarAloneIsNoComposer(t *testing.T) {
for _, screen := range []string{
"┃\n┃\n┃\n",
"some output\n ┃\n ┃\n ┃ total 3 files\n",
"│\n│ ❯ draft\n│\n",
} {
if PromptEmpty(screen) {
t.Errorf("read as an empty composer:\n%s", screen)
}
}
}
39 changes: 39 additions & 0 deletions core/agent/testdata/opencode-1.18-linux-dialog.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@











Update Available esc

A new release v1.19.5 is available. Would you like to
update now?


Skip Confirm

┃
┃ Ask anything… "Fix a TODO in the codebase"
┃
┃ Build · Big Pickle OpenCode Zen
╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
tab agents ctrl+p commands



● Tip Run /connect to add an AI provider and start coding









~/code/shop 1.18.35
39 changes: 39 additions & 0 deletions core/agent/testdata/opencode-1.18-linux-draft.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@













▄
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀


┃
┃ fix the totals please
┃
┃ Build · Big Pickle OpenCode Zen
╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
tab agents ctrl+p commands



● Tip Run /connect to add an AI provider and start coding









~/code/shop 1.18.35
39 changes: 39 additions & 0 deletions core/agent/testdata/opencode-1.18-linux-empty.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@













▄
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀


┃
┃ Ask anything… "Fix a TODO in the codebase"
┃
┃ Build · Big Pickle OpenCode Zen
╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
tab agents ctrl+p commands



● Tip Run /connect to add an AI provider and start coding









~/code/shop 1.18.35
9 changes: 9 additions & 0 deletions daemon/goa.go
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,15 @@ type goaLine struct {
// Report: it holds what the session reported, which goes even once its task is
// settled, said to be so: a report never goes without a word.
Report bool `json:"report,omitempty"`
// Stall: it says its task isn't moving (goa_stall.go), told once while it waits.
Stall bool `json:"stall,omitempty"`
}

// goaWaits says a line about task waits for GOA, of those match takes.
func (s *Server) goaWaits(task string, match func(goaLine) bool) bool {
s.goa.mu.Lock()
defer s.goa.mu.Unlock()
return slices.ContainsFunc(s.goa.tell, func(l goaLine) bool { return l.Task == task && match(l) })
}

// goaSettledNote follows a report whose task was settled before GOA took it.
Expand Down
27 changes: 26 additions & 1 deletion daemon/goa_stall.go
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,14 @@ func (s *Server) stallCheck(w *stallWatch, t proto.GOATask, h stallHost, lim pro
}
return
}
if s.stallReported(t, f) {
// GOA hears how its turn ended: that says more than its being still.
f.told, f.fired = time.Time{}, false
if t.Status == proto.GOATaskStalled {
s.stallMark(t.ID, proto.GOATaskStalled, proto.GOATaskWorking)
}
return
}
if t.Status != proto.GOATaskStalled && !s.stallMark(t.ID, proto.GOATaskWorking, proto.GOATaskStalled) {
return // settled meanwhile
}
Expand All @@ -457,6 +465,9 @@ func (s *Server) stallCheck(w *stallWatch, t proto.GOATask, h stallHost, lim pro
if !f.told.IsZero() && now.Sub(f.told) < stallAgain*limit {
return
}
if s.goaWaits(t.ID, func(l goaLine) bool { return l.Stall }) {
return // the line before still waits for GOA: another says nothing new
}
f.told = now
line := fmt.Sprintf("%s, for %s", why, look.Still.Round(time.Second))
if look.Blank {
Expand All @@ -472,7 +483,21 @@ func (s *Server) stallCheck(w *stallWatch, t proto.GOATask, h stallHost, lim pro
if look.NoCPU {
line += " (its host can't say what its processes use, so it may be a long build)"
}
s.goaTellLine(goaLine{Text: stallLine(t, line, text), Task: t.ID, Session: t.Session})
s.goaTellLine(goaLine{Text: stallLine(t, line, text), Task: t.ID, Session: t.Session, Stall: true})
}

// stallReportSlack is how long before its screen went still a report still tells of
// the turn that left it so: the report's own output shows after it.
const stallReportSlack = 2 * time.Minute

// stallReported says task t's session reported how its turn ended since it last moved,
// or a report of it waits for GOA: a task that said it is done isn't stalled, though the
// board says working until GOA settles it.
func (s *Server) stallReported(t proto.GOATask, f *stallFollow) bool {
if t.Outcome != "" && time.UnixMilli(t.ReportAt).After(f.since.Add(-stallReportSlack)) {
return true
}
return s.goaWaits(t.ID, func(l goaLine) bool { return l.Turn != "" })
}

// stallMark moves task id from one status to another, unless something else
Expand Down
Loading
Loading