feat(harness): title Claude Code harness sessions through the CLI

A harness agent has no docker-agent model, so the TUI got no title
generator for it and its sessions stayed untitled.

The Claude Code driver now implements an optional harness.Completer: a
one-shot `claude` call in print mode on the haiku model, with no tools,
no MCP servers and no saved session, the prompt passed on stdin and the
JSON result parsed. sessiontitle.Generator accepts such completers after
its models, and LocalRuntime.TitleGenerator uses the driver's completer
for a harness agent. Other harness types still get no title.

Signed-off-by: Sayt-0 <louis.dal-moro-compta@epita.fr>
This commit is contained in:
Sayt-0 committed 2026-10-26 13:46:08 +00:00
1 parent 9d7700c81c
commit d3981ccf15
10 files changed
+319 -17

No files matched your search

+3
View File
@@ -79,6 +79,9 @@ agents:
goes through Docker Agent's model providers or routing.
- `effort` is forwarded as `--effort` and must be one of `low`, `medium`,
`high`, `xhigh`, or `max`. Omit it to use Claude Code's own default.
- Session titles come from the CLI as well, since a harness agent has no
model: Docker Agent runs `claude --print` once on the `haiku` model, with
no tools, MCP servers or saved session, to title a new session.
#### Authentication (Claude subscription, no API key)
+55
View File
@@ -3,6 +3,8 @@ package codingharness
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"strings"
@@ -32,6 +34,14 @@ const (
// claudeProbeTimeout bounds each CLI invocation of the probe so a hung
// `claude` process cannot stall doctor or setup.
claudeProbeTimeout = 15 * time.Second
// claudeCompleteModel answers one-shot prompts: the small, fast model
// Claude Code uses for its own background tasks.
claudeCompleteModel = "haiku"
// claudeCompleteWaitDelay bounds the wait for a canceled one-shot prompt
// whose output pipe is still held open, e.g. by a child process.
claudeCompleteWaitDelay = time.Second
)
// claudeLoginArgs is ClaudeLoginCommand as argv, kept next to the constant so
@@ -183,6 +193,51 @@ func firstLine(out []byte) string {
return strings.TrimSpace(line)
}
// claudeCodeProvider is the Claude Code harness driver. It also implements
// harness.Completer, so harness agents get session titles.
type claudeCodeProvider struct {
provider
}
// Complete answers prompt with the claude CLI's fast model, without tools, MCP
// servers or a saved session. Like the probe, its errors never carry output.
func (claudeCodeProvider) Complete(ctx context.Context, systemPrompt, prompt string) (string, error) {
cmd := exec.CommandContext(ctx, claudeBinary, claudeCompleteArgs(systemPrompt)...)
// Through stdin, a long or dash-led prompt is never parsed as arguments.
cmd.Stdin = strings.NewReader(prompt)
cmd.WaitDelay = claudeCompleteWaitDelay
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("`claude --print` failed: %w", err)
}
var result struct {
Type string `json:"type"`
IsError bool `json:"is_error"`
Result string `json:"result"`
}
if err := json.Unmarshal(out, &result); err != nil || result.Type != "result" {
return "", errors.New("`claude --print` returned unexpected output")
}
if result.IsError {
return "", errors.New("`claude --print` reported an error")
}
return result.Result, nil
}
func claudeCompleteArgs(systemPrompt string) []string {
return []string{
"--print",
"--model", claudeCompleteModel,
"--system-prompt", systemPrompt,
// An empty list disables every built-in tool.
"--tools", "",
"--strict-mcp-config",
"--no-session-persistence",
"--output-format", "json",
}
}
// RunClaudeLogin runs the official interactive Claude Code login
// (ClaudeLoginCommand) attached to this process's terminal, so the login
// lands in the exact OS user and environment docker-agent runs as. Callers
+77
View File
@@ -8,10 +8,14 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/harness"
)
// fakeClaudeCLI scripts the probe's exec seams and records every invocation,
@@ -206,6 +210,79 @@ func TestClaudeCLIProbe_MissingBinaryWithDefaultSeams(t *testing.T) {
assert.Equal(t, ClaudeStateNotInstalled, status.State)
}
// useClaudeCompleteCLI puts a fake `claude` first on PATH: it records its
// arguments and stdin in dir, prints output and exits with code.
func useClaudeCompleteCLI(t *testing.T, output string, code int) (dir string) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("the fake claude executable is a shell script")
}
dir = t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "out"), []byte(output), 0o600))
script := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > %q\ncat > %q\ncat %q\nexit %d\n",
filepath.Join(dir, "args"), filepath.Join(dir, "stdin"), filepath.Join(dir, "out"), code)
require.NoError(t, os.WriteFile(filepath.Join(dir, "claude"), []byte(script), 0o700))
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
return dir
}
func TestFactoryOnlyClaudeCodeCompletes(t *testing.T) {
t.Parallel()
p, err := Factory(&latest.HarnessConfig{Type: TypeClaudeCode})
require.NoError(t, err)
assert.Implements(t, (*harness.Completer)(nil), p)
p, err = Factory(&latest.HarnessConfig{Type: TypeCodex})
require.NoError(t, err)
assert.NotImplements(t, (*harness.Completer)(nil), p)
}
func TestClaudeCodeComplete(t *testing.T) {
dir := useClaudeCompleteCLI(t, `{"type":"result","subtype":"success","is_error":false,"result":"Fix the login bug"}`, 0)
p, err := Factory(&latest.HarnessConfig{Type: TypeClaudeCode, Model: "opus", Effort: "max"})
require.NoError(t, err)
answer, err := p.(harness.Completer).Complete(t.Context(), "Be brief.", "--help me title this")
require.NoError(t, err)
assert.Equal(t, "Fix the login bug", answer)
args, err := os.ReadFile(filepath.Join(dir, "args"))
require.NoError(t, err)
assert.Equal(t, strings.Join([]string{
"--print", "--model", "haiku", "--system-prompt", "Be brief.",
"--tools", "", "--strict-mcp-config", "--no-session-persistence",
"--output-format", "json",
}, "\n")+"\n", string(args), "a tool-free, unsaved prompt on the fast model, without the agent's flags")
stdin, err := os.ReadFile(filepath.Join(dir, "stdin"))
require.NoError(t, err)
assert.Equal(t, "--help me title this", string(stdin), "the prompt goes through stdin, never argv")
}
func TestClaudeCodeCompleteErrorsOmitOutput(t *testing.T) {
for name, tt := range map[string]struct {
output string
code int
want string
}{
"reported error": {output: `{"type":"result","is_error":true,"result":"private-output-marker"}`, want: "reported an error"},
"not json": {output: "private-output-marker", want: "unexpected output"},
"not a result": {output: `{"type":"private-output-marker"}`, want: "unexpected output"},
"failed command": {output: "private-output-marker", code: 1, want: "exit status 1"},
} {
t.Run(name, func(t *testing.T) {
useClaudeCompleteCLI(t, tt.output, tt.code)
p, err := Factory(&latest.HarnessConfig{Type: TypeClaudeCode})
require.NoError(t, err)
_, err = p.(harness.Completer).Complete(t.Context(), "system", "prompt")
require.ErrorContains(t, err, tt.want)
assert.NotContains(t, err.Error(), "private-output-marker")
})
}
}
func TestClaudeCLIStatus_AuthSummaryOmitsEmptyFields(t *testing.T) {
t.Parallel()
+3
View File
@@ -19,6 +19,9 @@ func Factory(cfg *latest.HarnessConfig) (harness.Provider, error) {
if err != nil {
return nil, err
}
if cfg.Type == TypeClaudeCode {
return claudeCodeProvider{provider{p}}, nil
}
return provider{p}, nil
}
+7
View File
@@ -56,5 +56,12 @@ type Provider interface {
Resume(ctx context.Context, sessionID, prompt string, handle func(Event)) error
}
// Completer is an optional Provider capability: answering a one-shot prompt
// without tools or a persisted harness session, as session titles need.
type Completer interface {
Provider
Complete(ctx context.Context, systemPrompt, prompt string) (string, error)
}
// Factory builds the Provider for a harness configuration.
type Factory func(cfg *latest.HarnessConfig) (Provider, error)
+16
View File
@@ -5,8 +5,10 @@ import (
"strings"
"sync/atomic"
"github.com/docker/docker-agent/pkg/agent"
"github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/harness"
"github.com/docker/docker-agent/pkg/sessiontitle"
)
// harnessFactory is empty until a driver is registered (the CLI and
@@ -48,6 +50,20 @@ func (r *LocalRuntime) newHarnessProvider(cfg *latest.HarnessConfig) (harness.Pr
return (*factory)(cfg)
}
// harnessTitleGenerator titles a harness agent's sessions through its driver,
// or returns nil when the driver cannot answer one-shot prompts.
func (r *LocalRuntime) harnessTitleGenerator(a *agent.Agent) *sessiontitle.Generator {
p, err := r.newHarnessProvider(a.Harness())
if err != nil {
return nil
}
completer, ok := p.(harness.Completer)
if !ok {
return nil
}
return sessiontitle.NewFromCompleters(completer)
}
// harnessLabel is the model-slot label shown for a harness agent, e.g.
// "claude-code/opus".
func harnessLabel(cfg *latest.HarnessConfig) string {
+23
View File
@@ -395,6 +395,29 @@ func TestHarnessSuppressesReplayedClaudeCodeFinalText(t *testing.T) {
assert.Equal(t, []string{"Hello", " world"}, chunks)
}
// A harness agent has no models to title its session with: Claude Code's
// driver answers the title prompt itself.
func TestHarnessAgentTitleGeneratedThroughClaudeCode(t *testing.T) {
if stdruntime.GOOS == "windows" {
t.Skip("shell script shim test")
}
useHarnessShim(t, "claude", `{"type":"result","subtype":"success","is_error":false,"result":"Fix the login bug"}`)
gen := newHarnessRuntime(t, "claude-code").TitleGenerator(t.Context())
require.NotNil(t, gen)
title, err := gen.Generate(t.Context(), "sess-1", []string{"the login form rejects valid passwords"})
require.NoError(t, err)
assert.Equal(t, "Fix the login bug", title)
args, err := os.ReadFile(filepath.Join(harnessBinDir, "claude.args"))
require.NoError(t, err)
assert.Contains(t, string(args), "--no-session-persistence")
assert.NotContains(t, string(args), "--dangerously-skip-permissions")
assert.Nil(t, newHarnessRuntime(t, "codex").TitleGenerator(t.Context()), "the codex driver cannot answer one-shot prompts")
}
func newHarnessRuntime(t *testing.T, harnessType string) *LocalRuntime {
t.Helper()
return newHarnessRuntimeWithStore(t, harnessType, session.NewInMemorySessionStore())
+5 -1
View File
@@ -1159,12 +1159,16 @@ func (r *LocalRuntime) ExecuteMCPPrompt(ctx context.Context, promptName string,
// TitleGenerator returns a title generator for automatic session title
// generation, or nil when no configured model is a usable title candidate
// (see [sessiontitle.New]).
// (see [sessiontitle.New]). A harness agent has no models: its driver titles
// the session when it implements [harness.Completer].
func (r *LocalRuntime) TitleGenerator(ctx context.Context) *sessiontitle.Generator {
a := r.CurrentAgent()
if a == nil {
return nil
}
if a.HasHarness() {
return r.harnessTitleGenerator(a)
}
return sessiontitle.New(a.TitleModels(ctx)...)
}
+74 -16
View File
@@ -38,9 +38,17 @@ const (
titleGenerationTimeout = 30 * time.Second
)
// Completer answers a one-shot prompt outside docker-agent's model providers,
// such as an external coding harness CLI.
type Completer interface {
Name() string
Complete(ctx context.Context, systemPrompt, prompt string) (string, error)
}
// Generator generates session titles using a one-shot LLM completion.
type Generator struct {
models []provider.Provider
models []provider.Provider
completers []Completer
}
// New creates a title Generator from the ordered candidate models. Nil
@@ -59,13 +67,29 @@ func New(models ...provider.Provider) *Generator {
return &Generator{models: usable}
}
// NewFromCompleters creates a title Generator from the ordered candidate
// completers. Nil completers are skipped.
func NewFromCompleters(completers ...Completer) *Generator {
usable := make([]Completer, 0, len(completers))
for _, c := range completers {
if c != nil {
usable = append(usable, c)
}
}
if len(usable) == 0 {
return nil
}
return &Generator{completers: usable}
}
// Generate produces a title for a session based on the provided user messages.
// It performs one-shot LLM calls directly via the provider's
// CreateChatCompletionStream, avoiding the overhead of spinning up a nested
// runtime, and falls back to the next model on failure.
// Returns an empty string if no models or messages are configured.
// runtime, and falls back to the next candidate on failure; completers come
// after models.
// Returns an empty string if no candidates or messages are configured.
func (g *Generator) Generate(ctx context.Context, sessionID string, userMessages []string) (title string, err error) {
if g == nil || len(g.models) == 0 || len(userMessages) == 0 {
if g == nil || g.candidateCount() == 0 || len(userMessages) == 0 {
return "", nil
}
@@ -85,7 +109,7 @@ func (g *Generator) Generate(ctx context.Context, sessionID string, userMessages
trace.WithSpanKind(trace.SpanKindInternal),
trace.WithAttributes(
attribute.String(genai.AttrConversationID, sessionID),
attribute.Int("cagent.sessiontitle.candidate_count", len(g.models)),
attribute.Int("cagent.sessiontitle.candidate_count", g.candidateCount()),
),
)
defer func() {
@@ -102,10 +126,8 @@ func (g *Generator) Generate(ctx context.Context, sessionID string, userMessages
slog.DebugContext(ctx, "Generating title for session", "session_id", sessionID, "message_count", len(userMessages))
messages := buildPrompt(userMessages)
var errs []error
for idx, baseModel := range g.models {
for idx := range g.candidateCount() {
// Assign to the named-return `err` so a context cancellation
// is observed by the deferred span closure as a recorded
// error rather than silently slipping through.
@@ -113,9 +135,9 @@ func (g *Generator) Generate(ctx context.Context, sessionID string, userMessages
return "", err
}
title, err := generateOnce(ctx, baseModel, messages)
title, name, err := g.attempt(ctx, idx, userMessages)
if err == nil {
slog.DebugContext(ctx, "Generated session title", "session_id", sessionID, "title", title, "model", baseModel.ID())
slog.DebugContext(ctx, "Generated session title", "session_id", sessionID, "title", title, "model", name)
return title, nil
}
@@ -125,12 +147,43 @@ func (g *Generator) Generate(ctx context.Context, sessionID string, userMessages
// which model failed and why instead of just the last one.
slog.DebugContext(ctx, "Title generation attempt failed",
"session_id", sessionID,
"model", baseModel.ID(),
"model", name,
"attempt", idx+1,
"error", err)
}
return "", fmt.Errorf("all %d title model(s) failed: %w", len(g.models), errors.Join(errs...))
return "", fmt.Errorf("all %d title model(s) failed: %w", g.candidateCount(), errors.Join(errs...))
}
func (g *Generator) candidateCount() int {
return len(g.models) + len(g.completers)
}
// attempt asks the idx-th candidate for a title, models first, and returns
// the candidate's name for logs.
func (g *Generator) attempt(ctx context.Context, idx int, userMessages []string) (title, name string, err error) {
if idx < len(g.models) {
model := g.models[idx]
title, err = generateOnce(ctx, model, buildPrompt(userMessages))
return title, model.ID().String(), err
}
c := g.completers[idx-len(g.models)]
title, err = completeOnce(ctx, c, buildUserPrompt(userMessages))
return title, c.Name(), err
}
// completeOnce is generateOnce for a Completer.
func completeOnce(ctx context.Context, c Completer, prompt string) (string, error) {
raw, err := c.Complete(ctx, systemPrompt, prompt)
if err != nil {
return "", fmt.Errorf("completer %q: %w", c.Name(), err)
}
title := sanitizeTitle(raw)
if title == "" {
return "", fmt.Errorf("empty title output from completer %q", c.Name())
}
return title, nil
}
// generateOnce performs a single one-shot title generation call against
@@ -193,14 +246,19 @@ func drainStream(stream chat.MessageStream) (string, error) {
// buildPrompt formats the user messages into the system+user message pair
// sent to the model.
func buildPrompt(userMessages []string) []chat.Message {
return []chat.Message{
{Role: chat.MessageRoleSystem, Content: systemPrompt},
{Role: chat.MessageRoleUser, Content: buildUserPrompt(userMessages)},
}
}
// buildUserPrompt formats the user messages into the title request.
func buildUserPrompt(userMessages []string) string {
var formatted strings.Builder
for i, msg := range userMessages {
fmt.Fprintf(&formatted, "%d. %s\n", i+1, msg)
}
return []chat.Message{
{Role: chat.MessageRoleSystem, Content: systemPrompt},
{Role: chat.MessageRoleUser, Content: fmt.Sprintf(userPromptFormat, formatted.String())},
}
return fmt.Sprintf(userPromptFormat, formatted.String())
}
// sanitizeTitle returns the first non-empty trimmed line of title, with any
+56
View File
@@ -305,6 +305,62 @@ func TestNew_KeepsUndeclaredAndExplicitFalseCandidates(t *testing.T) {
assert.Zero(t, declaredFalse.calls)
}
type mockCompleter struct {
name string
answer string
err error
calls int
systemPrompt, prompt string
}
func (c *mockCompleter) Name() string { return c.name }
func (c *mockCompleter) Complete(_ context.Context, systemPrompt, prompt string) (string, error) {
c.calls++
c.systemPrompt, c.prompt = systemPrompt, prompt
return c.answer, c.err
}
func TestNewFromCompleters_NoUsableCandidates_ReturnsNil(t *testing.T) {
t.Parallel()
assert.Nil(t, NewFromCompleters())
assert.Nil(t, NewFromCompleters(nil, nil))
}
func TestGenerator_Generate_Completer(t *testing.T) {
t.Parallel()
c := &mockCompleter{name: "claude-code", answer: "\n Fix the login bug \nSecond line"}
title, err := NewFromCompleters(c).Generate(t.Context(), "sess-1", []string{"the login form rejects valid passwords"})
require.NoError(t, err)
assert.Equal(t, "Fix the login bug", title)
assert.Equal(t, systemPrompt, c.systemPrompt)
assert.Contains(t, c.prompt, "1. the login form rejects valid passwords")
}
func TestGenerator_Generate_CompleterFallsBackAndReportsEachFailure(t *testing.T) {
t.Parallel()
failing := &mockCompleter{name: "first", err: errors.New("exit status 1")}
empty := &mockCompleter{name: "second", answer: "\n\n"}
title, err := NewFromCompleters(failing, empty).Generate(t.Context(), "sess-1", []string{"hello"})
require.Error(t, err)
assert.Empty(t, title)
assert.Contains(t, err.Error(), "all 2 title model(s) failed")
assert.Contains(t, err.Error(), `completer "first": exit status 1`)
assert.Contains(t, err.Error(), `empty title output from completer "second"`)
working := &mockCompleter{name: "third", answer: "Greeting"}
title, err = NewFromCompleters(failing, working).Generate(t.Context(), "sess-1", []string{"hello"})
require.NoError(t, err)
assert.Equal(t, "Greeting", title)
assert.Equal(t, 2, failing.calls)
assert.Equal(t, 1, working.calls)
}
func TestGeneratorCloneFailuresExhaustCandidates(t *testing.T) {
t.Parallel()
synthetic := errors.New("synthetic rebuild failure")