Skip to content

Commit a2be937

Browse files
committed
fix: agent message flickering
1 parent faabad7 commit a2be937

File tree

3 files changed

+88
-26
lines changed

3 files changed

+88
-26
lines changed

lib/screentracker/conversation.go

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,17 @@ func (c *Conversation) StartSnapshotLoop(ctx context.Context) {
117117
case <-ctx.Done():
118118
return
119119
case <-time.After(c.cfg.SnapshotInterval):
120-
c.AddSnapshot(c.cfg.AgentIO.ReadScreen())
120+
// It's important that we hold the lock while reading the screen.
121+
// There's a race condition that occurs without it:
122+
// 1. The screen is read
123+
// 2. Independently, SendMessage is called and takes the lock.
124+
// 3. AddSnapshot is called and waits on the lock.
125+
// 4. SendMessage modifies the terminal state, releases the lock
126+
// 5. AddSnapshot adds a snapshot from a stale screen
127+
c.lock.Lock()
128+
screen := c.cfg.AgentIO.ReadScreen()
129+
c.addSnapshotInner(screen)
130+
c.lock.Unlock()
121131
}
122132
}
123133
}()
@@ -191,32 +201,21 @@ func (c *Conversation) updateLastAgentMessage(screen string, timestamp time.Time
191201
c.messages[len(c.messages)-1].Id = len(c.messages) - 1
192202
}
193203

