Files
pad/internal/cli/chat_seen_test.go
xarmian ffecfc744b feat(chat): show chat delivered to an agent to the human at its terminal (TASK-4071) (#2312)
* feat(chat): tee monitor chat lines to a per-session chat-seen log (TASK-4071)

pad watch --stream --for-session appends every chat line it prints to
~/.pad/chat-seen/<harness-pid>.log (0600 in a 0700 dir, O_NOFOLLOW),
capped at 256 KiB with one .1 generation, so the human at the agent's
terminal can see it. No log when the pid is pad watch's own; pruning a
session record removes its log.

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg

* test(chat): pin the chat-seen tee, perms, rotation, prune and C2 (TASK-4071)

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg

* test(chat): refuse a FIFO that has a reader (TASK-4071 M13)

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg

* feat(plugin): show chat delivered to the agent in a band and a toast (TASK-4071)

The pad mod tails ~/.pad/chat-seen/<harness pid>.log from the session's
first use of Pad, starting at the end of the file, and shows new lines in
a band above the prompt (the last three, until the person's next prompt)
and one toast per burst. Lines rotated away between polls are not shown.

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg

* refactor(plugin): one rotation guard in the chat-seen reader (TASK-4071 P5)

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg

* fix(chat): report a log that is off on its first line, and say what prune and the monitor do with it (TASK-4071)

The monitor prints nothing before it has something to deliver (DOC-2479),
so the no-harness-pid line waits for the first chat line. The pad watch
--stream --for-session and pad session prune help now describe the log.

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg

* fix(chat): r1 findings on the chat-seen log and reader (TASK-4071)

- Refuse a symlinked chat-seen directory instead of chmodding its target.
- Reopen a log that was removed under the writer.
- Remove .log.1 before rotating: Rename does not replace on Windows.
- Reader: drop control characters and direction overrides before they
  reach the terminal; clip by code points; one toast per burst on the
  relay's clock; a stop during start leaves nothing running; a failed
  first read no longer replays the log.

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg

* fix(plugin): r2 findings on the chat-seen reader (TASK-4071)

- The first look skips what the log held at its stat, so a line written
  between that stat and the read still shows.
- A stop during a poll no longer leaves the reader stuck after the next wake.
- An unreadable moment (failed stat, a symlink) changes nothing, so it
  cannot cause a replay.
- U+061C joins the stripped bidi controls.

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg

* fix(plugin): r3 findings on the chat-seen reader (TASK-4071)

- Start a log at zero only when the listing shows it absent; a first look
  that merely failed leaves the start to the first good read (R3-1).
- Restore the byte-size rotation reset beside the character one: a log of
  multi-byte text can shrink in one while it grows in the other (R3-2).

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg
2026-10-25 07:17:51 -04:00

307 lines
9.4 KiB
Go

package cli
import (
"bytes"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"time"
)
// TASK-4071: the chat-seen log a plugin tails to show chat to the human.
func chatSeenOwner(pid int) SessionOwner {
return SessionOwner{PID: pid, PIDSource: "PAD_SESSION_PID"}
}
func readChatSeen(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func TestChatSeenAppendsLinesPrivately(t *testing.T) {
t.Setenv("HOME", t.TempDir())
var warn bytes.Buffer
l := OpenChatSeen(chatSeenOwner(4242), nil, &warn)
path := l.Path()
if want := filepath.Join(os.Getenv("HOME"), ".pad", "chat-seen", "4242.log"); path != want {
t.Fatalf("path %q, want %q", path, want)
}
if _, err := os.Stat(filepath.Dir(path)); !os.IsNotExist(err) {
t.Fatalf("no file or dir before the first line; stat err = %v", err)
}
l.Append("one")
l.Append("two")
if got := readChatSeen(t, path); got != "one\ntwo\n" || warn.Len() != 0 {
t.Fatalf("log %q warn %q", got, warn.String())
}
if runtime.GOOS == "windows" {
return
}
if info, _ := os.Stat(filepath.Dir(path)); info.Mode().Perm() != 0700 {
t.Fatalf("dir mode %v", info.Mode().Perm())
}
if info, _ := os.Stat(path); info.Mode().Perm() != 0600 {
t.Fatalf("file mode %v", info.Mode().Perm())
}
}
func TestChatSeenTightensLooseDirAndFile(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX modes")
}
t.Setenv("HOME", t.TempDir())
path, _ := ChatSeenPath(7)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatal(err)
}
if err := os.Chmod(filepath.Dir(path), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("old\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Chmod(path, 0644); err != nil {
t.Fatal(err)
}
var warn bytes.Buffer
OpenChatSeen(chatSeenOwner(7), nil, &warn).Append("new")
if got := readChatSeen(t, path); got != "old\nnew\n" || warn.Len() != 0 {
t.Fatalf("log %q warn %q", got, warn.String())
}
if info, _ := os.Stat(filepath.Dir(path)); info.Mode().Perm() != 0700 {
t.Fatalf("dir mode %v, want 0700", info.Mode().Perm())
}
if info, _ := os.Stat(path); info.Mode().Perm() != 0600 {
t.Fatalf("file mode %v, want 0600", info.Mode().Perm())
}
}
func TestChatSeenRefusesSymlinkAndStops(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlinks")
}
t.Setenv("HOME", t.TempDir())
path, _ := ChatSeenPath(8)
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
t.Fatal(err)
}
target := filepath.Join(t.TempDir(), "target")
if err := os.WriteFile(target, nil, 0600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, path); err != nil {
t.Fatal(err)
}
var warn bytes.Buffer
l := OpenChatSeen(chatSeenOwner(8), nil, &warn)
l.Append("a")
l.Append("b")
if got := readChatSeen(t, target); got != "" {
t.Fatalf("wrote through a symlink: %q", got)
}
if strings.Count(warn.String(), "\n") != 1 || !strings.Contains(warn.String(), "stopped showing chat lines") {
t.Fatalf("want exactly one warning, got %q", warn.String())
}
}
func TestChatSeenRefusesFifoWithoutHanging(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("FIFOs")
}
t.Setenv("HOME", t.TempDir())
path, _ := ChatSeenPath(9)
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
t.Fatal(err)
}
if err := mkfifoForTest(path); err != nil {
t.Skip("mkfifo:", err)
}
var warn bytes.Buffer
done := make(chan struct{})
go func() {
OpenChatSeen(chatSeenOwner(9), nil, &warn).Append("x")
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("Append blocked opening a FIFO")
}
if !strings.Contains(warn.String(), "stopped showing chat lines") {
t.Fatalf("warn %q", warn.String())
}
}
func TestChatSeenRotatesOneGeneration(t *testing.T) {
t.Setenv("HOME", t.TempDir())
var warn bytes.Buffer
l := OpenChatSeen(chatSeenOwner(10), nil, &warn)
l.max = 10
for _, s := range []string{"aaaa", "bbbb", "cccc", "dddd", "eeee"} {
l.Append(s) // 5 bytes each: two fit in one generation
}
if got := readChatSeen(t, l.Path()); got != "eeee\n" {
t.Fatalf("current %q", got)
}
if got := readChatSeen(t, l.Path()+".1"); got != "cccc\ndddd\n" {
t.Fatalf("previous generation %q", got)
}
if warn.Len() != 0 {
t.Fatal(warn.String())
}
}
// A line longer than the cap still lands; it is not rotated against itself.
func TestChatSeenOversizeLineOnEmptyLog(t *testing.T) {
t.Setenv("HOME", t.TempDir())
var warn bytes.Buffer
l := OpenChatSeen(chatSeenOwner(11), nil, &warn)
l.max = 4
l.Append("longer than four")
if got := readChatSeen(t, l.Path()); got != "longer than four\n" {
t.Fatalf("current %q", got)
}
if _, err := os.Stat(l.Path() + ".1"); !os.IsNotExist(err) {
t.Fatalf("no rotation of an empty log; stat err = %v", err)
}
}
// C2: a pid from os.Getpid() keys nothing a reader or a prune can find.
func TestChatSeenOffWithoutHarnessPid(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("PAD_SESSION_PID", "")
t.Setenv("CLAUDE_PID", "")
owner, err := CaptureSessionOwner()
if err != nil || owner.PIDSource != "self" {
t.Fatalf("setup: %+v %v", owner, err)
}
var warn bytes.Buffer
l := OpenChatSeen(owner, err, &warn)
if l.Path() != "" || warn.Len() != 0 {
t.Fatalf("want no log and a silent start for a self pid, got %q, warn %q", l.Path(), warn.String())
}
l.Append("x")
l.Append("y")
if strings.Count(warn.String(), "\n") != 1 || !strings.Contains(warn.String(), "PAD_SESSION_PID") {
t.Fatalf("want one stderr line naming PAD_SESSION_PID, got %q", warn.String())
}
if _, err := os.Stat(filepath.Join(os.Getenv("HOME"), ".pad", "chat-seen")); !os.IsNotExist(err) {
t.Fatalf("nothing may be created; stat err = %v", err)
}
}
func TestChatSeenOffOnInvalidSessionPid(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("PAD_SESSION_PID", "nope")
owner, err := CaptureSessionOwner()
var warn bytes.Buffer
l := OpenChatSeen(owner, err, &warn)
l.Append("x")
if l.Path() != "" || !strings.Contains(warn.String(), "PAD_SESSION_PID") {
t.Fatalf("log %q warn %q", l.Path(), warn.String())
}
}
// C1's pin: the mod passes its sessionPid() as PAD_SESSION_PID, and that
// keys the log ahead of CLAUDE_PID, so the reader and writer agree.
func TestChatSeenKeyedByPadSessionPidFirst(t *testing.T) {
t.Setenv("HOME", t.TempDir())
ppid := os.Getppid()
t.Setenv("PAD_SESSION_PID", strconv.Itoa(ppid))
t.Setenv("CLAUDE_PID", "1")
owner, err := CaptureSessionOwner()
var warn bytes.Buffer
l := OpenChatSeen(owner, err, &warn)
if filepath.Base(l.Path()) != strconv.Itoa(ppid)+".log" {
t.Fatalf("log %v warn %q", l, warn.String())
}
t.Setenv("PAD_SESSION_PID", "")
owner, err = CaptureSessionOwner()
if l := OpenChatSeen(owner, err, &warn); filepath.Base(l.Path()) != "1.log" {
t.Fatalf("CLAUDE_PID fallback: log %q", l.Path())
}
}
func TestPruneRemovesDeadSessionsChatSeen(t *testing.T) {
dir := registryEnv(t)
dead := exitedProcessPID(t)
writeV2Record(t, dir, SessionRegistration{SessionOwner: SessionOwner{PID: dead, PIDSource: "CLAUDE_PID"}, Agent: "gone", Cwd: "/b", RegisteredAt: "2026-08-01T00:00:00Z"})
writeV2Record(t, dir, SessionRegistration{SessionOwner: SessionOwner{PID: os.Getpid(), PIDSource: "CLAUDE_PID"}, Agent: "live", Cwd: "/a", RegisteredAt: "2026-08-01T00:00:00Z"})
var warn bytes.Buffer
deadLog := OpenChatSeen(chatSeenOwner(dead), nil, &warn)
deadLog.Append("x")
if err := os.WriteFile(deadLog.Path()+".1", []byte("y\n"), 0600); err != nil {
t.Fatal(err)
}
liveLog := OpenChatSeen(chatSeenOwner(os.Getpid()), nil, &warn)
liveLog.Append("z")
rep, err := PruneSessions(0, time.Now())
if err != nil || rep.DeadRemoved != 1 {
t.Fatalf("prune: %+v %v", rep, err)
}
for _, p := range []string{deadLog.Path(), deadLog.Path() + ".1"} {
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Fatalf("%s must go with its record; stat err = %v", p, err)
}
}
if got := readChatSeen(t, liveLog.Path()); got != "z\n" {
t.Fatalf("a live session's log must stay: %q", got)
}
}
// r1: ensurePrivateDir follows a symlink, so a symlinked chat-seen directory
// would have its target chmodded and the log written into it.
func TestChatSeenRefusesSymlinkedDir(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlinks")
}
t.Setenv("HOME", t.TempDir())
path, _ := ChatSeenPath(13)
target := t.TempDir()
if err := os.Chmod(target, 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(filepath.Dir(path)), 0700); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, filepath.Dir(path)); err != nil {
t.Fatal(err)
}
var warn bytes.Buffer
OpenChatSeen(chatSeenOwner(13), nil, &warn).Append("x")
if !strings.Contains(warn.String(), "is a symlink") {
t.Fatalf("warn %q", warn.String())
}
if info, _ := os.Stat(target); info.Mode().Perm() != 0755 {
t.Fatalf("the symlink's target was chmodded to %v", info.Mode().Perm())
}
if entries, _ := os.ReadDir(target); len(entries) != 0 {
t.Fatalf("wrote into the symlink's target: %v", entries)
}
}
// r1: a log removed under the writer (a prune of an unknown-liveness record)
// is recreated at its path rather than written into an unlinked file.
func TestChatSeenReopensAfterRemoval(t *testing.T) {
t.Setenv("HOME", t.TempDir())
var warn bytes.Buffer
l := OpenChatSeen(chatSeenOwner(14), nil, &warn)
l.Append("before")
if err := removeChatSeen(14); err != nil {
t.Fatal(err)
}
l.Append("after")
if got := readChatSeen(t, l.Path()); got != "after\n" || warn.Len() != 0 {
t.Fatalf("log %q warn %q", got, warn.String())
}
}