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
11 changes: 9 additions & 2 deletions pkg/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ import (
"github.com/docker/docker-agent/pkg/tui/messages"
)

type titleGenerator interface {
Generate(ctx context.Context, sessionID string, userMessages []string) (string, error)
}

type App struct {
operation atomic.Uint64
ctx func() context.Context
Expand All @@ -54,7 +58,7 @@ type App struct {
exitAfterFirstResponse bool // Exit TUI after first assistant response completes
readOnly bool // When true, no new messages can be sent to the LLM
titleGenerating atomic.Bool // True when title generation is in progress
titleGen *sessiontitle.Generator // Title generator for local runtime (nil for remote)
titleGen titleGenerator // Title generator for local runtime (nil for remote)
snapshotController builtins.SnapshotController // Drives /undo, /snapshots, /reset; nil for runtimes that don't capture snapshots
streamGuard sync.Locker // Held for the duration of every direct RunStream call; nil when not attached to a SessionManager (see WithStreamGuard)

Expand Down Expand Up @@ -100,7 +104,10 @@ func WithQueuedMessages(msgs []string) Opt {

// WithTitleGenerator sets the title generator for local title generation.
// If not set, title generation will be handled by the runtime (for remote) or skipped.
func WithTitleGenerator(gen *sessiontitle.Generator) Opt {
func WithTitleGenerator(gen titleGenerator) Opt {
if concrete, ok := gen.(*sessiontitle.Generator); ok && concrete == nil {
gen = nil
}
return func(a *App) {
a.titleGen = gen
}
Expand Down
45 changes: 9 additions & 36 deletions pkg/app/session_lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,15 @@ package app

import (
"context"
"io"
"testing"
"testing/synctest"

tea "charm.land/bubbletea/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/docker/docker-agent/pkg/chat"
"github.com/docker/docker-agent/pkg/model/provider/base"
"github.com/docker/docker-agent/pkg/modelsdev"
"github.com/docker/docker-agent/pkg/runtime"
"github.com/docker/docker-agent/pkg/session"
"github.com/docker/docker-agent/pkg/sessiontitle"
"github.com/docker/docker-agent/pkg/tools"
skillstool "github.com/docker/docker-agent/pkg/tools/builtin/skills"
)
Expand Down Expand Up @@ -215,42 +210,20 @@ func TestAppRunDropsCanceledWorkWaitingForStreamGuard(t *testing.T) {
}
}

type blockingTitleProvider struct {
type blockingTitleGenerator struct {
started chan struct{}
release chan struct{}
}

func (p *blockingTitleProvider) ID() modelsdev.ID {
return modelsdev.NewID("test", "title")
func (g *blockingTitleGenerator) Generate(context.Context, string, []string) (string, error) {
close(g.started)
<-g.release
return "Original title", nil
}

func (p *blockingTitleProvider) BaseConfig() base.Config { return base.Config{} }

func (p *blockingTitleProvider) CreateChatCompletionStream(context.Context, []chat.Message, []tools.Tool) (chat.MessageStream, error) {
close(p.started)
<-p.release
return &singleTitleStream{}, nil
}

type singleTitleStream struct {
done bool
}

func (s *singleTitleStream) Recv() (chat.MessageStreamResponse, error) {
if s.done {
return chat.MessageStreamResponse{}, io.EOF
}
s.done = true
return chat.MessageStreamResponse{
Choices: []chat.MessageStreamChoice{{Delta: chat.MessageDelta{Content: "Original title"}}},
}, nil
}

func (*singleTitleStream) Close() {}

func TestGenerateTitleKeepsOriginalSession(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
provider := &blockingTitleProvider{
generator := &blockingTitleGenerator{
started: make(chan struct{}),
release: make(chan struct{}),
}
Expand All @@ -259,18 +232,18 @@ func TestGenerateTitleKeepsOriginalSession(t *testing.T) {
runtime: &mockRuntime{},
session: oldSession,
events: make(chan tea.Msg, 1),
titleGen: sessiontitle.New(provider),
titleGen: generator,
}

done := make(chan struct{})
go func() {
defer close(done)
app.generateTitle(t.Context(), oldSession, []string{"hello"})
}()
<-provider.started
<-generator.started
newSession := session.New()
app.ReplaceSession(t.Context(), newSession)
close(provider.release)
close(generator.release)
<-done

assert.Equal(t, "Original title", oldSession.TitleSnapshot())
Expand Down
87 changes: 87 additions & 0 deletions pkg/app/title_generator_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package app

import (
"context"
"errors"
"testing"
"testing/synctest"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/docker/docker-agent/pkg/runtime"
"github.com/docker/docker-agent/pkg/session"
"github.com/docker/docker-agent/pkg/sessiontitle"
)

type recordingTitleGenerator struct {
calls int
id string
messages []string
title string
err error
}

func (g *recordingTitleGenerator) Generate(_ context.Context, id string, messages []string) (string, error) {
g.calls++
g.id = id
g.messages = messages
return g.title, g.err
}

func TestTitleGeneratorContract(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
title string
err error
}{
{name: "success", title: "Generated title"},
{name: "empty"},
{name: "failure", err: errors.New("generation failed")},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
sess := session.New()
sess.AddMessage(session.UserMessage("hello"))
gen := &recordingTitleGenerator{title: tc.title, err: tc.err}
app := New(t.Context(), &mockRuntime{}, sess, WithTitleGenerator(gen))
assert.Zero(t, gen.calls)
require.NoError(t, app.RegenerateSessionTitle(t.Context()))
synctest.Wait()
assert.Equal(t, 1, gen.calls)
assert.Equal(t, sess.ID, gen.id)
assert.Equal(t, []string{"hello"}, gen.messages)
assert.False(t, app.IsTitleGenerating())
event, ok := unwrappedTestEvent(<-app.events).(*runtime.SessionTitleEvent)
require.True(t, ok)
assert.Equal(t, sess.ID, event.SessionID)
want := tc.title
if tc.err != nil {
want = ""
}
assert.Equal(t, want, sess.TitleSnapshot())
assert.Equal(t, want, event.Title)
})
})
}
}

func TestNilTitleGenerator(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
gen titleGenerator
}{
{"nil", nil}, {"typed nil", (*sessiontitle.Generator)(nil)}, {"no providers", sessiontitle.New()},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
app := New(t.Context(), &mockRuntime{}, session.New(), WithTitleGenerator(tc.gen))
assert.Nil(t, app.titleGen)
require.EqualError(t, app.RegenerateSessionTitle(t.Context()), "title regeneration not available")
assert.False(t, app.IsTitleGenerating())
})
}
}
97 changes: 97 additions & 0 deletions pkg/runtime/command_source_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package runtime

