diff --git a/core/session/session.go b/core/session/session.go index c8f961f6..a7f2b82b 100644 --- a/core/session/session.go +++ b/core/session/session.go @@ -85,6 +85,8 @@ type Meta struct { // Note is what the session is at, as its agent or the user last said // (SessionInfo.Note). Note string + // TurnText is the message its last turn ended on, when its agent's hook says. + TurnText string // Report, ReportOutcome and ReportAt are its agent's last report of a turn // (SessionInfo). They don't outlast the daemon, their news having gone. Report string @@ -207,6 +209,7 @@ func (s *Session) Info() proto.SessionInfo { Report: s.meta.Report, ReportOutcome: s.meta.ReportOutcome, ReportAt: s.meta.ReportAt, + TurnText: s.meta.TurnText, Group: s.meta.Group, Account: s.meta.Account, Config: cloneConfig(s.meta.Config), diff --git a/core/session/work.go b/core/session/work.go index 12d2521e..8dc35719 100644 --- a/core/session/work.go +++ b/core/session/work.go @@ -104,6 +104,21 @@ func (m *Manager) SetNote(s *Session, note string) { m.notify(s) } +// turnTextMost bounds what a session keeps of its turn's last message: what GOA's line +// holds, as it goes with every update of the session. +const turnTextMost = 2000 + +// NoteTurnText keeps the message s's turn ended on, or "" as a turn starts. It says +// nothing by itself: a change of state follows. +func (m *Manager) NoteTurnText(s *Session, text string) { + if r := []rune(text); len(r) > turnTextMost { + text = string(r[:turnTextMost]) + "…" + } + s.mu.Lock() + s.meta.TurnText = text + s.mu.Unlock() +} + // SetReport keeps its agent's report of a turn. func (m *Manager) SetReport(s *Session, outcome, text string) { s.mu.Lock() diff --git a/daemon/goa.go b/daemon/goa.go index bd848400..b9914162 100644 --- a/daemon/goa.go +++ b/daemon/goa.go @@ -7,6 +7,7 @@ import ( "path/filepath" "slices" "sync" + "time" "github.com/enowx/enowx/core/agent" "github.com/enowx/enowx/core/goa" @@ -198,6 +199,9 @@ func (s *Server) goaSeen(info proto.SessionInfo) { if s.goa.board.Has(info.ID) { s.goaRanOn(info) go func() { + if info.State == proto.StateWorking { + s.goaTurnStarted(info.ID) + } s.goaReportSeen(info) s.goaWorkerChanged(info) if info.State == proto.StateDone || info.Exited && !info.Hibernated { @@ -242,6 +246,13 @@ type goaLine struct { // Soft: it only says to look. It never keeps GOA's turn going, and reading the // session takes it back. Soft bool `json:"soft,omitempty"` + // Turn says it tells of a turn (goa_turnline.go): a report (goaLineReport) or its + // end (goaLineTurn). Those of one turn become one line. + Turn string `json:"turn,omitempty"` + // Closed: its turn is over and told, so nothing more merges into it. + Closed bool `json:"closed,omitempty"` + // HoldUntil keeps a report back for its turn's end to join it. + HoldUntil time.Time `json:"holdUntil,omitzero"` } // goaTellMax bounds what waits for a GOA that never comes back, the oldest going. @@ -256,6 +267,9 @@ func (s *Server) goaTellLine(line goaLine) { return } s.goa.mu.Lock() + if line.Turn == "" && line.Session != "" { + releaseHeld(s.goa.tell, line.Session) // what it says of the session comes first + } s.goa.tell = append(s.goa.tell, line) if over := len(s.goa.tell) - goaTellMax; over > 0 { s.goa.tell = slices.Delete(s.goa.tell, 0, over) @@ -320,16 +334,23 @@ func (s *Server) goaFlushIdle() { // goaLinesToSay takes what waits for GOA, less what its task's end made moot. func (s *Server) goaLinesToSay() []goaLine { + now := time.Now() s.goa.mu.Lock() lines := s.goa.tell s.goa.tell = nil - s.goa.mu.Unlock() + // Of a session, nothing goes past what it holds back; another session's go on. + held := map[string]bool{} var out []goaLine for _, l := range lines { - if l.Task == "" || !s.goa.board.IsSettled(l.Task) { + switch { + case l.HoldUntil.After(now) || held[l.Session] && l.Session != "": + held[l.Session] = true + s.goa.tell = append(s.goa.tell, l) + case l.Task == "" || !s.goa.board.IsSettled(l.Task): out = append(out, l) } } + s.goa.mu.Unlock() return out } diff --git a/daemon/goa_tasks.go b/daemon/goa_tasks.go index d69e4648..5e29c676 100644 --- a/daemon/goa_tasks.go +++ b/daemon/goa_tasks.go @@ -312,9 +312,12 @@ func (s *Server) goaWorkerChanged(info proto.SessionInfo) { head := fmt.Sprintf("[enowX GOA] Task %s %q (%s)", t.ID, t.Title, where) switch status { case proto.GOATaskTurnEnded: - said := s.goaResult(t) + said := nonEmpty(info.TurnText, "") + if said == "" { + said = s.goaResult(t) + } t, _ = s.goa.board.Update(t.ID, func(t *proto.GOATask) { t.Said = said }) - s.goaTellLine(goaLine{Task: t.ID, Session: t.Session, Text: head + " finished a turn, which may be mid-work: " + clip(oneLine(said), goaResultMax) + " Check it (chat " + t.Session + hostFlag + "), then settle it: goa task " + t.ID + " --status done|failed --result \"…\"."}) + s.goaTellTurnEnd(goaLine{Task: t.ID, Session: t.Session, Text: head + " finished a turn, which may be mid-work: " + clip(oneLine(said), goaResultMax) + " Check it (chat " + t.Session + hostFlag + "), then settle it: goa task " + t.ID + " --status done|failed --result \"…\"."}, said) case proto.GOATaskWaiting: ask := s.goaAskOf(t) t, _ = s.goa.board.Update(t.ID, func(t *proto.GOATask) { t.Asks, t.Urgent = askLine(ask), ask.Urgent }) @@ -412,7 +415,7 @@ func (s *Server) goaReportSeen(info proto.SessionInfo) { }) for _, t := range changed { where, hostFlag := goaTaskPlace(t) - s.goaTellLine(goaLine{Task: t.ID, Session: t.Session, Text: fmt.Sprintf("[enowX GOA] Task %s %q (%s) reports %s: %s Check it (chat %s%s), then settle it: goa task %s --status done|failed --result \"…\".", t.ID, t.Title, where, t.Outcome, clip(oneLine(t.Said), goaResultMax), t.Session, hostFlag, t.ID)}) + s.goaTellReport(info, goaLine{Task: t.ID, Session: t.Session, Text: fmt.Sprintf("[enowX GOA] Task %s %q (%s) reports %s: %s Check it (chat %s%s), then settle it: goa task %s --status done|failed --result \"…\".", t.ID, t.Title, where, t.Outcome, clip(oneLine(t.Said), goaResultMax), t.Session, hostFlag, t.ID)}) } if len(changed) > 0 { s.goaChanged() @@ -458,13 +461,24 @@ func (s *Server) goaResult(t proto.GOATask) string { if g, ok := s.goaSession(); ok { from = g.ID } - var tr proto.Transcript - if err := s.goaCall(from, t.Host, proto.MethodSessionTranscript, proto.TranscriptParams{ID: t.Session, Tail: 20}, &tr); err == nil { - for i := len(tr.Entries) - 1; i >= 0; i-- { - if e := tr.Entries[i]; e.Kind == proto.TranscriptAssistant && strings.TrimSpace(e.Text) != "" { - return clip(strings.TrimSpace(e.Text), goaResultMax) + // Never a message from before the turn's end: wait for the transcript to have it. + deadline := time.Now().Add(turnEndWait) + for { + var tr proto.Transcript + err := s.goaCall(from, t.Host, proto.MethodSessionTranscript, proto.TranscriptParams{ID: t.Session, Tail: 20}, &tr) + if err != nil { + break // it has none: its screen says + } + if text, ok := turnEndIn(tr.Entries); ok { + return clip(text, goaResultMax) + } + if time.Now().After(deadline) { + if len(tr.Entries) == 0 { + break } + return "(its last message isn't in its transcript yet: chat " + t.Session + " shows it)" } + time.Sleep(turnEndEvery) } var sc proto.SessionScreen if err := s.goaCall(from, t.Host, proto.MethodSessionScreen, proto.SessionRef{ID: t.Session}, &sc); err != nil { diff --git a/daemon/goa_told.go b/daemon/goa_told.go index 5cae5e0c..c62dd84b 100644 --- a/daemon/goa_told.go +++ b/daemon/goa_told.go @@ -157,6 +157,9 @@ func (s *Server) goaMarkTold(from, host, id string) { // goaToldChanged tells GOA of a session it sent something to, with no task, as it // is told of a worker: its report, its turn's end, what it asks, its end. func (s *Server) goaToldChanged(info proto.SessionInfo) { + if info.State == proto.StateWorking { + s.goaTurnStarted(info.ID) + } s.goa.mu.Lock() was, told := s.goa.told[info.ID] if !told { @@ -185,14 +188,21 @@ func (s *Server) goaToldChanged(info proto.SessionInfo) { s.saveGOATold() t := proto.GOATask{Session: info.ID} if info.ReportAt > was.ReportAt { - s.goaTellAbout(info.ID, fmt.Sprintf("[enowX GOA] Session %s, which you sent something to, reports %s: %s", info.ID, info.ReportOutcome, clip(oneLine(info.Report), goaResultMax))) - if status == was.Status || status == proto.GOATaskTurnEnded { - return // that was its turn's end + s.goaTellReport(info, goaLine{Session: info.ID, Text: fmt.Sprintf("[enowX GOA] Session %s, which you sent something to, reports %s: %s", info.ID, info.ReportOutcome, clip(oneLine(info.Report), goaResultMax))}) + if status == was.Status { + return // what it reports is all there is to say + } + if status != proto.GOATaskTurnEnded { + return // its turn goes on: its end joins the report } } switch status { case proto.GOATaskTurnEnded: - s.goaTellAbout(info.ID, fmt.Sprintf("[enowX GOA] Session %s, which you sent something to, finished a turn: %s", info.ID, clip(oneLine(s.goaResult(t)), goaResultMax))) + said := info.TurnText + if said == "" { + said = s.goaResult(t) + } + s.goaTellTurnEnd(goaLine{Session: info.ID, Text: fmt.Sprintf("[enowX GOA] Session %s, which you sent something to, finished a turn: %s", info.ID, clip(oneLine(said), goaResultMax))}, said) case proto.GOATaskWaiting: s.goaTellAbout(info.ID, fmt.Sprintf("[enowX GOA] Session %s, which you sent something to, asks something: %s", info.ID, clip(oneLine(s.goaResult(t)), goaResultMax))) case proto.GOATaskFailed, proto.GOATaskStopped: diff --git a/daemon/goa_turnline.go b/daemon/goa_turnline.go new file mode 100644 index 00000000..d779c2f6 --- /dev/null +++ b/daemon/goa_turnline.go @@ -0,0 +1,145 @@ +package daemon + +import ( + "slices" + "strings" + "time" + + "github.com/enowx/enowx/proto" +) + +// A worker's turn says two things of itself: its agent's goa report, a tool call made +// before the turn is over, and the turn's end. GOA gets them as one line. A report made +// at work waits for its turn's end, for a while, and the end joins it; a turn's end still +// waiting for GOA takes a report that comes after it. Lines never change places, a +// question or a failure is never merged, and a later turn has its own line. + +const ( + goaLineReport = "report" + goaLineTurn = "turn" + // goaReportHold is how long a report waits for its turn's end: past it, it goes alone, + // as a turn may go on long after, or never be heard to end. + goaReportHold = 30 * time.Second +) + +// releaseHeld lets what lines hold back of session go. The caller holds goa.mu. +func releaseHeld(lines []goaLine, session string) { + for i := range lines { + if lines[i].Session == session { + lines[i].HoldUntil = time.Time{} + } + } +} + +// goaTellReport tells of a report of session info, as line: joined to its turn's end if +// that waits for GOA, else held for it if the session is at work. +func (s *Server) goaTellReport(info proto.SessionInfo, line goaLine) { + line.Turn = goaLineReport + s.goa.mu.Lock() + i := s.goaOpenLine(line, goaLineTurn) + if i >= 0 { + said := s.goa.tell[i].Text + s.goa.tell[i].Text, s.goa.tell[i].Turn, s.goa.tell[i].Closed = line.Text+lastMessageOf(said), goaLineTurn, true + } + s.goa.mu.Unlock() + if i >= 0 { + s.goaLinesChanged() + return + } + if info.State == proto.StateWorking && !info.StateStale { + line.HoldUntil = time.Now().Add(goaReportHold) + } else { + line.Closed = true + } + s.goaTellLine(line) +} + +// goaTellTurnEnd tells of a turn's end, as line, saying said last: into its report if +// that waits for GOA. A report already gone leaves it a line of its own. +func (s *Server) goaTellTurnEnd(line goaLine, said string) { + line.Turn = goaLineTurn + s.goa.mu.Lock() + i := s.goaOpenLine(line, goaLineReport) + if i >= 0 { + l := &s.goa.tell[i] + l.HoldUntil, l.Closed = time.Time{}, true + if text := strings.TrimSpace(said); text != "" && !strings.Contains(oneLine(l.Text), oneLine(text)) { + l.Text += turnEndMark + oneLine(text) + } + } + s.goa.mu.Unlock() + if i >= 0 { + s.goaLinesChanged() + return + } + s.goaTellLine(line) +} + +// turnEndMark comes before the turn's last message, in a line that holds its report. +const turnEndMark = " Its turn ended on: " + +// lastMessageOf is a turn's end line as what follows its report in one line. +func lastMessageOf(line string) string { + if _, said, ok := strings.Cut(line, ": "); ok { + return turnEndMark + said + } + return "" +} + +// goaOpenLine is the last line waiting for GOA of line's session and task, of kind turn, +// still open to joining; -1 for none. The caller holds goa.mu. +func (s *Server) goaOpenLine(line goaLine, turn string) int { + return slices.IndexFunc(s.goa.tell, func(l goaLine) bool { + return l.Turn == turn && !l.Closed && l.Session == line.Session && l.Task == line.Task + }) +} + +// goaTurnStarted closes what waits of session's turns: what comes now is of another. +func (s *Server) goaTurnStarted(session string) { + s.goa.mu.Lock() + changed := false + for i := range s.goa.tell { + if l := &s.goa.tell[i]; l.Session == session && l.Turn == goaLineTurn && !l.Closed { + l.Closed, changed = true, true + } + } + s.goa.mu.Unlock() + if changed { + s.saveGOATell() + } +} + +// goaLinesChanged saves what waits, and gives it to an idle GOA. +func (s *Server) goaLinesChanged() { + s.saveGOATell() + if g, ok := s.goaSession(); ok && !g.Exited && g.State != proto.StateWorking { + s.goaFlush(g.ID) + } + s.goaChanged() +} + +// How long a turn's last message may take to reach its transcript, for an agent whose +// hook doesn't say it. Claude Code's transcript was seen behind its Stop hook by over a +// second (2.1.318). +const ( + turnEndWait = 6 * time.Second + turnEndEvery = 400 * time.Millisecond +) + +// turnEndIn is the message transcript entries end on, once it holds the turn's end: the +// last there is but thinking and notices. What follows a tool's call or a prompt isn't +// written yet, and a message before them isn't the turn's last. +func turnEndIn(entries []proto.TranscriptEntry) (string, bool) { + for i := len(entries) - 1; i >= 0; i-- { + switch e := entries[i]; e.Kind { + case proto.TranscriptThinking, proto.TranscriptNotice: + case proto.TranscriptAssistant: + if text := strings.TrimSpace(e.Text); text != "" { + return text, true + } + default: + return "", false + } + } + return "", false +} diff --git a/daemon/goa_turnline_test.go b/daemon/goa_turnline_test.go new file mode 100644 index 00000000..7894be85 --- /dev/null +++ b/daemon/goa_turnline_test.go @@ -0,0 +1,201 @@ +package daemon + +import ( + "strings" + "testing" + "time" + + "github.com/enowx/enowx/core/session" + "github.com/enowx/enowx/proto" +) + +// turnFixture is a server with a busy GOA, so lines wait to be read, and a worker's +// session with task id's. +func turnFixture(t *testing.T) (*Server, *session.Session, proto.GOATask) { + t.Helper() + srv := startServer(t) + g, err := srv.sessions.Create(session.Spec{Command: []string{"sh", "-c", "cat"}, Dir: srv.goaDir(), Cols: 80, Rows: 24, Meta: session.Meta{Agent: "codex", GOA: true}}) + if err != nil { + t.Fatal(err) + } + if err := srv.sessions.SetState(g, proto.StateWorking); err != nil { + t.Fatal(err) + } + w, err := srv.sessions.Create(session.Spec{Command: []string{"sh", "-c", "cat"}, Dir: t.TempDir(), Cols: 80, Rows: 24, Meta: session.Meta{Agent: "claude"}}) + if err != nil { + t.Fatal(err) + } + task, err := srv.goa.board.Add(proto.GOATask{Title: "Totals", Session: w.ID(), Status: proto.GOATaskWorking}) + if err != nil { + t.Fatal(err) + } + return srv, w, task +} + +func waitingOf(srv *Server) []string { return srv.goaWaitingLines() } + +func report(srv *Server, w *session.Session, state, text string, at int64) { + srv.goaReportSeen(proto.SessionInfo{ID: w.ID(), State: state, Report: text, ReportOutcome: "done", ReportAt: at}) +} + +func turnDone(t *testing.T, srv *Server, w *session.Session, said string) { + t.Helper() + time.Sleep(goaTurnLeast) + srv.sessions.NoteTurnText(w, said) + if err := srv.sessions.SetState(w, proto.StateDone); err != nil { + t.Fatal(err) + } + srv.goaWorkerChanged(w.Info()) +} + +func oneLineWith(t *testing.T, lines []string, want ...string) { + t.Helper() + if len(lines) != 1 { + t.Fatalf("%d lines: %q", len(lines), lines) + } + for _, w := range want { + if !strings.Contains(lines[0], w) { + t.Errorf("no %q in %q", w, lines[0]) + } + } +} + +// The usual order: it reports as its last act, then its turn ends. One line, with its +// real last message; held meanwhile, not said alone. +func TestReportThenTurnEndIsOneLine(t *testing.T) { + srv, w, _ := turnFixture(t) + report(srv, w, proto.StateWorking, "All done.", 1) + if said := srv.goaLinesToSay(); len(said) != 0 { + t.Fatalf("said before its turn ended: %+v", said) + } + turnDone(t, srv, w, "FINAL message") + oneLineWith(t, waitingOf(srv), "reports done: All done.", "FINAL message") + if said := srv.goaLinesToSay(); len(said) != 1 { + t.Fatalf("not free to go: %+v", said) + } +} + +// Its turn's end first, as another host's updates can come: one line, while it waits. +func TestTurnEndThenReportIsOneLine(t *testing.T) { + srv, w, _ := turnFixture(t) + turnDone(t, srv, w, "FINAL message") + report(srv, w, proto.StateDone, "All done.", 1) + oneLineWith(t, waitingOf(srv), "reports done: All done.", "FINAL message") +} + +// A report saying what the message says doesn't say it twice. +func TestSameTextIsSaidOnce(t *testing.T) { + srv, w, _ := turnFixture(t) + report(srv, w, proto.StateWorking, "All done.", 1) + turnDone(t, srv, w, "All done.") + lines := waitingOf(srv) + if len(lines) != 1 || strings.Count(lines[0], "All done.") != 1 { + t.Fatalf("%q", lines) + } +} + +// The turn's last message is the transcript's only once it ends on one: what comes +// before a tool's call, or a later prompt, is older, and waits. +func TestTurnEndInTranscript(t *testing.T) { + a := func(text string) proto.TranscriptEntry { + return proto.TranscriptEntry{Kind: proto.TranscriptAssistant, Text: text} + } + tool := proto.TranscriptEntry{Kind: proto.TranscriptTool} + user := proto.TranscriptEntry{Kind: proto.TranscriptUser} + thinking := proto.TranscriptEntry{Kind: proto.TranscriptThinking} + for name, c := range map[string]struct { + entries []proto.TranscriptEntry + want string + ok bool + }{ + "written": {[]proto.TranscriptEntry{user, a("interim"), tool, a("final")}, "final", true}, + "thinking after": {[]proto.TranscriptEntry{a("final"), thinking}, "final", true}, + "late: after a tool": {[]proto.TranscriptEntry{user, a("interim"), tool}, "", false}, + "late: only a prompt": {[]proto.TranscriptEntry{a("the turn before's"), user}, "", false}, + "none yet": {nil, "", false}, + } { + if got, ok := turnEndIn(c.entries); got != c.want || ok != c.ok { + t.Errorf("%s: %q, %v", name, got, ok) + } + } +} + +// A transcript written late doesn't give an old message: the hook's says, for a session +// whose agent gives it, whatever the transcript holds. +func TestHookTextGoesPastALateTranscript(t *testing.T) { + srv, w := hookServer(t) + t.Setenv(session.EnvSession, w.ID()) + if _, _, _ = hookOf(`{"last_assistant_message":"FINAL message"}`, "--event", proto.HookTurnDone); w.Info().TurnText != "FINAL message" { + t.Fatalf("%q", w.Info().TurnText) + } + if _, _, _ = hookOf(`{"prompt":"next"}`, "--event", proto.HookPromptStart); w.Info().TurnText != "" { + t.Fatalf("still %q as a turn started", w.Info().TurnText) + } + _ = srv +} + +// While GOA is busy, each real turn keeps its own line; the same turn's report joins. +func TestTwoRealTurnsHaveTwoLines(t *testing.T) { + srv, w, _ := turnFixture(t) + report(srv, w, proto.StateWorking, "First done.", 1) + turnDone(t, srv, w, "first message") + srv.goaTurnStarted(w.ID()) + _, _ = srv.goa.board.Update(srv.goa.board.List()[0].ID, func(x *proto.GOATask) { x.Status = proto.GOATaskWorking }) + report(srv, w, proto.StateWorking, "Second done.", 2) + turnDone(t, srv, w, "second message") + lines := waitingOf(srv) + if len(lines) != 2 || !strings.Contains(lines[0], "first message") || !strings.Contains(lines[1], "second message") { + t.Fatalf("%q", lines) + } +} + +// A session with no task goes the same way. +func TestSessionWithNoTaskIsOneLine(t *testing.T) { + srv := startServer(t) + g, err := srv.sessions.Create(session.Spec{Command: []string{"sh", "-c", "cat"}, Dir: srv.goaDir(), Cols: 80, Rows: 24, Meta: session.Meta{Agent: "codex", GOA: true}}) + if err != nil { + t.Fatal(err) + } + if err := srv.sessions.SetState(g, proto.StateWorking); err != nil { + t.Fatal(err) + } + const id = "worker-no-task" + srv.goaMarkTold(g.ID(), "", id) + at := time.Now().UnixMilli() + 1000 // after it was sent something + srv.goaToldChanged(proto.SessionInfo{ID: id, State: proto.StateWorking, Report: "All done.", ReportOutcome: "done", ReportAt: at}) + srv.goaToldChanged(proto.SessionInfo{ID: id, State: proto.StateDone, TurnText: "FINAL message", Report: "All done.", ReportOutcome: "done", ReportAt: at}) + oneLineWith(t, waitingOf(srv), "reports done: All done.", "FINAL message") +} + +// A report goes alone if its turn's end is long in coming, and a question or failure is +// never merged: it comes after what was held, in order. +func TestHeldReportIsNotLost(t *testing.T) { + srv, w, _ := turnFixture(t) + report(srv, w, proto.StateWorking, "All done.", 1) + srv.goa.mu.Lock() + srv.goa.tell[0].HoldUntil = time.Now().Add(-time.Second) + srv.goa.mu.Unlock() + if said := srv.goaLinesToSay(); len(said) != 1 || !strings.Contains(said[0].Text, "All done.") { + t.Fatalf("%+v", said) + } + report(srv, w, proto.StateWorking, "Second.", 2) + srv.goaTellLine(goaLine{Session: w.ID(), Text: "[enowX GOA] it asks something"}) + said := srv.goaLinesToSay() + if len(said) != 2 || !strings.Contains(said[0].Text, "Second.") || !strings.Contains(said[1].Text, "asks something") { + t.Fatalf("%+v", said) + } +} + +// A line held back for one session doesn't hold another's. +func TestHoldIsPerSession(t *testing.T) { + srv, w, _ := turnFixture(t) + report(srv, w, proto.StateWorking, "All done.", 1) + srv.goaTellLine(goaLine{Session: "another", Text: "[enowX GOA] another's"}) + said := srv.goaLinesToSay() + if len(said) != 1 || !strings.Contains(said[0].Text, "another's") { + t.Fatalf("%+v", said) + } + if lines := waitingOf(srv); len(lines) != 1 || !strings.Contains(lines[0], "All done.") { + t.Fatalf("%q", lines) + } +} diff --git a/daemon/hook.go b/daemon/hook.go index 1d03d495..fb5b5af2 100644 --- a/daemon/hook.go +++ b/daemon/hook.go @@ -133,9 +133,10 @@ func agentEvent(event string, data []byte) proto.HookEvent { Error string `json:"error"` Cwd string `json:"cwd"` Mode string `json:"permission_mode"` + Last string `json:"last_assistant_message"` } _ = json.Unmarshal(data, &in) - ev := proto.HookEvent{Event: event, Prompt: in.Prompt, Tool: in.Tool, PermissionMode: in.Mode, Failed: in.Event == "PostToolUseFailure" || in.Error != ""} + ev := proto.HookEvent{Event: event, Prompt: in.Prompt, Tool: in.Tool, PermissionMode: in.Mode, LastMessage: in.Last, Failed: in.Event == "PostToolUseFailure" || in.Error != ""} var input map[string]any if json.Unmarshal(in.Input, &input) == nil { for _, k := range []string{"command", "cmd"} { diff --git a/daemon/rpc_goa.go b/daemon/rpc_goa.go index f5788ab2..be34f04e 100644 --- a/daemon/rpc_goa.go +++ b/daemon/rpc_goa.go @@ -18,6 +18,9 @@ var goaHandlers = map[string]handler{ proto.MethodGOAWorker: with(func(c *conn, p proto.GOAWorkerParams) (any, error) { if c.srv.goa.board.Has(p.Session.ID) { go func() { + if p.Session.State == proto.StateWorking { + c.srv.goaTurnStarted(p.Session.ID) + } c.srv.goaReportSeen(p.Session) c.srv.goaWorkerChanged(p.Session) }() diff --git a/daemon/rpc_hooks.go b/daemon/rpc_hooks.go index 6c235432..a00b5659 100644 --- a/daemon/rpc_hooks.go +++ b/daemon/rpc_hooks.go @@ -43,6 +43,12 @@ func (s *Server) sessionHook(p proto.SessionHookParams) (proto.HookDecision, err if err != nil { return proto.HookDecision{}, err } + switch p.Event.Event { + case proto.HookTurnDone: + s.sessions.NoteTurnText(sess, s.maskLent(p.Event.LastMessage)) + case proto.HookPromptStart: + s.sessions.NoteTurnText(sess, "") + } if p.State != "" { if err := s.sessions.SetState(sess, p.State); err != nil { return proto.HookDecision{}, err diff --git a/docs/goa.md b/docs/goa.md index abfe669e..821de5ee 100644 --- a/docs/goa.md +++ b/docs/goa.md @@ -67,6 +67,10 @@ Routes, in order: 2. `goa report --outcome done|partly|nothing|blocked|refused|failed "what happened"`, which every task's prompt asks for at the end of each turn. The report stays on the session, on its own host, and reaches GOA as the session's changes do, through the app for another host's. It goes on the task as `Outcome`. 3. For an agent with no hooks, one started by hand in a shell tab, and one whose hooks fail: the screen. A session no hook speaks for, still for 20 seconds, using under 20% of a core (an agent at rest isn't at 0), showing a turn's end (the time it took, or a prompt mark alone on its line) raises a report, not "isn't moving". Only what follows that end can say otherwise: the turn's own words above it and a footer's under it don't. A screen only quiet, with no such sign, stays a stall line. A session GOA sent something to with no task is watched the same way, on any host: it reports its turn's end, says once if it goes quiet for the idle limit with no sign why, and says if it is gone. A shell with no agent, back at its prompt, has finished: one line, once. Those two lines only say to look: they never keep GOA's turn going, and reading the session takes them back. Whatever a line says, a turn's end is kept going only once for one session, until GOA reads it or sends it something; further lines wait for its next tool call or its idle moment. +**One line per turn.** A turn's report and its end reach GOA as one line. A report made while the agent works waits up to 30 seconds for its turn's end, and that end joins it. A turn's end still waiting for GOA takes a report that comes after it. A report whose moment has passed goes alone, and the turn's end then has its own line. Lines of one session never change order; a question, a failure or a correction is never merged and comes after what was held. Each real turn has its own line, also when several wait for a busy GOA. + +**The message is the turn's real last one.** Where the agent's turn-end hook says it (`last_assistant_message`: seen on Claude Code 2.1.318), that is the text, which goes along with the session's updates (`SessionInfo.TurnText`), so it reaches GOA on another host too. Otherwise the transcript is read until it holds the turn's end: its last entry is a message, not a tool's call. Claude Code's transcript was seen over a second behind its Stop hook. Six seconds on, the line says the message isn't recorded yet and where to read it, rather than give an older one. + What the session doesn't say, enowX says for it: its process exiting or crashing, with the exit code and its last words, marked `NoReport` on the board and never shown as at work; a turn interrupted; a limit its agent showed; a lost sign-in, as what it asks; going silent, as a stall line; a host that can't be reached, once, after the idle limit. A line stays until it has gone in: kept in `goa-tell.json` while GOA is busy, hibernated or stopped and over a daemon's restart, put back if typing it fails, the newest 200 kept. Only a stall line of a task since settled is dropped, as moot; a report isn't. With no GOA on the host at all there is no one to tell. diff --git a/proto/hooks.go b/proto/hooks.go index 080e8d03..524bf2bd 100644 --- a/proto/hooks.go +++ b/proto/hooks.go @@ -190,6 +190,8 @@ type HookEvent struct { // PermissionMode is the permission mode the agent's hook reports it in // (permission_mode: Claude Code's, Codex's), "" when it says none. PermissionMode string `json:"permissionMode,omitempty"` + // LastMessage is the turn's last message, from an agent whose turn-end hook says it. + LastMessage string `json:"lastMessage,omitempty"` // Via is how it was seen (HookInAgent, HookOutside). Via string `json:"via,omitempty"` Time time.Time `json:"time"` diff --git a/proto/session.go b/proto/session.go index 788ab7ee..ecbe8bba 100644 --- a/proto/session.go +++ b/proto/session.go @@ -176,7 +176,9 @@ type SessionInfo struct { // Report is what its agent last reported of a turn (goa report), Outcome one of // GOAOutcomes; ReportAt, in Unix milliseconds, tells one from the next. On the // session, it reaches GOA by the road its changes take, on any host. - Report string `json:"report,omitempty"` + Report string `json:"report,omitempty"` + // TurnText is the message its last turn ended on, when its agent's hook says. + TurnText string `json:"turnText,omitempty"` ReportOutcome string `json:"reportOutcome,omitempty"` ReportAt int64 `json:"reportAt,omitempty"` // Group is the fan-out the session is part of: sessions given one diff --git a/proto/testdata/api.txt b/proto/testdata/api.txt index 1393d616..94221367 100644 --- a/proto/testdata/api.txt +++ b/proto/testdata/api.txt @@ -1315,6 +1315,7 @@ field HookEvent.exit int field HookEvent.failed bool field HookEvent.host string field HookEvent.kind string +field HookEvent.lastMessage string field HookEvent.message string field HookEvent.op string field HookEvent.paths []string @@ -2015,6 +2016,7 @@ field SessionInfo.skipUnheard bool field SessionInfo.state string field SessionInfo.stateStale bool field SessionInfo.title string +field SessionInfo.turnText string field SessionInfo.worked int64 field SessionInfo.worktree bool field SessionInputParams.enter bool