194-
// This is a temporary hack to work around a bug in Claude Code 0.2.70.
195-
// https://github.com/anthropics/claude-code/issues/803
196-
// 0.2.71 should not need it anymore. We will remove it a couple of days
197-
// after the new version is released.
198-
func removeDuplicateClaude_0_2_70_Output(screen string) string {
199-
// this hack will only work if the terminal emulator is exactly 80 characters wide
200-
// this is hard-coded right now in the termexec package
201-
idx := strings.LastIndex(screen, "╭────────────────────────────────────────────╮ \n│ ✻ Welcome to Claude Code research preview! │")
202-
if idx == -1 {
203-
return screen
204+
// assumes the caller holds the lock
205+
func (c *Conversation) addSnapshotInner(screen string) {
206+
snapshot := screenSnapshot{
207+
timestamp: c.cfg.GetTime(),
208+
screen: screen,
204209
}
205-
return screen[idx:]
210+
c.snapshotBuffer.Add(snapshot)
211+
c.updateLastAgentMessage(screen, snapshot.timestamp)
206212
}
207213

208214
func (c *Conversation) AddSnapshot(screen string) {
209215
c.lock.Lock()
210216
defer c.lock.Unlock()
211217

212-
screen = removeDuplicateClaude_0_2_70_Output(screen)
213-
214-
snapshot := screenSnapshot{
215-
timestamp: c.cfg.GetTime(),
216-
screen: screen,
217-
}
218-
c.snapshotBuffer.Add(snapshot)
219-
c.updateLastAgentMessage(screen, snapshot.timestamp)
218+
c.addSnapshotInner(screen)
220219
}
221220

222221
type MessagePart interface {

lib/termexec/termexec.go

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,21 @@ import (
77
"log/slog"
88
"os"
99
"os/exec"
10+
"sync"
1011
"syscall"
1112
"time"
1213

1314
"github.com/ActiveState/termtest/xpty"
1415
"github.com/coder/agentapi/lib/logctx"
16+
"github.com/coder/agentapi/lib/util"
1517
"golang.org/x/xerrors"
1618
)
1719

1820
type Process struct {
19-
xp *xpty.Xpty
20-
execCmd *exec.Cmd
21+
xp *xpty.Xpty
22+
execCmd *exec.Cmd
23+
screenUpdateLock sync.RWMutex
24+
lastScreenUpdate time.Time
2125
}
2226

2327
type StartProcessConfig struct {
@@ -42,11 +46,34 @@ func StartProcess(ctx context.Context, args StartProcessConfig) (*Process, error
4246
return nil, err
4347
}
4448

49+
process := &Process{xp: xp, execCmd: execCmd}
50+
4551
go func() {
52+
// This is a hack to work around a concurrency issue in the xpty
53+
// library. The only way the xpty library allows the user to update
54+
// the terminal state is to call xp.ReadRune. Ideally, we'd just use it here.
55+
// However, we need to atomically update the terminal state and set p.lastScreenUpdate.
56+
// p.ReadScreen depends on it.
57+
// xp.ReadRune has a bug which makes it impossible to use xp.SetReadDeadline -
58+
// ReadRune panics if the deadline is set. So xp.ReadRune will block until the
59+
// underlying process produces new output.
60+
// So if we naively wrapped ReadRune and lastScreenUpdate in a mutex,
61+
// we'd have to wait for the underlying process to produce new output.
62+
// And that would block p.ReadScreen. That's no good.
63+
//
64+
// Internally, xp.ReadRune calls pp.ReadRune, which is what's doing the waiting,
65+
// and then xp.Term.WriteRune, which is what's updating the terminal state.
66+
// Below, we do the same things xp.ReadRune does, but we wrap only the terminal
67+
// state update in a mutex. As a result, p.ReadScreen is not blocked.
68+
//
69+
// It depends on the implementation details of the xpty library, and is prone
70+
// to break if xpty is updated.
71+
// The proper way to fix it would be to fork xpty and make changes there, but
72+
// I don't want to maintain a fork now.
73+
pp := util.GetUnexportedField(xp, "pp").(*xpty.PassthroughPipe)
4674
for {
47-
// calling ReadRune updates the terminal state. without it,
48-
// xp.State will always return an empty string
49-
if _, _, err := xp.ReadRune(); err != nil {
75+
r, _, err := pp.ReadRune()
76+
if err != nil {
5077
if err != io.EOF {
5178
logger.Error("Error reading from pseudo terminal", "error", err)
5279
}
@@ -55,18 +82,42 @@ func StartProcess(ctx context.Context, args StartProcessConfig) (*Process, error
5582
// unresponsive.
5683
return
5784
}
85+
process.screenUpdateLock.Lock()
86+
// writing to the terminal updates its state. without it,
87+
// xp.State will always return an empty string
88+
xp.Term.WriteRune(r)
89+
process.lastScreenUpdate = time.Now()
90+
process.screenUpdateLock.Unlock()
5891
}
5992
}()
6093

61-
return &Process{xp: xp, execCmd: execCmd}, nil
94+
return process, nil
6295
}
6396

6497
func (p *Process) Signal(sig os.Signal) error {
6598
return p.execCmd.Process.Signal(sig)
6699
}
67100

68101
// ReadScreen returns the contents of the terminal window.
102+
// It waits for the terminal to be stable for 16ms before
103+
// returning, or 48 ms since it's called, whichever is sooner.
104+
//
105+
// This logic acts as a kind of vsync. Agents regularly redraw
106+
// parts of the screen. If we naively snapshotted the screen,
107+
// we'd often capture it while it's being updated. This would
108+
// result in a malformed agent message being returned to the
109+
// user.
69110
func (p *Process) ReadScreen() string {
111+
for range 3 {
112+
p.screenUpdateLock.RLock()
113+
if time.Since(p.lastScreenUpdate) >= 16*time.Millisecond {
114+
state := p.xp.State.String()
115+
p.screenUpdateLock.RUnlock()
116+
return state
117+
}
118+
p.screenUpdateLock.RUnlock()
119+
time.Sleep(16 * time.Millisecond)
120+
}
70121
return p.xp.State.String()
71122
}
72123

lib/util/unsafe.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package util
2+
3+
import (
4+
"reflect"
5+
"unsafe"
6+
)
7+
8+
// Based on https://stackoverflow.com/a/60598827
9+
func GetUnexportedField[T any](obj *T, fieldName string) any {
10+
field := reflect.ValueOf(obj).Elem().FieldByName(fieldName)
11+
return reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Interface()
12+
}

0 commit comments

Comments
 (0)