import (
"context"
"testing"

"github.com/stretchr/testify/assert"

"github.com/docker/docker-agent/pkg/config/types"
"github.com/docker/docker-agent/pkg/tools"
)

type lookupOnlySource struct {
calls int
}

func (s *lookupOnlySource) CurrentAgentInfo(context.Context) CurrentAgentInfo {
s.calls++
return CurrentAgentInfo{Commands: types.Commands{"test": {Instruction: "instruction"}}}
}

func TestCommandLookupNeedsOnlyAgentInfo(t *testing.T) {
t.Parallel()
source := &lookupOnlySource{}
_, _, ok := LookupCommand(t.Context(), source, "ordinary message")
assert.False(t, ok)
assert.Zero(t, source.calls)
command, rest, ok := LookupCommand(t.Context(), source, "/test arguments")
assert.True(t, ok)
assert.Equal(t, "instruction", command.Instruction)
assert.Equal(t, "arguments", rest)
assert.Equal(t, 1, source.calls)
}

type recordingCommandSource struct {
commands types.Commands
calls []string
}

func (s *recordingCommandSource) CurrentAgentInfo(context.Context) CurrentAgentInfo {
s.calls = append(s.calls, "info")
return CurrentAgentInfo{Commands: s.commands}
}

func (s *recordingCommandSource) CurrentAgentTools(context.Context) ([]tools.Tool, error) {
s.calls = append(s.calls, "tools")
return []tools.Tool{{Name: "echo", Handler: func(context.Context, tools.ToolCall, tools.Runtime) (*tools.ToolCallResult, error) {
s.calls = append(s.calls, "handler")
return tools.ResultSuccess("${untrusted}"), nil
}}}, nil
}

