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 cmd/codeaf/chatv3_layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -660,7 +660,7 @@ func v3ScanBucket(bucket string) (spoken, empty []v3Folder) {
func v3EmptySession(dir string) bool {
place := session.Place{Dir: dir, Owned: true}
spoken, sure := session.SpokeIn(place.Transcript())
if spoken || !sure {
if spoken || !sure || session.HasSavedTasks(dir) {
return false
}
for _, kept := range []string{place.Work(), place.Trees(), place.Artifacts()} {
Expand Down
20 changes: 20 additions & 0 deletions cmd/codeaf/chatv3_layout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -373,3 +373,23 @@ func TestTheReaperKeepsEveryFolderItCannotSettle(t *testing.T) {
})
}
}

func TestLaunchKeepsTaskOnlyConversationFolders(t *testing.T) {
for _, name := range []string{"plandb.db", "plandb.db.1", "tasks.json", "tasks/1/transcript.jsonl"} {
t.Run(name, func(t *testing.T) {
bucket := t.TempDir()
dir := writeV3Session(t, bucket, "aaaaaaaaaaaaaaaa", "", time.Time{})
path := filepath.Join(dir, name)
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
// Even a torn store is evidence of work, never permission to reap it.
if err := os.WriteFile(path, []byte("partial saved work"), 0o600); err != nil {
t.Fatal(err)
}
if v3EmptySession(dir) {
t.Fatal("launch would reuse or reap saved task work")
}
})
}
}
10 changes: 10 additions & 0 deletions docs/changes/unreleased/1667-shell-conversation-titles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
kind: fixed
title: Conversation names skip shell commands and task-only chats remain resumable
pr: 1667
surface: [chat, engine, docs]
invalidates:
- "Conversation lists title-cased opening shell commands and collapsed their spaces. Shell-command previews now keep their casing and punctuation, and saved rows keep their spacing, within the existing preview limit."
- "The conversation namer treated the first human shell command as the opening question even after an ordinary message followed. It now skips user turns answered by a user_bash_ call and waits for the first ordinary question with an answer, including after reopening. Both feed the title so a question like 'what did that print?' has context; the answer may quote shell output. Ordinary openings still name immediately. Notes codeaf writes itself, including memory and deliveries, are never taken as the question."
- "Conversations opened directly with /senior-dev or /task could be absent from /resume and home after an abrupt exit because no ordinary user message existed. Lists now read the saved task brief, and empty-launch cleanup preserves task stores and journals."
---
20 changes: 17 additions & 3 deletions internal/manual/chat/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,19 @@ An empty `!`, attached files, or a busy conversation leaves your draft in place
and explains what to change. Wait for the turn to finish or stop it first.
Task pages accept task messages; run `!` commands from the parent conversation.

## Conversation titles after shell commands

A conversation containing only `!` commands keeps a literal command preview as
its working name. Home and conversation lists preserve its casing and punctuation;
long commands are clipped to fit. Shell turns do not ask a model for a title.
After a shell opening, automatic naming waits for the first ordinary message with
an answer and uses both, skipping recorded human shell turns. The command preview
stays until the title arrives. This gives a question such as "what did that print?"
the context of its answer; the answer may quote shell output. An interrupted question
does not stop naming: if part of its answer had arrived, your next message names the chat
from that exchange, and if none had, the next answered question does. Conversations that
begin with an ordinary message still start naming immediately.

## Typing a slash to see the command list

Type `/` in the home or conversation message box to see every available command in
Expand Down Expand Up @@ -1149,9 +1162,10 @@ esc leaves the conversation exactly as it was.
Ten rows show, each a name, a description and an age. The name climbs a ladder: the title
the session gave itself, else the first seven words you said in it, else the transcript's
file name — then title-cased, with small words left lowercase and nothing ever
lowercased, so `OpenAI` keeps its shape. The description is the last thing that happened,
capped at 80 columns. The age (`2h ago`, or a date past a month) is reserved first and
never cut. **Ids and file names appear nowhere.**
lowercased, so `OpenAI` keeps its shape. A name that is still a `!` command is the
exception: it is shown as typed, clipped only to fit, never title-cased or cut to seven
words. The description is the last thing that happened, capped at 80 columns. The age
(`2h ago`, or a date past a month) is reserved first and never cut. **Ids and file names appear nowhere.**

The cursor opens on the conversation you are already in. enter on another row closes this
agent, interrupting a running turn first, opens the chosen transcript, clears everything
Expand Down
22 changes: 17 additions & 5 deletions internal/manual/chat/sessions-and-rewind.md
Original file line number Diff line number Diff line change
Expand Up @@ -497,9 +497,13 @@ model's (`gpt-4.1-mini:high`), and the name came off it on 2026-09-17 because a
takes the room the numbers need.

**It starts with your first message.** The small model on the `title` role is shown the
opening question and asked for one descriptive **5–8 word phrase**. The answer and the naming request run independently. A late name still
reaches an idle chat, a background tab, or a hosted chat after the connection is restored;
no refresh or follow-up message is needed.
opening question and asked for one descriptive **5–8 word phrase**. A `!` shell command
is not that message: a conversation that opens with `!` commands keeps the command as its
name until an ordinary message has an answer, and is then named from that question and
answer. Unanswered questions are skipped after a shell opening. For an ordinary opening,
the answer and naming still run independently from the start. A late name reaches an idle
chat, a background tab, or a hosted chat after the connection is restored; no refresh or
follow-up message is needed.

Each ask is bounded to twenty seconds so a slow cheap endpoint yields to the existing
fallback promptly. Temporary provider failures are asked again for as long as the
Expand Down Expand Up @@ -569,12 +573,20 @@ appear nowhere.
The name of a conversation is the title it gave itself; failing that, the first seven words
you said; failing that, the file name with `.jsonl` stripped.

A conversation started with `/senior-dev` or `/task` is listed even if you never
sent an ordinary chat message, including after an abrupt exit. Until you send one, its
saved task brief is the row's description, and its name too unless the conversation
has earned a title. Empty-launch cleanup preserves saved task work. Listing the
conversation does not restart or finish the interrupted task.

There is **no argument form** of `/resume`. A conversation is named by a title the model
wrote and lives in a timestamped file, so the only honest way to ask for one is to be shown
them.

The list is read without locking anything: open, scan, close. It never writes and never
creates a file, so it can show a conversation another window is holding open. One known
The list is read without locking anything: open, scan, close. It never writes to a
conversation, so it can show a conversation another window is holding open. The one
exception is a task-only conversation: reading its saved brief opens the task store
read-only, which can leave SQLite's `plandb.db-wal` and `plandb.db-shm` files beside it. One known
staleness: the list reads what the file says rather than the transcript a resume would
rebuild, so a rewind with nothing typed after it leaves the taken-back message as the row's
description.
Expand Down
37 changes: 37 additions & 0 deletions internal/plandb/preview.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package plandb

import (
"database/sql"
"net/url"
"time"
)

// RootPreview is the original request behind a saved run. Listing a conversation
// needs these words, not a replay or a writable handle to the task graph.
type RootPreview struct {
Title string
Description string
CreatedAt time.Time
}

// ReadRootPreview reads only the committed root of an existing store. It never
// creates a database, migrates its schema or settles a task left running by a
// process that exited, so a conversation picker can safely read live work too.
func ReadRootPreview(path string) (RootPreview, error) {
uri := url.URL{Scheme: "file", Path: path, RawQuery: "mode=ro"}
db, err := sql.Open("sqlite", uri.String())
if err != nil {
return RootPreview{}, err
}
defer db.Close()
db.SetMaxOpenConns(1)
var root RootPreview
var created string
err = db.QueryRow(`SELECT tasks.title, tasks.description, tasks.created_at
FROM tasks JOIN meta ON tasks.id = meta.root_id WHERE meta.id = 1`).Scan(&root.Title, &root.Description, &created)
if err != nil {
return RootPreview{}, err
}
root.CreatedAt, err = parseTime(created)
return root, err
}
35 changes: 35 additions & 0 deletions internal/plandb/preview_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package plandb

import (
"os"
"path/filepath"
"testing"
)

func TestRootPreviewReadsBesideAWriterAndNeverCreatesAStore(t *testing.T) {
path := filepath.Join(t.TempDir(), "run brief.db")
if _, err := ReadRootPreview(path); err == nil {
t.Fatal("missing store was readable")
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("read created a store: %v", err)
}
store, err := Open(path, "program", "1", "fix parser", "fix the parser crash")
if err != nil {
t.Fatal(err)
}
defer store.Close()
// A live worker holding the write transaction must not block the picker.
tx, err := store.beginWrite()
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
got, err := ReadRootPreview(path)
if err != nil {
t.Fatal(err)
}
if got.Title != "fix parser" || got.Description != "fix the parser crash" || !got.CreatedAt.Equal(store.Task("1").CreatedAt) {
t.Fatalf("wrong saved root: %+v", got)
}
}
6 changes: 3 additions & 3 deletions internal/session/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -1815,9 +1815,9 @@ func (a *Agent) startTurnLocked(ctx context.Context, user userMessage, watcher *
a.recordUserLocked(user)
// AND THE SESSION STARTS NAMING ITSELF NOW, on the person's own words,
// beside the answer rather than behind it (title.go). The message is in
// the transcript on the line above, which is the only thing the namer
// needs; it is started under this lock so that two Submits racing to be
// the first cannot buy two names.
// the transcript on the line above; a shell opening additionally waits
// for an ordinary reply to explain its output. Naming is started under
// this lock so two Submits racing to be first cannot buy two names.
if user.bash == "" {
a.startTitleLocked()
}
Expand Down
2 changes: 1 addition & 1 deletion internal/session/compact_summary.go
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,7 @@ func (a *Agent) personMessagesLocked() []int {
if a.messages[index].Role != "user" {
continue
}
if isCodeafNote(messageContentText(a.messages[index])) || a.file.isNote(a.messages[index]) {
if a.sessionNoteLocked(a.messages[index]) {
continue
}
persons = append(persons, index)
Expand Down
31 changes: 31 additions & 0 deletions internal/session/delegate_door_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -589,3 +589,34 @@ func TestAProgramsRunIsReadableWithTheSwitchOff(t *testing.T) {
}
endBeltRun(t, agent, double)
}

func TestStartingAProgramWithoutChatIsImmediatelyResumable(t *testing.T) {
double := newBeltRunDouble("done")
registerBeltRunEngine(t, double)
workspace := newTestRepo(t)
bucket := t.TempDir()
place := Place{Dir: filepath.Join(bucket, "aaaaaaaaaaaaaaaa"), Workspace: workspace}
agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(cfg *Config) {
cfg.Workspace = workspace
cfg.Place = place
cfg.SessionFile = place.Transcript()
cfg.AskConsent = false
cfg.Delegates = testPrograms("fake")
})
const brief = "repair the parser after the upgrade"
if _, _, _, err := agent.StartDelegate(context.Background(), "fake", brief); err != nil {
t.Fatal(err)
}
<-double.entered
// Read while the program is still running, without closing the agent or
// settling metadata writes; abrupt exit cannot supply either of those.
found := RecentSessions(bucket, 10)
rows := ReadRows([]string{place.Transcript()})
endBeltRun(t, agent, double)
if len(found) != 1 || found[0].Opening != brief {
t.Fatalf("program opening missing from resume: %+v", found)
}
if _, ok := rows[place.Transcript()]; !ok {
t.Fatal("program opening missing from home")
}
}
16 changes: 11 additions & 5 deletions internal/session/peek.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ type Summary struct {
At time.Time
// Asked is how many of the person's messages the FILE holds. It counts
// lines rather than turns — a compaction re-journals the messages it kept,
// so a long session counts some of them twice — because the only question
// asked of it is whether anything was ever said here at all.
// so a long session counts some of them twice. A task-only conversation can
// have a saved brief and still have no chat messages.
Asked int
}

Expand All @@ -68,7 +68,8 @@ const summaryClip = 200

// Peek reads one transcript and reports what a picker can show of it. The
// boolean is false for a file that is not a conversation — missing, unreadable,
// a header with nothing under it, or a session nobody ever spoke in. A picker
// a header with nothing under it, or a session with neither messages nor saved
// task work. Command-only conversations fall back to the saved run brief. A picker
// row for one of those is a row with no words on it and nothing behind it.
func Peek(path string) (Summary, bool) {
file, err := os.Open(path)
Expand Down Expand Up @@ -113,7 +114,7 @@ func Peek(path string) (Summary, bool) {
// picture and nothing else is the ordinary case (the surface's
// attach.go writes exactly that), and a file whose only turn was
// one is a conversation — so it counts toward [Summary.Asked],
// which is the whole test for whether this file is one at all.
// which identifies an ordinary chat independently of saved task work.
// What it cannot be is the OPENING or the LAST line: those are
// sentences, and this message has none. The answer below is what
// the last line falls back to, for precisely this case.
Expand Down Expand Up @@ -147,7 +148,12 @@ func Peek(path string) (Summary, bool) {
// and a picker row built from the first half of a file says more than a row
// that is missing because the second half was unreadable.
if summary.Asked == 0 {
return Summary{}, false
saved, ok := savedTaskSummary(path)
if !ok {
return Summary{}, false
}
saved.Title = summary.Title
return saved, true
}
if summary.Last == "" {
summary.Last = answered
Expand Down
3 changes: 3 additions & 0 deletions internal/session/placemeta.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ func (s *stampWriter) settle() {
// name that turned out not to be one is thrown away and the words have to come
// back ([openingPlaceholder]).
func placeholderTitle(text string) string {
if _, shell := BashCommand(text); shell {
return clip(strings.TrimSpace(firstLine(text)), metaTitleLimit)
}
return clip(strings.Join(strings.Fields(firstLine(text)), " "), metaTitleLimit)
}

Expand Down
52 changes: 52 additions & 0 deletions internal/session/recentplace_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package session

import (
"fmt"
"os"
"path/filepath"
"testing"
"time"

"github.com/Agent-Field/codeaf/internal/plandb"
)

// A directory holding both shapes at once is the state every machine passes
Expand Down Expand Up @@ -106,3 +109,52 @@ func TestADirectoryWithNoTranscriptIsNotListed(t *testing.T) {
t.Fatalf("the listing is %+v", found)
}
}

// A program can persist its work before any ordinary chat turn exists. These
// are the files left by an abrupt exit, with no close or metadata flush.
func TestTaskOnlyConversationSurvivesInConversationLists(t *testing.T) {
for _, archived := range []bool{false, true} {
t.Run(fmt.Sprint(archived), func(t *testing.T) {
bucket := t.TempDir()
place := Place{Dir: filepath.Join(bucket, "aaaaaaaaaaaaaaaa")}
writeTranscript(t, place.Transcript(), `{"type":"session","version":1}`)
if err := SaveMeta(place.Dir, Meta{ID: filepath.Base(place.Dir)}); err != nil {
t.Fatal(err)
}
path := filepath.Join(place.Dir, planStoreFilename)
if archived {
path += ".1"
}
const brief = "repair the parser after the upgrade"
store, err := plandb.Open(path, "program run", "1", brief, brief, filepath.Base(place.Dir))
if err != nil {
t.Fatal(err)
}
wantAt := store.Task("1").CreatedAt
if err := store.Close(); err != nil {
t.Fatal(err)
}
found := RecentSessions(bucket, 10)
if len(found) != 1 || found[0].File != place.Transcript() || found[0].Opening != brief || !found[0].At.Equal(wantAt) {
t.Fatalf("resume lost the task-only conversation: %+v", found)
}
row, ok := ReadRows([]string{place.Transcript()})[place.Transcript()]
if !ok || row.Title != brief || !row.At.Equal(wantAt) {
t.Fatalf("home lost the task-only conversation: %+v", row)
}
// Reading does not invent a chat message or mark an interrupted task done.
spoken, sure := SpokeIn(place.Transcript())
if spoken || !sure {
t.Fatalf("listing changed the journal: spoken=%v sure=%v", spoken, sure)
}
reopened, err := plandb.Open(path, "", "", "", "")
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
if reopened.Task("1").Status != plandb.StatusRunning {
t.Fatal("listing settled the task")
}
})
}
}
Loading
Loading