func (s *recordingCommandSource) CommandEvaluatorFactory() CommandEvaluatorFactory {
s.calls = append(s.calls, "factory")
return func([]tools.Tool) CommandEvaluator {
return commandEvaluatorFunc(func(_ context.Context, instruction string, _ []string) string {
s.calls = append(s.calls, "evaluate")
return instruction
})
}
}

func TestCommandSourcePreservesLazyDiscovery(t *testing.T) {
t.Parallel()
source := &recordingCommandSource{commands: types.Commands{
"agent": {Agent: "worker"},
"test": {Instruction: "!echo()"},
}}
assert.Equal(t, "message", ResolveCommand(t.Context(), source, "message"))
assert.Empty(t, source.calls)
assert.Equal(t, "/unknown", ResolveCommand(t.Context(), source, "/unknown"))
assert.Equal(t, []string{"info"}, source.calls)
source.calls = nil
assert.Equal(t, "task", ResolveCommand(t.Context(), source, "/agent task"))
assert.Equal(t, []string{"info"}, source.calls)
source.calls = nil
assert.Equal(t, "${untrusted}", ResolveCommand(t.Context(), source, "/test"))
assert.Equal(t, []string{"info", "factory", "tools", "evaluate", "tools", "handler"}, source.calls)
}

type toolsOnlySource struct {
calls int
}

func (s *toolsOnlySource) CurrentAgentTools(context.Context) ([]tools.Tool, error) {
s.calls++
return nil, nil
}

func TestToolCommandsNeedOnlyTools(t *testing.T) {
t.Parallel()
source := &toolsOnlySource{}
assert.Equal(t, "ordinary text", executeToolCommands(t.Context(), source, "ordinary text"))
assert.Zero(t, source.calls)
assert.Contains(t, executeToolCommands(t.Context(), source, "!missing()"), "not found")
assert.Equal(t, 1, source.calls)
}
19 changes: 16 additions & 3 deletions pkg/runtime/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,26 @@ func globalCommandFactory() CommandEvaluatorFactory {
// This includes ${args}, ${args[N]}, ${args.join(...)}, ${args.length}, etc.
var argsPlaceholderRegex = regexp.MustCompile(`\$\{args[^}]*\}`)

type commandLookup interface {
CurrentAgentInfo(ctx context.Context) CurrentAgentInfo
}

type commandTools interface {
CurrentAgentTools(ctx context.Context) ([]tools.Tool, error)
}

type commandSource interface {
commandLookup
commandTools
}

// LookupCommand parses userInput as a /command invocation and returns the
// matching command along with its trailing arguments. The boolean is false
// when userInput doesn't start with '/' or doesn't match a configured
// command. Callers that need both the resolved instruction and the original
// command metadata (e.g. its target agent) typically call LookupCommand to
// inspect the command before calling ResolveCommand.
func LookupCommand(ctx context.Context, rt Runtime, userInput string) (cmd types.Command, rest string, ok bool) {
func LookupCommand(ctx context.Context, rt commandLookup, userInput string) (cmd types.Command, rest string, ok bool) {
if !strings.HasPrefix(userInput, "/") {
return types.Command{}, "", false
}
Expand Down Expand Up @@ -104,7 +117,7 @@ func LookupCommand(ctx context.Context, rt Runtime, userInput string) (cmd types
// caller can forward them to the target sub-agent after switching. When the
// command has no instruction and no arguments, the result is the empty
// string, signalling "no message to send".
func ResolveCommand(ctx context.Context, rt Runtime, userInput string) string {
func ResolveCommand(ctx context.Context, rt commandSource, userInput string) string {
command, rest, ok := LookupCommand(ctx, rt, userInput)
if !ok {
return userInput
Expand Down Expand Up @@ -270,7 +283,7 @@ func isWordChar(b byte) bool {
}

// executeToolCommands executes !tool_name(arg=value) patterns and replaces them with output.
func executeToolCommands(ctx context.Context, rt Runtime, instruction string) string {
func executeToolCommands(ctx context.Context, rt commandTools, instruction string) string {
commands := parseToolCommands(instruction)
if len(commands) == 0 {
return instruction
Expand Down
Loading
Loading