diff --git a/go/adk/cmd/main.go b/go/adk/cmd/main.go index 8da1fd47ca..db89411d5e 100644 --- a/go/adk/cmd/main.go +++ b/go/adk/cmd/main.go @@ -13,6 +13,7 @@ import ( a2atype "github.com/a2aproject/a2a-go/v2/a2a" "github.com/kagent-dev/kagent/go/adk/pkg/a2a" + agentpkg "github.com/kagent-dev/kagent/go/adk/pkg/agent" "github.com/kagent-dev/kagent/go/adk/pkg/app" "github.com/kagent-dev/kagent/go/adk/pkg/auth" "github.com/kagent-dev/kagent/go/adk/pkg/config" @@ -178,6 +179,21 @@ func run(logLevel, host, port, configDir string) error { return fmt.Errorf("create Google ADK Runner config: %w", err) } + var titler a2a.Titler + var recapper a2a.Recapper + if agentConfig.Model != nil { + agentTitler, err := agentpkg.NewTitler(ctx, agentConfig.Model) + if err != nil { + return fmt.Errorf("create titler: %w", err) + } + titler = agentTitler + agentRecapper, err := agentpkg.NewRecapper(ctx, agentConfig.Model) + if err != nil { + return fmt.Errorf("create recapper: %w", err) + } + recapper = agentRecapper + } + stream := agentConfig.GetStream() executor, err := a2a.NewKAgentExecutor(a2a.KAgentExecutorConfig{ RunnerConfig: runnerConfig, @@ -188,6 +204,10 @@ func run(logLevel, host, port, configDir string) error { Output: agentConfig.Output, Flush: providers.ForceFlush, AgentName: runtimeTelemetry.AgentName, + + ControllerClient: controllerClient, + Titler: titler, + Recapper: recapper, }) if err != nil { return fmt.Errorf("create A2A executor: %w", err) diff --git a/go/adk/pkg/a2a/executor.go b/go/adk/pkg/a2a/executor.go index 03b4c845b3..15c994be18 100644 --- a/go/adk/pkg/a2a/executor.go +++ b/go/adk/pkg/a2a/executor.go @@ -8,6 +8,7 @@ import ( "log/slog" "slices" "strings" + "sync" "time" a2atype "github.com/a2aproject/a2a-go/v2/a2a" @@ -22,16 +23,22 @@ import ( "google.golang.org/genai" "github.com/kagent-dev/kagent/go/adk/pkg/auth" + "github.com/kagent-dev/kagent/go/adk/pkg/controllerclient" "github.com/kagent-dev/kagent/go/adk/pkg/mcp" "github.com/kagent-dev/kagent/go/adk/pkg/models" "github.com/kagent-dev/kagent/go/adk/pkg/telemetry" apia2a "github.com/kagent-dev/kagent/go/api/a2a" + apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" configv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/config/v1" "github.com/kagent-dev/kagent/go/pkg/tracing" ) const ( sessionNameMaxLength = 20 + defaultTitleTimeout = 3 * time.Second + titleMaxLength = 200 + // titleGenerationTimeout bounds the model call that writes a title. + titleGenerationTimeout = 30 * time.Second ) // KAgentExecutorConfig holds the configuration for KAgentExecutor. @@ -44,6 +51,16 @@ type KAgentExecutorConfig struct { Output *configv1.ADKOutputConfig Flush func(context.Context) error AgentName string + // ControllerClient and Titler together enable first-turn chat titles. + ControllerClient *controllerclient.Client + Titler Titler + // Recapper, with ControllerClient, enables chat recaps after longer chats. + Recapper Recapper +} + +// Titler generates a short chat title from the first user message. +type Titler interface { + Title(ctx context.Context, userMessage string) (string, error) } // KAgentExecutor keeps kagent's request/session glue around the upstream ADK @@ -57,6 +74,13 @@ type KAgentExecutor struct { flush func(context.Context) error runningUsage runningUsage agentName string + controllerClient *controllerclient.Client + titler Titler + titleTimeout time.Duration + recapper Recapper + recapWait time.Duration + recapMu sync.Mutex + recap *pendingRecap } type structuredOutput struct { @@ -128,6 +152,11 @@ func NewKAgentExecutor(cfg KAgentExecutorConfig) (*KAgentExecutor, error) { structuredOutputEnabled: output != nil, flush: cfg.Flush, agentName: cfg.AgentName, + controllerClient: cfg.ControllerClient, + titler: cfg.Titler, + titleTimeout: defaultTitleTimeout, + recapper: cfg.Recapper, + recapWait: recapCleanupWait, }, nil } @@ -300,10 +329,13 @@ func (e *KAgentExecutor) Execute(ctx context.Context, reqCtx *a2asrv.ExecutorCon // Run our own session management before upstream executor runs its prepareSession function. // This ensures that we create a session that contains metadata like x-kagent-source, // and the upstream executor will find this session already exists and skip creation. - if err := e.ensureSession(ctx, reqCtx.Message, userID, sessionID); err != nil { + created, err := e.ensureSession(ctx, reqCtx.Message, userID, sessionID) + if err != nil { yield(nil, err) return } + e.cancelRecap() + finishTitle := e.startTitle(ctx, reqCtx.Message, sessionID, created) hitlActivated := HitlActivated(ctx) if hitlActivated && IsHITLResponse(reqCtx.Message) { @@ -355,6 +387,10 @@ func (e *KAgentExecutor) Execute(ctx context.Context, reqCtx *a2asrv.ExecutorCon stampStatusMessageTimeline(event) canonicalizeADKEvent(event) if endsTurn(event, err) { + if turnCompleted(event, err) { + finishTitle(ctx) + e.startRecap(ctx, userID, sessionID) + } e.flushTurnSpans(ctx) } if !yield(event, err) { @@ -410,6 +446,94 @@ func endsTurn(event a2atype.Event, err error) bool { return state.Terminal() || state == a2atype.TaskStateInputRequired || state == a2atype.TaskStateAuthRequired } +// startTitle begins title generation for a new session and returns the function +// that waits for it and writes the title. +func (e *KAgentExecutor) startTitle(ctx context.Context, message *a2atype.Message, sessionID string, created bool) func(context.Context) { + noop := func(context.Context) {} + if !created || e.titler == nil || e.controllerClient == nil { + return noop + } + // The call outlives the request but not the model's own deadline. + titleCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), titleGenerationTimeout) + text := userText(message) + result := make(chan string, 1) + go func() { + defer cancel() + title, err := e.titler.Title(titleCtx, text) + if err != nil { + e.logger.DebugContext(ctx, "title generation failed", "error", err, "session_id", sessionID) + title = "" + } + result <- cleanTitle(title) + }() + + var once sync.Once + return func(ctx context.Context) { + once.Do(func() { + timer := time.NewTimer(e.titleTimeout) + defer timer.Stop() + var title string + select { + case title = <-result: + case <-timer.C: + e.logger.DebugContext(ctx, "title not ready in time", "session_id", sessionID) + case <-ctx.Done(): + } + if title == "" { + return + } + callCtx, callCancel := e.controllerClient.CallContext(ctx, "") + defer callCancel() + if _, err := e.controllerClient.SessionService().SetSessionNameIfEmpty(callCtx, &apiv1.SetSessionNameIfEmptyRequest{ + SessionId: sessionID, Name: title, + }); err != nil { + e.logger.WarnContext(ctx, "failed to set session title", "error", err, "session_id", sessionID) + } + }) + } +} + +// userText joins the text parts of a message. +func userText(message *a2atype.Message) string { + if message == nil { + return "" + } + var texts []string + for _, part := range message.Parts { + if part != nil && part.Text() != "" { + texts = append(texts, part.Text()) + } + } + return strings.Join(texts, "\n") +} + +// turnCompleted reports whether the event ends the turn successfully. +func turnCompleted(event a2atype.Event, err error) bool { + if err != nil { + return false + } + update, ok := event.(*a2atype.TaskStatusUpdateEvent) + return ok && update.Status.State == a2atype.TaskStateCompleted +} + +// cleanTitle turns raw model output into a short single-line chat title. +func cleanTitle(raw string) string { + // Keep only the first non-empty line, with whitespace collapsed. + var title string + for l := range strings.SplitSeq(raw, "\n") { + if words := strings.Fields(l); len(words) > 0 { + title = strings.Join(words, " ") + break + } + } + title = strings.Trim(title, "\"'`“”‘’") + title = strings.TrimRight(title, ".,;:!?。 ") + if runes := []rune(title); len(runes) > titleMaxLength { + title = strings.TrimSpace(string(runes[:titleMaxLength])) + } + return title +} + // flushTurnSpans exports the turn's spans before the event that ends the turn // leaves the process. // @@ -431,15 +555,15 @@ func (e *KAgentExecutor) flushTurnSpans(ctx context.Context) { // ensureSession ensures that a session exists for the given user and session ID. // If a session does not exist, it creates a new session with the given user and session ID. -func (e *KAgentExecutor) ensureSession(ctx context.Context, message *a2atype.Message, userID, sessionID string) error { +func (e *KAgentExecutor) ensureSession(ctx context.Context, message *a2atype.Message, userID, sessionID string) (bool, error) { if e.sessionService == nil { - return nil + return false, nil } resp, err := e.sessionService.Get(ctx, &adksession.GetRequest{ AppName: e.appName, UserID: userID, SessionID: sessionID, }) if err == nil && resp != nil && resp.Session != nil { - return nil + return false, nil } if err != nil { e.logger.DebugContext(ctx, "session lookup failed, will create", "error", err, "session_id", sessionID) @@ -459,9 +583,9 @@ func (e *KAgentExecutor) ensureSession(ctx context.Context, message *a2atype.Mes if _, err := e.sessionService.Create(ctx, &adksession.CreateRequest{ AppName: e.appName, UserID: userID, State: state, SessionID: sessionID, }); err != nil { - return fmt.Errorf("failed to create session: %w", err) + return false, fmt.Errorf("failed to create session: %w", err) } - return nil + return true, nil } // Cancel delegates cancellation to the upstream executor. @@ -490,6 +614,7 @@ func (e *KAgentExecutor) Cleanup(ctx context.Context, reqCtx *a2asrv.ExecutorCon if cleaner, ok := e.builtin.(a2asrv.AgentExecutionCleaner); ok { cleaner.Cleanup(ctx, reqCtx, result, cause) } + e.waitRecap(ctx, reqCtx.ContextID) } // extractSessionName extracts session name from the first text part of a message. diff --git a/go/adk/pkg/a2a/executor_recap_test.go b/go/adk/pkg/a2a/executor_recap_test.go new file mode 100644 index 0000000000..25fda40f88 --- /dev/null +++ b/go/adk/pkg/a2a/executor_recap_test.go @@ -0,0 +1,345 @@ +package a2a + +import ( + "context" + "errors" + "log/slog" + "net" + "strings" + "sync" + "testing" + "time" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2asrv" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + adkmodel "google.golang.org/adk/v2/model" + adksession "google.golang.org/adk/v2/session" + "google.golang.org/genai" + "google.golang.org/grpc" + "google.golang.org/grpc/test/bufconn" + + "github.com/kagent-dev/kagent/go/adk/pkg/controllerclient" + apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" +) + +type recapWrite struct { + sessionID, text string + turnCount int32 +} + +type recapSessionServer struct { + apiv1.UnimplementedSessionServiceServer + mu sync.Mutex + writes []recapWrite +} + +func (s *recapSessionServer) SetSessionRecap(_ context.Context, req *apiv1.SetSessionRecapRequest) (*apiv1.SetSessionRecapResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.writes = append(s.writes, recapWrite{req.GetSessionId(), req.GetText(), req.GetTurnCount()}) + return &apiv1.SetSessionRecapResponse{}, nil +} + +func (s *recapSessionServer) snapshot() []recapWrite { + s.mu.Lock() + defer s.mu.Unlock() + return append([]recapWrite(nil), s.writes...) +} + +type fakeRecapper struct { + mu sync.Mutex + transcripts []string + text string + err error + started chan struct{} + // release, when set, holds Recap until it is closed. + release chan struct{} + // hang holds Recap until its context ends. + hang bool + cancelled bool +} + +func newFakeRecapper(text string) *fakeRecapper { + return &fakeRecapper{text: text, started: make(chan struct{}, 10)} +} + +func (f *fakeRecapper) Recap(ctx context.Context, transcript string) (string, error) { + f.mu.Lock() + f.transcripts = append(f.transcripts, transcript) + f.mu.Unlock() + f.started <- struct{}{} + if f.hang { + <-ctx.Done() + f.mu.Lock() + f.cancelled = true + f.mu.Unlock() + return "", ctx.Err() + } + if f.release != nil { + <-f.release + } + return f.text, f.err +} + +func (f *fakeRecapper) calls() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.transcripts...) +} + +func (f *fakeRecapper) sawCancel() bool { + f.mu.Lock() + defer f.mu.Unlock() + return f.cancelled +} + +type recapFixture struct { + t *testing.T + server *recapSessionServer + recapper *fakeRecapper + builtin *recordingExecutor + executor *KAgentExecutor + // writesAtTerminal is what the controller had received when the last terminal event reached the consumer. + writesAtTerminal []recapWrite + // events are the events the last turn delivered, in order. + events []a2atype.Event +} + +func newRecapFixture(t *testing.T) *recapFixture { + t.Helper() + listener := bufconn.Listen(1024 * 1024) + server := &recapSessionServer{} + grpcServer := grpc.NewServer() + apiv1.RegisterSessionServiceServer(grpcServer, server) + go func() { _ = grpcServer.Serve(listener) }() + t.Cleanup(func() { + grpcServer.Stop() + _ = listener.Close() + }) + client, err := controllerclient.New(controllerclient.Config{ + APIURL: "http://bufnet:80", + DialOptions: []grpc.DialOption{grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { + return listener.Dial() + })}, + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, client.Close()) }) + + f := &recapFixture{t: t, server: server, recapper: newFakeRecapper("Debugged the login page")} + f.builtin = &recordingExecutor{} + f.executor = &KAgentExecutor{ + builtin: f.builtin, + sessionService: adksession.InMemoryService(), + appName: "test-app", + logger: slog.New(slog.DiscardHandler), + controllerClient: client, + recapper: f.recapper, + recapWait: time.Minute, + } + // Each turn adds the user message and an agent reply to the ADK session, as the runner does. + f.builtin.onExecute = f.appendTurn + return f.givenTurnEndsIn(a2atype.TaskStateCompleted) +} + +func (f *recapFixture) givenTurnEndsIn(state a2atype.TaskState) *recapFixture { + reqCtx := &a2asrv.ExecutorContext{TaskID: "task-1", ContextID: "context-1"} + f.builtin.events = []a2atype.Event{ + a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateWorking, nil), + a2atype.NewStatusUpdateEvent(reqCtx, state, nil), + } + return f +} + +func (f *recapFixture) appendTurn(ctx context.Context) { + resp, err := f.executor.sessionService.Get(ctx, &adksession.GetRequest{ + AppName: "test-app", UserID: "A2A_USER_context-1", SessionID: "context-1", + }) + require.NoError(f.t, err) + user := userText(f.builtin.message) + require.NoError(f.t, f.executor.sessionService.AppendEvent(ctx, resp.Session, textEvent("user", user))) + require.NoError(f.t, f.executor.sessionService.AppendEvent(ctx, resp.Session, textEvent("agent", "reply to "+user))) +} + +func textEvent(author, text string) *adksession.Event { + role := genai.RoleModel + if author == "user" { + role = genai.RoleUser + } + return &adksession.Event{ + Author: author, + LLMResponse: adkmodel.LLMResponse{Content: genai.NewContentFromText(text, genai.Role(role))}, + } +} + +func (f *recapFixture) whenTurnsRun(messages ...string) *recapFixture { + f.t.Helper() + for _, message := range messages { + reqCtx := &a2asrv.ExecutorContext{ + TaskID: "task-1", ContextID: "context-1", + Message: a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart(message)), + } + terminalSeen := false + f.events = nil + for event, err := range f.executor.Execute(f.t.Context(), reqCtx) { + require.NoError(f.t, err) + f.events = append(f.events, event) + if update, ok := event.(*a2atype.TaskStatusUpdateEvent); ok && update.Status.State != a2atype.TaskStateWorking { + terminalSeen = true + f.writesAtTerminal = f.server.snapshot() + } + } + require.True(f.t, terminalSeen, "turn must deliver its terminal event") + } + return f +} + +func (f *recapFixture) whenCleanupRuns() *recapFixture { + f.t.Helper() + f.executor.Cleanup(f.t.Context(), &a2asrv.ExecutorContext{TaskID: "task-1", ContextID: "context-1"}, nil, nil) + return f +} + +// awaitRecapStarted fails the test rather than hanging when no recap call starts. +func (f *recapFixture) awaitRecapStarted() { + f.t.Helper() + select { + case <-f.recapper.started: + case <-time.After(2 * time.Second): + f.t.Fatal("recap was never requested") + } +} + +func TestRecapCleanupWaitStaysUnderDispatchWindow(t *testing.T) { + t.Run("Given the gateway retries dispatch for 10s, assert that cleanup waits less than that", func(t *testing.T) { + assert.Less(t, recapCleanupWait, 10*time.Second) + }) +} + +func TestChatRecap(t *testing.T) { + t.Run("Given a chat on its third turn, when the turn completes and cleanup runs, assert that the recap is written with the turns it covers", func(t *testing.T) { + f := newRecapFixture(t).whenTurnsRun("q1", "q2", "q3").whenCleanupRuns() + require.Len(t, f.recapper.calls(), 1) + assert.Contains(t, f.recapper.calls()[0], "User: q3") + assert.Contains(t, f.recapper.calls()[0], "Agent: reply to q3") + assert.Equal(t, []recapWrite{{"context-1", "Debugged the login page", 3}}, f.server.snapshot()) + }) + + t.Run("Given a chat on its first two turns, when each turn completes and cleanup runs, assert that no recap is requested", func(t *testing.T) { + f := newRecapFixture(t).whenTurnsRun("q1").whenCleanupRuns().whenTurnsRun("q2").whenCleanupRuns() + assert.Empty(t, f.recapper.calls()) + assert.Empty(t, f.server.snapshot()) + }) + + t.Run("Given a slow recapper, when the third turn completes, assert that the terminal event arrives before the recap and cleanup writes it", func(t *testing.T) { + f := newRecapFixture(t) + f.recapper.release = make(chan struct{}) + f.whenTurnsRun("q1", "q2", "q3") + assert.Empty(t, f.writesAtTerminal) + assert.Empty(t, f.server.snapshot()) + close(f.recapper.release) + f.whenCleanupRuns() + assert.Equal(t, []recapWrite{{"context-1", "Debugged the login page", 3}}, f.server.snapshot()) + }) + + t.Run("Given a recapper slower than the cleanup cap, when cleanup runs, assert that it returns without writing", func(t *testing.T) { + f := newRecapFixture(t) + f.executor.recapWait = 20 * time.Millisecond + f.recapper.hang = true + f.whenTurnsRun("q1", "q2", "q3").whenCleanupRuns() + assert.Len(t, f.recapper.calls(), 1) + assert.Empty(t, f.server.snapshot()) + }) + + t.Run("Given a recap longer than the limit, when cleanup runs, assert that the stored recap is cut to the limit", func(t *testing.T) { + f := newRecapFixture(t) + f.recapper.text = strings.Repeat("é", recapMaxLength+50) + f.whenTurnsRun("q1", "q2", "q3").whenCleanupRuns() + writes := f.server.snapshot() + require.Len(t, writes, 1) + assert.Equal(t, strings.Repeat("é", recapMaxLength), writes[0].text) + }) + + t.Run("Given a recapper that fails, when cleanup runs, assert that it returns without writing", func(t *testing.T) { + f := newRecapFixture(t) + f.recapper.err = errors.New("model unavailable") + f.whenTurnsRun("q1", "q2", "q3").whenCleanupRuns() + assert.Len(t, f.recapper.calls(), 1) + assert.Empty(t, f.server.snapshot()) + }) + + t.Run("Given a recapper that returns nothing, when cleanup runs, assert that it returns without writing", func(t *testing.T) { + f := newRecapFixture(t) + f.recapper.text = " \n" + f.whenTurnsRun("q1", "q2", "q3").whenCleanupRuns() + assert.Len(t, f.recapper.calls(), 1) + assert.Empty(t, f.server.snapshot()) + }) + + t.Run("Given a recap in flight, when a new request starts, assert that the recap is cancelled and nothing is written", func(t *testing.T) { + f := newRecapFixture(t) + f.recapper.hang = true + f.whenTurnsRun("q1", "q2", "q3") + f.awaitRecapStarted() + f.givenTurnEndsIn(a2atype.TaskStateInputRequired).whenTurnsRun("q4").whenCleanupRuns() + assert.True(t, f.recapper.sawCancel()) + assert.Empty(t, f.server.snapshot()) + }) + + for _, state := range []a2atype.TaskState{a2atype.TaskStateFailed, a2atype.TaskStateInputRequired} { + t.Run("Given a chat with two turns, when the third turn ends "+string(state)+", assert that no recap is requested or written", func(t *testing.T) { + f := newRecapFixture(t).whenTurnsRun("q1", "q2").givenTurnEndsIn(state).whenTurnsRun("q3").whenCleanupRuns() + assert.Empty(t, f.recapper.calls()) + assert.Empty(t, f.server.snapshot()) + }) + } + + t.Run("Given no recapper, when the third turn completes, assert that nothing is written", func(t *testing.T) { + f := newRecapFixture(t) + f.executor.recapper = nil + f.whenTurnsRun("q1", "q2", "q3").whenCleanupRuns() + assert.Empty(t, f.server.snapshot()) + }) + + t.Run("Given a recapper and no controller client, when the third turn completes, assert that the recapper is not called", func(t *testing.T) { + f := newRecapFixture(t) + f.executor.controllerClient = nil + f.whenTurnsRun("q1", "q2", "q3").whenCleanupRuns() + assert.Empty(t, f.recapper.calls()) + }) +} + +func TestCountUserTurns(t *testing.T) { + toolResult := &adksession.Event{Author: "user", LLMResponse: adkmodel.LLMResponse{Content: &genai.Content{ + Role: genai.RoleUser, Parts: []*genai.Part{genai.NewPartFromFunctionResponse("search", map[string]any{"ok": true})}, + }}} + t.Run("Given user text, agent text and a tool result, when turns are counted, assert that only the user text counts", func(t *testing.T) { + events := []*adksession.Event{ + textEvent("user", "q1"), textEvent("agent", "a1"), toolResult, + textEvent("user", "q2"), textEvent("agent", "a2"), + } + assert.Equal(t, 2, countUserTurns(events)) + }) + t.Run("Given no events, when turns are counted, assert that the count is zero", func(t *testing.T) { + assert.Zero(t, countUserTurns(nil)) + }) +} + +func TestBuildTranscript(t *testing.T) { + t.Run("Given user and agent text, when the transcript is built, assert that each turn is labelled in order", func(t *testing.T) { + events := []*adksession.Event{textEvent("user", "q1"), textEvent("agent", "a1"), textEvent("user", "q2")} + transcript := buildTranscript(events, 1000) + assert.Equal(t, 0, strings.Index(transcript, "User: q1")) + assert.Less(t, strings.Index(transcript, "User: q1"), strings.Index(transcript, "Agent: a1")) + assert.Less(t, strings.Index(transcript, "Agent: a1"), strings.Index(transcript, "User: q2")) + }) + t.Run("Given a chat longer than the cap, when the transcript is built, assert that it is capped and keeps the latest turn", func(t *testing.T) { + events := []*adksession.Event{ + textEvent("user", strings.Repeat("old ", 100)), textEvent("agent", "the latest answer"), + } + transcript := buildTranscript(events, 50) + assert.LessOrEqual(t, len([]rune(transcript)), 50) + assert.Contains(t, transcript, "the latest answer") + }) +} diff --git a/go/adk/pkg/a2a/executor_title_test.go b/go/adk/pkg/a2a/executor_title_test.go new file mode 100644 index 0000000000..4c0aafc080 --- /dev/null +++ b/go/adk/pkg/a2a/executor_title_test.go @@ -0,0 +1,216 @@ +package a2a + +import ( + "context" + "errors" + "log/slog" + "net" + "sync" + "testing" + "time" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2asrv" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + adksession "google.golang.org/adk/v2/session" + "google.golang.org/grpc" + "google.golang.org/grpc/test/bufconn" + + "github.com/kagent-dev/kagent/go/adk/pkg/controllerclient" + apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" +) + +type nameWrite struct{ sessionID, name string } + +type recordingSessionServer struct { + apiv1.UnimplementedSessionServiceServer + mu sync.Mutex + writes []nameWrite +} + +func (s *recordingSessionServer) SetSessionNameIfEmpty(_ context.Context, req *apiv1.SetSessionNameIfEmptyRequest) (*apiv1.SetSessionNameIfEmptyResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.writes = append(s.writes, nameWrite{req.GetSessionId(), req.GetName()}) + return &apiv1.SetSessionNameIfEmptyResponse{}, nil +} + +func (s *recordingSessionServer) snapshot() []nameWrite { + s.mu.Lock() + defer s.mu.Unlock() + return append([]nameWrite(nil), s.writes...) +} + +type fakeTitler struct { + mu sync.Mutex + messages []string + title string + err error + // block makes Title wait for the caller's context to end. + block bool +} + +func (f *fakeTitler) Title(ctx context.Context, userMessage string) (string, error) { + f.mu.Lock() + f.messages = append(f.messages, userMessage) + f.mu.Unlock() + if f.block { + <-ctx.Done() + return "", ctx.Err() + } + return f.title, f.err +} + +func (f *fakeTitler) calls() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.messages...) +} + +type titleFixture struct { + t *testing.T + server *recordingSessionServer + client *controllerclient.Client + titler *fakeTitler + executor *KAgentExecutor + // writesAtTerminal is what the controller had received when the terminal event reached the consumer. + writesAtTerminal []nameWrite + terminalSeen bool +} + +func newTitleFixture(t *testing.T) *titleFixture { + t.Helper() + listener := bufconn.Listen(1024 * 1024) + server := &recordingSessionServer{} + grpcServer := grpc.NewServer() + apiv1.RegisterSessionServiceServer(grpcServer, server) + go func() { _ = grpcServer.Serve(listener) }() + t.Cleanup(func() { + grpcServer.Stop() + _ = listener.Close() + }) + client, err := controllerclient.New(controllerclient.Config{ + APIURL: "http://bufnet:80", + DialOptions: []grpc.DialOption{grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { + return listener.Dial() + })}, + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, client.Close()) }) + return &titleFixture{t: t, server: server, client: client, titler: &fakeTitler{title: "Fix login bug"}} +} + +func (f *titleFixture) withTerminalState(state a2atype.TaskState) *titleFixture { + reqCtx := &a2asrv.ExecutorContext{TaskID: "task-1", ContextID: "context-1"} + builtin := &recordingExecutor{events: []a2atype.Event{ + a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateWorking, nil), + a2atype.NewStatusUpdateEvent(reqCtx, state, nil), + }} + f.executor = &KAgentExecutor{ + builtin: builtin, + sessionService: adksession.InMemoryService(), + appName: "test-app", + logger: slog.New(slog.DiscardHandler), + controllerClient: f.client, + titler: f.titler, + titleTimeout: 50 * time.Millisecond, + } + return f +} + +func (f *titleFixture) whenTurnRuns(message string) *titleFixture { + f.t.Helper() + f.terminalSeen = false + reqCtx := &a2asrv.ExecutorContext{ + TaskID: "task-1", ContextID: "context-1", + Message: a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart(message)), + } + for event, err := range f.executor.Execute(f.t.Context(), reqCtx) { + require.NoError(f.t, err) + if update, ok := event.(*a2atype.TaskStatusUpdateEvent); ok && update.Status.State != a2atype.TaskStateWorking { + f.terminalSeen = true + f.writesAtTerminal = f.server.snapshot() + } + } + require.True(f.t, f.terminalSeen, "turn must deliver its terminal event") + return f +} + +func TestFirstTurnTitle(t *testing.T) { + t.Run("Given a new session, when the first turn completes, assert that the title is written before the terminal event is delivered", func(t *testing.T) { + f := newTitleFixture(t).withTerminalState(a2atype.TaskStateCompleted).whenTurnRuns("my login page is broken") + assert.Equal(t, []string{"my login page is broken"}, f.titler.calls()) + assert.Equal(t, []nameWrite{{"context-1", "Fix login bug"}}, f.writesAtTerminal) + }) + + t.Run("Given a session that already had a turn, when a second turn completes, assert that no title is requested or written", func(t *testing.T) { + f := newTitleFixture(t).withTerminalState(a2atype.TaskStateCompleted).whenTurnRuns("first") + before := len(f.server.snapshot()) + f.whenTurnRuns("second") + assert.Equal(t, []string{"first"}, f.titler.calls()) + assert.Len(t, f.server.snapshot(), before) + }) + + t.Run("Given a titler that never answers, when the first turn completes, assert that the terminal event is delivered and nothing is written", func(t *testing.T) { + f := newTitleFixture(t) + f.titler.block = true + f.withTerminalState(a2atype.TaskStateCompleted).whenTurnRuns("hello") + assert.Len(t, f.titler.calls(), 1) + assert.Empty(t, f.server.snapshot()) + }) + + t.Run("Given a titler that fails, when the first turn completes, assert that the turn completes and nothing is written", func(t *testing.T) { + f := newTitleFixture(t) + f.titler.err = errors.New("model unavailable") + f.withTerminalState(a2atype.TaskStateCompleted).whenTurnRuns("hello") + assert.Len(t, f.titler.calls(), 1) + assert.Empty(t, f.server.snapshot()) + }) + + for _, state := range []a2atype.TaskState{a2atype.TaskStateFailed, a2atype.TaskStateInputRequired} { + t.Run("Given a new session, when the first turn ends "+string(state)+", assert that no title is written", func(t *testing.T) { + f := newTitleFixture(t).withTerminalState(state).whenTurnRuns("hello") + assert.Empty(t, f.server.snapshot()) + }) + } + + t.Run("Given no titler, when the first turn completes, assert that nothing is written", func(t *testing.T) { + f := newTitleFixture(t).withTerminalState(a2atype.TaskStateCompleted) + f.executor.titler = nil + f.whenTurnRuns("hello") + assert.Empty(t, f.server.snapshot()) + }) + + t.Run("Given a titler and no controller client, when the first turn completes, assert that the titler is not called", func(t *testing.T) { + f := newTitleFixture(t).withTerminalState(a2atype.TaskStateCompleted) + f.executor.controllerClient = nil + f.whenTurnRuns("hello") + assert.Empty(t, f.titler.calls()) + }) +} + +func TestCleanTitle(t *testing.T) { + long := "" + for len(long) < 300 { + long += "word " + } + scenarios := []struct { + name, raw, want string + }{ + {"surrounding quotes", `"Fix the login bug"`, "Fix the login bug"}, + {"trailing punctuation", "Fix the login bug.", "Fix the login bug"}, + {"padding and blank lines", "\n Fix the login bug \n", "Fix the login bug"}, + {"only the first line", "Fix the login bug\nThis title covers...", "Fix the login bug"}, + {"empty output", " \n ", ""}, + } + for _, s := range scenarios { + t.Run("Given model output with "+s.name+", when it is cleaned, assert that a plain title remains", func(t *testing.T) { + assert.Equal(t, s.want, cleanTitle(s.raw)) + }) + } + t.Run("Given model output over 200 characters, when it is cleaned, assert that the title is capped at 200", func(t *testing.T) { + assert.LessOrEqual(t, len([]rune(cleanTitle(long))), titleMaxLength) + assert.NotEmpty(t, cleanTitle(long)) + }) +} diff --git a/go/adk/pkg/a2a/recap.go b/go/adk/pkg/a2a/recap.go new file mode 100644 index 0000000000..4e127c52fb --- /dev/null +++ b/go/adk/pkg/a2a/recap.go @@ -0,0 +1,179 @@ +package a2a + +import ( + "context" + "slices" + "strings" + "time" + + adksession "google.golang.org/adk/v2/session" + + apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" +) + +const ( + // recapMinTurns is the number of user turns a chat needs before it gets a recap. + recapMinTurns = 3 + // recapTranscriptMaxLength caps the transcript, in characters, sent to the model. + recapTranscriptMaxLength = 20000 + // recapMaxLength is the longest recap, in characters, the session API accepts. + recapMaxLength = 2000 + // recapCleanupWait bounds how long a turn stays open after the answer + // while the recap is written. + recapCleanupWait = 8 * time.Second + // recapGenerationTimeout bounds the model call that writes a recap. + recapGenerationTimeout = 60 * time.Second +) + +// Recapper writes a short recap of a chat from its transcript. +type Recapper interface { + Recap(ctx context.Context, transcript string) (string, error) +} + +// eventText joins the text parts of an event, leaving out thoughts. +func eventText(event *adksession.Event) string { + if event == nil || event.Content == nil { + return "" + } + var texts []string + for _, part := range event.Content.Parts { + if part != nil && part.Text != "" && !part.Thought { + texts = append(texts, part.Text) + } + } + return strings.Join(texts, "\n") +} + +// countUserTurns counts the events in which the user sent text. +func countUserTurns(events []*adksession.Event) int { + turns := 0 + for _, event := range events { + if event != nil && event.Author == "user" && eventText(event) != "" { + turns++ + } + } + return turns +} + +// buildTranscript labels the user and agent text of the events, keeping the +// most recent maxLength characters. +func buildTranscript(events []*adksession.Event, maxLength int) string { + var lines []string + for _, event := range events { + text := eventText(event) + if text == "" { + continue + } + label := "Agent" + if event.Author == "user" { + label = "User" + } + lines = append(lines, label+": "+text) + } + runes := []rune(strings.Join(lines, "\n")) + if len(runes) > maxLength { + runes = runes[len(runes)-maxLength:] + } + return string(runes) +} + +// pendingRecap is a recap being generated in the background. +type pendingRecap struct { + sessionID string + turns int + cancel context.CancelFunc + // done closes after text is set. + done chan struct{} + text string +} + +// startRecap begins a recap when the chat has enough turns. +func (e *KAgentExecutor) startRecap(ctx context.Context, userID, sessionID string) { + if e.recapper == nil || e.controllerClient == nil { + return + } + resp, err := e.sessionService.Get(ctx, &adksession.GetRequest{AppName: e.appName, UserID: userID, SessionID: sessionID}) + if err != nil || resp == nil || resp.Session == nil { + e.logger.DebugContext(ctx, "recap skipped: session unavailable", "error", err, "session_id", sessionID) + return + } + events := slices.Collect(resp.Session.Events().All()) + turns := countUserTurns(events) + if turns < recapMinTurns { + return + } + transcript := buildTranscript(events, recapTranscriptMaxLength) + + // The call outlives the request but not its own deadline or the next request. + recapCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), recapGenerationTimeout) + recap := &pendingRecap{sessionID: sessionID, turns: turns, cancel: cancel, done: make(chan struct{})} + e.cancelRecap() + e.recapMu.Lock() + e.recap = recap + e.recapMu.Unlock() + go func() { + defer close(recap.done) + defer cancel() + text, err := e.recapper.Recap(recapCtx, transcript) + if err != nil { + e.logger.DebugContext(ctx, "recap generation failed", "error", err, "session_id", sessionID) + return + } + recap.text = truncateRunes(strings.TrimSpace(text), recapMaxLength) + }() +} + +// truncateRunes cuts text to at most max characters. +func truncateRunes(text string, max int) string { + if runes := []rune(text); len(runes) > max { + return string(runes[:max]) + } + return text +} + +// cancelRecap abandons any in-flight recap. +func (e *KAgentExecutor) cancelRecap() { + e.recapMu.Lock() + recap := e.recap + e.recap = nil + e.recapMu.Unlock() + if recap != nil { + recap.cancel() + <-recap.done + } +} + +// waitRecap waits for the in-flight recap, then stores it. +func (e *KAgentExecutor) waitRecap(ctx context.Context, sessionID string) { + e.recapMu.Lock() + recap := e.recap + if recap != nil && recap.sessionID == sessionID { + e.recap = nil + } else { + recap = nil + } + e.recapMu.Unlock() + if recap == nil { + return + } + timer := time.NewTimer(e.recapWait) + defer timer.Stop() + select { + case <-recap.done: + case <-timer.C: + e.logger.DebugContext(ctx, "recap not ready in time", "session_id", sessionID) + case <-ctx.Done(): + } + recap.cancel() + <-recap.done + if recap.text == "" || ctx.Err() != nil { + return + } + callCtx, callCancel := e.controllerClient.CallContext(ctx, "") + defer callCancel() + if _, err := e.controllerClient.SessionService().SetSessionRecap(callCtx, &apiv1.SetSessionRecapRequest{ + SessionId: sessionID, Text: recap.text, TurnCount: int32(recap.turns), + }); err != nil { + e.logger.WarnContext(ctx, "failed to set session recap", "error", err, "session_id", sessionID) + } +} diff --git a/go/adk/pkg/agent/titler.go b/go/adk/pkg/agent/titler.go new file mode 100644 index 0000000000..f57a9f86e1 --- /dev/null +++ b/go/adk/pkg/agent/titler.go @@ -0,0 +1,105 @@ +package agent + +import ( + "context" + "errors" + "fmt" + "strings" + + adkmodel "google.golang.org/adk/v2/model" + "google.golang.org/genai" + + configv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/config/v1" +) + +const titlePrompt = "Write a title of 3 to 6 words for a chat that starts with the message below. " + + "Use the language of the message. Reply with the title only: no quotes and no trailing punctuation.\n\n" + + "Message:\n" + +// Titler writes short chat titles with the agent's own model. +type Titler struct { + llm adkmodel.LLM + config *genai.GenerateContentConfig +} + +// NewTitler builds a Titler on the agent's configured model. +func NewTitler(ctx context.Context, modelConfig *configv1.ADKModelConfig) (*Titler, error) { + if modelConfig == nil { + return nil, errors.New("title generation needs a model") + } + llm, err := CreateLLM(ctx, modelConfig) + if err != nil { + return nil, fmt.Errorf("failed to create title model: %w", err) + } + return &Titler{llm: llm, config: generateContentConfig(modelConfig)}, nil +} + +// Title returns the model's raw title for the user's first message. +func (t *Titler) Title(ctx context.Context, userMessage string) (string, error) { + req := &adkmodel.LLMRequest{ + Contents: []*genai.Content{genai.NewContentFromText(titlePrompt+userMessage, genai.RoleUser)}, + Config: t.config, + } + var title strings.Builder + for resp, err := range t.llm.GenerateContent(ctx, req, false) { + if err != nil { + return "", fmt.Errorf("failed to generate title: %w", err) + } + if resp != nil && resp.Content != nil { + for _, part := range resp.Content.Parts { + if part != nil && !part.Thought { + title.WriteString(part.Text) + } + } + } + } + return title.String(), nil +} + +const recapPrompt = "Write a recap of one or two short sentences for the person who is reading the chat below: " + + "where things stand, and what comes next. " + + `Always address the reader as "you" and refer to the assistant as "I"; never call them "the user". ` + + `For example: "You asked about X and I answered Y. Next, you could do Z." ` + + "Use the language of the conversation. " + + "Reply with plain text only: no markdown, no headings, no lists.\n\n" + + "Conversation:\n" + +// Recapper writes short chat recaps with the agent's own model. +type Recapper struct { + llm adkmodel.LLM + config *genai.GenerateContentConfig +} + +// NewRecapper builds a Recapper on the agent's configured model. +func NewRecapper(ctx context.Context, modelConfig *configv1.ADKModelConfig) (*Recapper, error) { + if modelConfig == nil { + return nil, errors.New("recap generation needs a model") + } + llm, err := CreateLLM(ctx, modelConfig) + if err != nil { + return nil, fmt.Errorf("failed to create recap model: %w", err) + } + return &Recapper{llm: llm, config: generateContentConfig(modelConfig)}, nil +} + +// Recap returns the model's raw recap of the transcript. +func (r *Recapper) Recap(ctx context.Context, transcript string) (string, error) { + req := &adkmodel.LLMRequest{ + Contents: []*genai.Content{genai.NewContentFromText(recapPrompt+transcript, genai.RoleUser)}, + Config: r.config, + } + var recap strings.Builder + for resp, err := range r.llm.GenerateContent(ctx, req, false) { + if err != nil { + return "", fmt.Errorf("failed to generate recap: %w", err) + } + if resp != nil && resp.Content != nil { + for _, part := range resp.Content.Parts { + if part != nil && !part.Thought { + recap.WriteString(part.Text) + } + } + } + } + return recap.String(), nil +} diff --git a/go/adk/pkg/agent/titler_test.go b/go/adk/pkg/agent/titler_test.go new file mode 100644 index 0000000000..5d16523d50 --- /dev/null +++ b/go/adk/pkg/agent/titler_test.go @@ -0,0 +1,77 @@ +package agent + +import ( + "context" + "iter" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + adkmodel "google.golang.org/adk/v2/model" + "google.golang.org/genai" +) + +// thoughtModel replies with one thought part and one text part. +type thoughtModel struct{} + +func (thoughtModel) Name() string { return "thought" } + +func (thoughtModel) GenerateContent(context.Context, *adkmodel.LLMRequest, bool) iter.Seq2[*adkmodel.LLMResponse, error] { + return func(yield func(*adkmodel.LLMResponse, error) bool) { + yield(&adkmodel.LLMResponse{Content: &genai.Content{Role: genai.RoleModel, Parts: []*genai.Part{ + {Text: "let me think", Thought: true}, + {Text: "the answer"}, + }}}, nil) + } +} + +func TestTitler(t *testing.T) { + t.Run("Given a model, when a title is requested, assert that the prompt carries the message and the reply is returned", func(t *testing.T) { + model := &scriptedModel{} + titler := &Titler{llm: model} + + title, err := titler.Title(t.Context(), "my login page is broken") + + require.NoError(t, err) + assert.Equal(t, "reply 1", title) + prompt := model.request(0).Contents[0].Parts[0].Text + assert.Contains(t, prompt, titlePrompt) + assert.Contains(t, prompt, "my login page is broken") + }) +} + +func TestTitlerSkipsThoughts(t *testing.T) { + t.Run("Given a model that returns a thought part, when a title is requested, assert that only the text part is returned", func(t *testing.T) { + title, err := (&Titler{llm: thoughtModel{}}).Title(t.Context(), "hello") + + require.NoError(t, err) + assert.Equal(t, "the answer", title) + }) +} + +func TestRecapperSkipsThoughts(t *testing.T) { + t.Run("Given a model that returns a thought part, when a recap is requested, assert that only the text part is returned", func(t *testing.T) { + recap, err := (&Recapper{llm: thoughtModel{}}).Recap(t.Context(), "User: hello") + + require.NoError(t, err) + assert.Equal(t, "the answer", recap) + }) +} + +func TestRecapper(t *testing.T) { + t.Run("Given a model, when a recap is requested, assert that the prompt carries the transcript and the reply is returned", func(t *testing.T) { + model := &scriptedModel{} + recapper := &Recapper{llm: model} + + recap, err := recapper.Recap(t.Context(), "User: my login page is broken") + + require.NoError(t, err) + assert.Equal(t, "reply 1", recap) + prompt := model.request(0).Contents[0].Parts[0].Text + assert.Contains(t, prompt, recapPrompt) + assert.Contains(t, recapPrompt, "one or two short sentences") + assert.Contains(t, recapPrompt, `address the reader as "you"`) + assert.Contains(t, recapPrompt, `never call them "the user"`) + assert.Contains(t, prompt, "User: my login page is broken") + }) +} diff --git a/go/adk/pkg/controllerclient/client.go b/go/adk/pkg/controllerclient/client.go index ae4e94e56c..c2f12cb480 100644 --- a/go/adk/pkg/controllerclient/client.go +++ b/go/adk/pkg/controllerclient/client.go @@ -16,6 +16,7 @@ import ( "github.com/kagent-dev/kagent/go/adk/pkg/auth" "github.com/kagent-dev/kagent/go/api/client" + apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" runtimev1 "github.com/kagent-dev/kagent/go/api/gen/kagent/runtime/v1" ) @@ -45,6 +46,7 @@ type Client struct { tokenProvider TokenProvider memoryService runtimev1.MemoryServiceClient taskStoreService runtimev1.TaskStoreServiceClient + sessionService apiv1.SessionServiceClient closeOnce sync.Once closeErr error } @@ -93,6 +95,7 @@ func New(config Config) (*Client, error) { tokenProvider: config.TokenProvider, memoryService: runtimev1.NewMemoryServiceClient(connection), taskStoreService: runtimev1.NewTaskStoreServiceClient(connection), + sessionService: apiv1.NewSessionServiceClient(connection), }, nil } @@ -122,6 +125,10 @@ func (client *Client) TaskStoreService() runtimev1.TaskStoreServiceClient { return client.taskStoreService } +func (client *Client) SessionService() apiv1.SessionServiceClient { + return client.sessionService +} + func (client *Client) MaxMessageBytes() int { return client.maxMessageBytes } diff --git a/go/adk/pkg/controllerclient/client_test.go b/go/adk/pkg/controllerclient/client_test.go index ef82f4661c..1989a05a7e 100644 --- a/go/adk/pkg/controllerclient/client_test.go +++ b/go/adk/pkg/controllerclient/client_test.go @@ -14,6 +14,7 @@ import ( "google.golang.org/grpc/test/bufconn" "github.com/kagent-dev/kagent/go/adk/pkg/auth" + apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" runtimev1 "github.com/kagent-dev/kagent/go/api/gen/kagent/runtime/v1" ) @@ -123,3 +124,43 @@ func TestCallContextRequestsActorJWTWithoutDuplicateCredentials(t *testing.T) { require.Equal(t, []string{"trace"}, got.Get("x-trace-id")) require.Equal(t, []string{"Bearer inherited"}, original.Get("authorization")) } + +type nameSessionServer struct { + apiv1.UnimplementedSessionServiceServer + requests []*apiv1.SetSessionNameIfEmptyRequest +} + +func (server *nameSessionServer) SetSessionNameIfEmpty(_ context.Context, request *apiv1.SetSessionNameIfEmptyRequest) (*apiv1.SetSessionNameIfEmptyResponse, error) { + server.requests = append(server.requests, request) + return &apiv1.SetSessionNameIfEmptyResponse{}, nil +} + +func TestSessionServiceReachesTheController(t *testing.T) { + listener := bufconn.Listen(1024 * 1024) + service := &nameSessionServer{} + grpcServer := grpc.NewServer() + apiv1.RegisterSessionServiceServer(grpcServer, service) + go func() { _ = grpcServer.Serve(listener) }() + t.Cleanup(func() { + grpcServer.Stop() + _ = listener.Close() + }) + client, err := New(Config{ + APIURL: "http://bufnet:80", + DialOptions: []grpc.DialOption{grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { + return listener.Dial() + })}, + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, client.Close()) }) + + sessions := client.SessionService() + require.NotNil(t, sessions) + ctx, cancel := client.CallContext(t.Context(), "user") + defer cancel() + _, err = sessions.SetSessionNameIfEmpty(ctx, &apiv1.SetSessionNameIfEmptyRequest{SessionId: "s1", Name: "Title"}) + require.NoError(t, err) + require.Len(t, service.requests, 1) + assert.Equal(t, "s1", service.requests[0].GetSessionId()) + assert.Equal(t, "Title", service.requests[0].GetName()) +} diff --git a/go/api/gen/kagent/api/v1/sessions.pb.go b/go/api/gen/kagent/api/v1/sessions.pb.go index ab556d6f9b..593d854c80 100644 --- a/go/api/gen/kagent/api/v1/sessions.pb.go +++ b/go/api/gen/kagent/api/v1/sessions.pb.go @@ -93,7 +93,10 @@ type Session struct { Name string `protobuf:"bytes,13,opt,name=name,proto3" json:"name,omitempty"` // Output only. A2A conversation ID, equal to this session's ID. // Forks receive a new conversation ID and new task IDs. - ContextId string `protobuf:"bytes,14,opt,name=context_id,json=contextId,proto3" json:"context_id,omitempty"` + ContextId string `protobuf:"bytes,14,opt,name=context_id,json=contextId,proto3" json:"context_id,omitempty"` + // Output only. Latest generated summary of the conversation. Unset until a + // runtime writes one. + Recap *SessionRecap `protobuf:"bytes,16,opt,name=recap,proto3" json:"recap,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -212,6 +215,75 @@ func (x *Session) GetContextId() string { return "" } +func (x *Session) GetRecap() *SessionRecap { + if x != nil { + return x.Recap + } + return nil +} + +type SessionRecap struct { + state protoimpl.MessageState `protogen:"open.v1"` + Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` + // Number of turns the recap covers. A reader compares it with the + // conversation's turn count to tell whether the recap is current. + TurnCount int32 `protobuf:"varint,2,opt,name=turn_count,json=turnCount,proto3" json:"turn_count,omitempty"` + GeneratedAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=generated_at,json=generatedAt,proto3" json:"generated_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SessionRecap) Reset() { + *x = SessionRecap{} + mi := &file_kagent_api_v1_sessions_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SessionRecap) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionRecap) ProtoMessage() {} + +func (x *SessionRecap) ProtoReflect() protoreflect.Message { + mi := &file_kagent_api_v1_sessions_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SessionRecap.ProtoReflect.Descriptor instead. +func (*SessionRecap) Descriptor() ([]byte, []int) { + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{2} +} + +func (x *SessionRecap) GetText() string { + if x != nil { + return x.Text + } + return "" +} + +func (x *SessionRecap) GetTurnCount() int32 { + if x != nil { + return x.TurnCount + } + return 0 +} + +func (x *SessionRecap) GetGeneratedAt() *timestamppb.Timestamp { + if x != nil { + return x.GeneratedAt + } + return nil +} + type AgentProvenance struct { state protoimpl.MessageState `protogen:"open.v1"` Ref *ResourceReference `protobuf:"bytes,1,opt,name=ref,proto3" json:"ref,omitempty"` @@ -222,7 +294,7 @@ type AgentProvenance struct { func (x *AgentProvenance) Reset() { *x = AgentProvenance{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[2] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -234,7 +306,7 @@ func (x *AgentProvenance) String() string { func (*AgentProvenance) ProtoMessage() {} func (x *AgentProvenance) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[2] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -247,7 +319,7 @@ func (x *AgentProvenance) ProtoReflect() protoreflect.Message { // Deprecated: Use AgentProvenance.ProtoReflect.Descriptor instead. func (*AgentProvenance) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{2} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{3} } func (x *AgentProvenance) GetRef() *ResourceReference { @@ -276,7 +348,7 @@ type CreateSessionRequest struct { func (x *CreateSessionRequest) Reset() { *x = CreateSessionRequest{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[3] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -288,7 +360,7 @@ func (x *CreateSessionRequest) String() string { func (*CreateSessionRequest) ProtoMessage() {} func (x *CreateSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[3] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -301,7 +373,7 @@ func (x *CreateSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateSessionRequest.ProtoReflect.Descriptor instead. func (*CreateSessionRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{3} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{4} } func (x *CreateSessionRequest) GetAgent() *ResourceReference { @@ -334,7 +406,7 @@ type CreateSessionResponse struct { func (x *CreateSessionResponse) Reset() { *x = CreateSessionResponse{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[4] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -346,7 +418,7 @@ func (x *CreateSessionResponse) String() string { func (*CreateSessionResponse) ProtoMessage() {} func (x *CreateSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[4] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -359,7 +431,7 @@ func (x *CreateSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateSessionResponse.ProtoReflect.Descriptor instead. func (*CreateSessionResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{4} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{5} } func (x *CreateSessionResponse) GetSession() *Session { @@ -378,7 +450,7 @@ type GetSessionRequest struct { func (x *GetSessionRequest) Reset() { *x = GetSessionRequest{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[5] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -390,7 +462,7 @@ func (x *GetSessionRequest) String() string { func (*GetSessionRequest) ProtoMessage() {} func (x *GetSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[5] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -403,7 +475,7 @@ func (x *GetSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSessionRequest.ProtoReflect.Descriptor instead. func (*GetSessionRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{5} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{6} } func (x *GetSessionRequest) GetSessionId() string { @@ -422,7 +494,7 @@ type GetSessionResponse struct { func (x *GetSessionResponse) Reset() { *x = GetSessionResponse{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[6] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -434,7 +506,7 @@ func (x *GetSessionResponse) String() string { func (*GetSessionResponse) ProtoMessage() {} func (x *GetSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[6] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -447,7 +519,7 @@ func (x *GetSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSessionResponse.ProtoReflect.Descriptor instead. func (*GetSessionResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{6} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{7} } func (x *GetSessionResponse) GetSession() *Session { @@ -470,7 +542,7 @@ type ListSessionsRequest struct { func (x *ListSessionsRequest) Reset() { *x = ListSessionsRequest{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[7] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -482,7 +554,7 @@ func (x *ListSessionsRequest) String() string { func (*ListSessionsRequest) ProtoMessage() {} func (x *ListSessionsRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[7] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -495,7 +567,7 @@ func (x *ListSessionsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSessionsRequest.ProtoReflect.Descriptor instead. func (*ListSessionsRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{7} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{8} } func (x *ListSessionsRequest) GetAllCreators() bool { @@ -529,7 +601,7 @@ type ListSessionsResponse struct { func (x *ListSessionsResponse) Reset() { *x = ListSessionsResponse{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[8] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -541,7 +613,7 @@ func (x *ListSessionsResponse) String() string { func (*ListSessionsResponse) ProtoMessage() {} func (x *ListSessionsResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[8] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -554,7 +626,7 @@ func (x *ListSessionsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSessionsResponse.ProtoReflect.Descriptor instead. func (*ListSessionsResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{8} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{9} } func (x *ListSessionsResponse) GetSessions() []*Session { @@ -583,7 +655,7 @@ type UpdateSessionNameRequest struct { func (x *UpdateSessionNameRequest) Reset() { *x = UpdateSessionNameRequest{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[9] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -595,7 +667,7 @@ func (x *UpdateSessionNameRequest) String() string { func (*UpdateSessionNameRequest) ProtoMessage() {} func (x *UpdateSessionNameRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[9] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -608,7 +680,7 @@ func (x *UpdateSessionNameRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateSessionNameRequest.ProtoReflect.Descriptor instead. func (*UpdateSessionNameRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{9} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{10} } func (x *UpdateSessionNameRequest) GetSessionId() string { @@ -634,7 +706,7 @@ type UpdateSessionNameResponse struct { func (x *UpdateSessionNameResponse) Reset() { *x = UpdateSessionNameResponse{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[10] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -646,7 +718,7 @@ func (x *UpdateSessionNameResponse) String() string { func (*UpdateSessionNameResponse) ProtoMessage() {} func (x *UpdateSessionNameResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[10] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -659,7 +731,7 @@ func (x *UpdateSessionNameResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateSessionNameResponse.ProtoReflect.Descriptor instead. func (*UpdateSessionNameResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{10} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{11} } func (x *UpdateSessionNameResponse) GetSession() *Session { @@ -669,6 +741,215 @@ func (x *UpdateSessionNameResponse) GetSession() *Session { return nil } +type SetSessionNameIfEmptyRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetSessionNameIfEmptyRequest) Reset() { + *x = SetSessionNameIfEmptyRequest{} + mi := &file_kagent_api_v1_sessions_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetSessionNameIfEmptyRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetSessionNameIfEmptyRequest) ProtoMessage() {} + +func (x *SetSessionNameIfEmptyRequest) ProtoReflect() protoreflect.Message { + mi := &file_kagent_api_v1_sessions_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetSessionNameIfEmptyRequest.ProtoReflect.Descriptor instead. +func (*SetSessionNameIfEmptyRequest) Descriptor() ([]byte, []int) { + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{12} +} + +func (x *SetSessionNameIfEmptyRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *SetSessionNameIfEmptyRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type SetSessionNameIfEmptyResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Session *Session `protobuf:"bytes,1,opt,name=session,proto3" json:"session,omitempty"` + // True when this call set the name; false when the Session already had one. + Applied bool `protobuf:"varint,2,opt,name=applied,proto3" json:"applied,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetSessionNameIfEmptyResponse) Reset() { + *x = SetSessionNameIfEmptyResponse{} + mi := &file_kagent_api_v1_sessions_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetSessionNameIfEmptyResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetSessionNameIfEmptyResponse) ProtoMessage() {} + +func (x *SetSessionNameIfEmptyResponse) ProtoReflect() protoreflect.Message { + mi := &file_kagent_api_v1_sessions_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetSessionNameIfEmptyResponse.ProtoReflect.Descriptor instead. +func (*SetSessionNameIfEmptyResponse) Descriptor() ([]byte, []int) { + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{13} +} + +func (x *SetSessionNameIfEmptyResponse) GetSession() *Session { + if x != nil { + return x.Session + } + return nil +} + +func (x *SetSessionNameIfEmptyResponse) GetApplied() bool { + if x != nil { + return x.Applied + } + return false +} + +type SetSessionRecapRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + Text string `protobuf:"bytes,2,opt,name=text,proto3" json:"text,omitempty"` + TurnCount int32 `protobuf:"varint,3,opt,name=turn_count,json=turnCount,proto3" json:"turn_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetSessionRecapRequest) Reset() { + *x = SetSessionRecapRequest{} + mi := &file_kagent_api_v1_sessions_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetSessionRecapRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetSessionRecapRequest) ProtoMessage() {} + +func (x *SetSessionRecapRequest) ProtoReflect() protoreflect.Message { + mi := &file_kagent_api_v1_sessions_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetSessionRecapRequest.ProtoReflect.Descriptor instead. +func (*SetSessionRecapRequest) Descriptor() ([]byte, []int) { + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{14} +} + +func (x *SetSessionRecapRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *SetSessionRecapRequest) GetText() string { + if x != nil { + return x.Text + } + return "" +} + +func (x *SetSessionRecapRequest) GetTurnCount() int32 { + if x != nil { + return x.TurnCount + } + return 0 +} + +type SetSessionRecapResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Session *Session `protobuf:"bytes,1,opt,name=session,proto3" json:"session,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetSessionRecapResponse) Reset() { + *x = SetSessionRecapResponse{} + mi := &file_kagent_api_v1_sessions_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetSessionRecapResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetSessionRecapResponse) ProtoMessage() {} + +func (x *SetSessionRecapResponse) ProtoReflect() protoreflect.Message { + mi := &file_kagent_api_v1_sessions_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetSessionRecapResponse.ProtoReflect.Descriptor instead. +func (*SetSessionRecapResponse) Descriptor() ([]byte, []int) { + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{15} +} + +func (x *SetSessionRecapResponse) GetSession() *Session { + if x != nil { + return x.Session + } + return nil +} + type RevertSessionRequest struct { state protoimpl.MessageState `protogen:"open.v1"` SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` @@ -678,7 +959,7 @@ type RevertSessionRequest struct { func (x *RevertSessionRequest) Reset() { *x = RevertSessionRequest{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[11] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -690,7 +971,7 @@ func (x *RevertSessionRequest) String() string { func (*RevertSessionRequest) ProtoMessage() {} func (x *RevertSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[11] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -703,7 +984,7 @@ func (x *RevertSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RevertSessionRequest.ProtoReflect.Descriptor instead. func (*RevertSessionRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{11} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{16} } func (x *RevertSessionRequest) GetSessionId() string { @@ -722,7 +1003,7 @@ type RevertSessionResponse struct { func (x *RevertSessionResponse) Reset() { *x = RevertSessionResponse{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[12] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -734,7 +1015,7 @@ func (x *RevertSessionResponse) String() string { func (*RevertSessionResponse) ProtoMessage() {} func (x *RevertSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[12] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -747,7 +1028,7 @@ func (x *RevertSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RevertSessionResponse.ProtoReflect.Descriptor instead. func (*RevertSessionResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{12} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{17} } func (x *RevertSessionResponse) GetSession() *Session { @@ -769,7 +1050,7 @@ type DeleteSessionRequest struct { func (x *DeleteSessionRequest) Reset() { *x = DeleteSessionRequest{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[13] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -781,7 +1062,7 @@ func (x *DeleteSessionRequest) String() string { func (*DeleteSessionRequest) ProtoMessage() {} func (x *DeleteSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[13] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -794,7 +1075,7 @@ func (x *DeleteSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteSessionRequest.ProtoReflect.Descriptor instead. func (*DeleteSessionRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{13} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{18} } func (x *DeleteSessionRequest) GetSessionId() string { @@ -820,7 +1101,7 @@ type DeleteSessionResponse struct { func (x *DeleteSessionResponse) Reset() { *x = DeleteSessionResponse{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[14] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -832,7 +1113,7 @@ func (x *DeleteSessionResponse) String() string { func (*DeleteSessionResponse) ProtoMessage() {} func (x *DeleteSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[14] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -845,7 +1126,7 @@ func (x *DeleteSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteSessionResponse.ProtoReflect.Descriptor instead. func (*DeleteSessionResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{14} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{19} } func (x *DeleteSessionResponse) GetSession() *Session { @@ -869,7 +1150,7 @@ type SessionShare struct { func (x *SessionShare) Reset() { *x = SessionShare{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[15] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -881,7 +1162,7 @@ func (x *SessionShare) String() string { func (*SessionShare) ProtoMessage() {} func (x *SessionShare) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[15] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -894,7 +1175,7 @@ func (x *SessionShare) ProtoReflect() protoreflect.Message { // Deprecated: Use SessionShare.ProtoReflect.Descriptor instead. func (*SessionShare) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{15} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{20} } func (x *SessionShare) GetId() string { @@ -938,7 +1219,7 @@ type CreateSessionShareRequest struct { func (x *CreateSessionShareRequest) Reset() { *x = CreateSessionShareRequest{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[16] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -950,7 +1231,7 @@ func (x *CreateSessionShareRequest) String() string { func (*CreateSessionShareRequest) ProtoMessage() {} func (x *CreateSessionShareRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[16] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -963,7 +1244,7 @@ func (x *CreateSessionShareRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateSessionShareRequest.ProtoReflect.Descriptor instead. func (*CreateSessionShareRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{16} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{21} } func (x *CreateSessionShareRequest) GetSessionId() string { @@ -991,7 +1272,7 @@ type CreateSessionShareResponse struct { func (x *CreateSessionShareResponse) Reset() { *x = CreateSessionShareResponse{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[17] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1003,7 +1284,7 @@ func (x *CreateSessionShareResponse) String() string { func (*CreateSessionShareResponse) ProtoMessage() {} func (x *CreateSessionShareResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[17] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1016,7 +1297,7 @@ func (x *CreateSessionShareResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateSessionShareResponse.ProtoReflect.Descriptor instead. func (*CreateSessionShareResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{17} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{22} } func (x *CreateSessionShareResponse) GetShare() *SessionShare { @@ -1043,7 +1324,7 @@ type ListSessionSharesRequest struct { func (x *ListSessionSharesRequest) Reset() { *x = ListSessionSharesRequest{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[18] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1055,7 +1336,7 @@ func (x *ListSessionSharesRequest) String() string { func (*ListSessionSharesRequest) ProtoMessage() {} func (x *ListSessionSharesRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[18] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1068,7 +1349,7 @@ func (x *ListSessionSharesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSessionSharesRequest.ProtoReflect.Descriptor instead. func (*ListSessionSharesRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{18} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{23} } func (x *ListSessionSharesRequest) GetSessionId() string { @@ -1095,7 +1376,7 @@ type ListSessionSharesResponse struct { func (x *ListSessionSharesResponse) Reset() { *x = ListSessionSharesResponse{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[19] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1107,7 +1388,7 @@ func (x *ListSessionSharesResponse) String() string { func (*ListSessionSharesResponse) ProtoMessage() {} func (x *ListSessionSharesResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[19] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1120,7 +1401,7 @@ func (x *ListSessionSharesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSessionSharesResponse.ProtoReflect.Descriptor instead. func (*ListSessionSharesResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{19} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{24} } func (x *ListSessionSharesResponse) GetShares() []*SessionShare { @@ -1146,7 +1427,7 @@ type RevokeSessionShareRequest struct { func (x *RevokeSessionShareRequest) Reset() { *x = RevokeSessionShareRequest{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[20] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1158,7 +1439,7 @@ func (x *RevokeSessionShareRequest) String() string { func (*RevokeSessionShareRequest) ProtoMessage() {} func (x *RevokeSessionShareRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[20] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1171,7 +1452,7 @@ func (x *RevokeSessionShareRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeSessionShareRequest.ProtoReflect.Descriptor instead. func (*RevokeSessionShareRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{20} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{25} } func (x *RevokeSessionShareRequest) GetShareId() string { @@ -1189,7 +1470,7 @@ type RevokeSessionShareResponse struct { func (x *RevokeSessionShareResponse) Reset() { *x = RevokeSessionShareResponse{} - mi := &file_kagent_api_v1_sessions_proto_msgTypes[21] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1201,7 +1482,7 @@ func (x *RevokeSessionShareResponse) String() string { func (*RevokeSessionShareResponse) ProtoMessage() {} func (x *RevokeSessionShareResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_sessions_proto_msgTypes[21] + mi := &file_kagent_api_v1_sessions_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1214,7 +1495,7 @@ func (x *RevokeSessionShareResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeSessionShareResponse.ProtoReflect.Descriptor instead. func (*RevokeSessionShareResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{21} + return file_kagent_api_v1_sessions_proto_rawDescGZIP(), []int{26} } var File_kagent_api_v1_sessions_proto protoreflect.FileDescriptor @@ -1224,7 +1505,7 @@ const file_kagent_api_v1_sessions_proto_rawDesc = "" + "\x1ckagent/api/v1/sessions.proto\x12\rkagent.api.v1\x1a\x1bbuf/validate/validate.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1akagent/api/v1/common.proto\x1a\x1bkagent/api/v1/runtime.proto\";\n" + "\aFailure\x12\x16\n" + "\x06reason\x18\x01 \x01(\tR\x06reason\x12\x18\n" + - "\amessage\x18\x02 \x01(\tR\amessage\"\x88\x04\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"\xbb\x04\n" + "\aSession\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x18\n" + "\acreator\x18\x02 \x01(\tR\acreator\x124\n" + @@ -1241,7 +1522,13 @@ const file_kagent_api_v1_sessions_proto_rawDesc = "" + "updated_at\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12\x12\n" + "\x04name\x18\r \x01(\tR\x04name\x12\x1d\n" + "\n" + - "context_id\x18\x0e \x01(\tR\tcontextId\"W\n" + + "context_id\x18\x0e \x01(\tR\tcontextId\x121\n" + + "\x05recap\x18\x10 \x01(\v2\x1b.kagent.api.v1.SessionRecapR\x05recap\"\x80\x01\n" + + "\fSessionRecap\x12\x12\n" + + "\x04text\x18\x01 \x01(\tR\x04text\x12\x1d\n" + + "\n" + + "turn_count\x18\x02 \x01(\x05R\tturnCount\x12=\n" + + "\fgenerated_at\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\vgeneratedAt\"W\n" + "\x0fAgentProvenance\x122\n" + "\x03ref\x18\x01 \x01(\v2 .kagent.api.v1.ResourceReferenceR\x03ref\x12\x10\n" + "\x03uid\x18\x02 \x01(\tR\x03uid\"\xd4\x01\n" + @@ -1270,6 +1557,22 @@ const file_kagent_api_v1_sessions_proto_rawDesc = "" + "session_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\tsessionId\x12Q\n" + "\x04name\x18\x02 \x01(\tB=\xbaH:r8\x18\xc8\x0123^(?:$|[^\\p{Z}\\p{Cc}](?:[^\\p{Cc}]*[^\\p{Z}\\p{Cc}])?)$R\x04name\"M\n" + "\x19UpdateSessionNameResponse\x120\n" + + "\asession\x18\x01 \x01(\v2\x16.kagent.api.v1.SessionR\asession\"\x96\x01\n" + + "\x1cSetSessionNameIfEmptyRequest\x12'\n" + + "\n" + + "session_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\tsessionId\x12M\n" + + "\x04name\x18\x02 \x01(\tB9\xbaH6r4\x10\x01\x18\xc8\x012-^[^\\p{Z}\\p{Cc}](?:[^\\p{Cc}]*[^\\p{Z}\\p{Cc}])?$R\x04name\"k\n" + + "\x1dSetSessionNameIfEmptyResponse\x120\n" + + "\asession\x18\x01 \x01(\v2\x16.kagent.api.v1.SessionR\asession\x12\x18\n" + + "\aapplied\x18\x02 \x01(\bR\aapplied\"\x89\x01\n" + + "\x16SetSessionRecapRequest\x12'\n" + + "\n" + + "session_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\tsessionId\x12\x1e\n" + + "\x04text\x18\x02 \x01(\tB\n" + + "\xbaH\ar\x05\x10\x01\x18\xd0\x0fR\x04text\x12&\n" + + "\n" + + "turn_count\x18\x03 \x01(\x05B\a\xbaH\x04\x1a\x02(\x01R\tturnCount\"K\n" + + "\x17SetSessionRecapResponse\x120\n" + "\asession\x18\x01 \x01(\v2\x16.kagent.api.v1.SessionR\asession\"?\n" + "\x14RevertSessionRequest\x12'\n" + "\n" + @@ -1306,13 +1609,15 @@ const file_kagent_api_v1_sessions_proto_rawDesc = "" + "\x04page\x18\x02 \x01(\v2\x1b.kagent.api.v1.PageResponseR\x04page\"@\n" + "\x19RevokeSessionShareRequest\x12#\n" + "\bshare_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\ashareId\"\x1c\n" + - "\x1aRevokeSessionShareResponse2\xf6\x06\n" + + "\x1aRevokeSessionShareResponse2\xcc\b\n" + "\x0eSessionService\x12Z\n" + "\rCreateSession\x12#.kagent.api.v1.CreateSessionRequest\x1a$.kagent.api.v1.CreateSessionResponse\x12Q\n" + "\n" + "GetSession\x12 .kagent.api.v1.GetSessionRequest\x1a!.kagent.api.v1.GetSessionResponse\x12W\n" + "\fListSessions\x12\".kagent.api.v1.ListSessionsRequest\x1a#.kagent.api.v1.ListSessionsResponse\x12f\n" + - "\x11UpdateSessionName\x12'.kagent.api.v1.UpdateSessionNameRequest\x1a(.kagent.api.v1.UpdateSessionNameResponse\x12Z\n" + + "\x11UpdateSessionName\x12'.kagent.api.v1.UpdateSessionNameRequest\x1a(.kagent.api.v1.UpdateSessionNameResponse\x12r\n" + + "\x15SetSessionNameIfEmpty\x12+.kagent.api.v1.SetSessionNameIfEmptyRequest\x1a,.kagent.api.v1.SetSessionNameIfEmptyResponse\x12`\n" + + "\x0fSetSessionRecap\x12%.kagent.api.v1.SetSessionRecapRequest\x1a&.kagent.api.v1.SetSessionRecapResponse\x12Z\n" + "\rRevertSession\x12#.kagent.api.v1.RevertSessionRequest\x1a$.kagent.api.v1.RevertSessionResponse\x12Z\n" + "\rDeleteSession\x12#.kagent.api.v1.DeleteSessionRequest\x1a$.kagent.api.v1.DeleteSessionResponse\x12i\n" + "\x12CreateSessionShare\x12(.kagent.api.v1.CreateSessionShareRequest\x1a).kagent.api.v1.CreateSessionShareResponse\x12f\n" + @@ -1331,86 +1636,99 @@ func file_kagent_api_v1_sessions_proto_rawDescGZIP() []byte { return file_kagent_api_v1_sessions_proto_rawDescData } -var file_kagent_api_v1_sessions_proto_msgTypes = make([]protoimpl.MessageInfo, 22) +var file_kagent_api_v1_sessions_proto_msgTypes = make([]protoimpl.MessageInfo, 27) var file_kagent_api_v1_sessions_proto_goTypes = []any{ - (*Failure)(nil), // 0: kagent.api.v1.Failure - (*Session)(nil), // 1: kagent.api.v1.Session - (*AgentProvenance)(nil), // 2: kagent.api.v1.AgentProvenance - (*CreateSessionRequest)(nil), // 3: kagent.api.v1.CreateSessionRequest - (*CreateSessionResponse)(nil), // 4: kagent.api.v1.CreateSessionResponse - (*GetSessionRequest)(nil), // 5: kagent.api.v1.GetSessionRequest - (*GetSessionResponse)(nil), // 6: kagent.api.v1.GetSessionResponse - (*ListSessionsRequest)(nil), // 7: kagent.api.v1.ListSessionsRequest - (*ListSessionsResponse)(nil), // 8: kagent.api.v1.ListSessionsResponse - (*UpdateSessionNameRequest)(nil), // 9: kagent.api.v1.UpdateSessionNameRequest - (*UpdateSessionNameResponse)(nil), // 10: kagent.api.v1.UpdateSessionNameResponse - (*RevertSessionRequest)(nil), // 11: kagent.api.v1.RevertSessionRequest - (*RevertSessionResponse)(nil), // 12: kagent.api.v1.RevertSessionResponse - (*DeleteSessionRequest)(nil), // 13: kagent.api.v1.DeleteSessionRequest - (*DeleteSessionResponse)(nil), // 14: kagent.api.v1.DeleteSessionResponse - (*SessionShare)(nil), // 15: kagent.api.v1.SessionShare - (*CreateSessionShareRequest)(nil), // 16: kagent.api.v1.CreateSessionShareRequest - (*CreateSessionShareResponse)(nil), // 17: kagent.api.v1.CreateSessionShareResponse - (*ListSessionSharesRequest)(nil), // 18: kagent.api.v1.ListSessionSharesRequest - (*ListSessionSharesResponse)(nil), // 19: kagent.api.v1.ListSessionSharesResponse - (*RevokeSessionShareRequest)(nil), // 20: kagent.api.v1.RevokeSessionShareRequest - (*RevokeSessionShareResponse)(nil), // 21: kagent.api.v1.RevokeSessionShareResponse - (RuntimeState)(0), // 22: kagent.api.v1.RuntimeState - (RuntimeOperation)(0), // 23: kagent.api.v1.RuntimeOperation - (*timestamppb.Timestamp)(nil), // 24: google.protobuf.Timestamp - (*ResourceReference)(nil), // 25: kagent.api.v1.ResourceReference - (*PageRequest)(nil), // 26: kagent.api.v1.PageRequest - (*PageResponse)(nil), // 27: kagent.api.v1.PageResponse - (*durationpb.Duration)(nil), // 28: google.protobuf.Duration + (*Failure)(nil), // 0: kagent.api.v1.Failure + (*Session)(nil), // 1: kagent.api.v1.Session + (*SessionRecap)(nil), // 2: kagent.api.v1.SessionRecap + (*AgentProvenance)(nil), // 3: kagent.api.v1.AgentProvenance + (*CreateSessionRequest)(nil), // 4: kagent.api.v1.CreateSessionRequest + (*CreateSessionResponse)(nil), // 5: kagent.api.v1.CreateSessionResponse + (*GetSessionRequest)(nil), // 6: kagent.api.v1.GetSessionRequest + (*GetSessionResponse)(nil), // 7: kagent.api.v1.GetSessionResponse + (*ListSessionsRequest)(nil), // 8: kagent.api.v1.ListSessionsRequest + (*ListSessionsResponse)(nil), // 9: kagent.api.v1.ListSessionsResponse + (*UpdateSessionNameRequest)(nil), // 10: kagent.api.v1.UpdateSessionNameRequest + (*UpdateSessionNameResponse)(nil), // 11: kagent.api.v1.UpdateSessionNameResponse + (*SetSessionNameIfEmptyRequest)(nil), // 12: kagent.api.v1.SetSessionNameIfEmptyRequest + (*SetSessionNameIfEmptyResponse)(nil), // 13: kagent.api.v1.SetSessionNameIfEmptyResponse + (*SetSessionRecapRequest)(nil), // 14: kagent.api.v1.SetSessionRecapRequest + (*SetSessionRecapResponse)(nil), // 15: kagent.api.v1.SetSessionRecapResponse + (*RevertSessionRequest)(nil), // 16: kagent.api.v1.RevertSessionRequest + (*RevertSessionResponse)(nil), // 17: kagent.api.v1.RevertSessionResponse + (*DeleteSessionRequest)(nil), // 18: kagent.api.v1.DeleteSessionRequest + (*DeleteSessionResponse)(nil), // 19: kagent.api.v1.DeleteSessionResponse + (*SessionShare)(nil), // 20: kagent.api.v1.SessionShare + (*CreateSessionShareRequest)(nil), // 21: kagent.api.v1.CreateSessionShareRequest + (*CreateSessionShareResponse)(nil), // 22: kagent.api.v1.CreateSessionShareResponse + (*ListSessionSharesRequest)(nil), // 23: kagent.api.v1.ListSessionSharesRequest + (*ListSessionSharesResponse)(nil), // 24: kagent.api.v1.ListSessionSharesResponse + (*RevokeSessionShareRequest)(nil), // 25: kagent.api.v1.RevokeSessionShareRequest + (*RevokeSessionShareResponse)(nil), // 26: kagent.api.v1.RevokeSessionShareResponse + (RuntimeState)(0), // 27: kagent.api.v1.RuntimeState + (RuntimeOperation)(0), // 28: kagent.api.v1.RuntimeOperation + (*timestamppb.Timestamp)(nil), // 29: google.protobuf.Timestamp + (*ResourceReference)(nil), // 30: kagent.api.v1.ResourceReference + (*PageRequest)(nil), // 31: kagent.api.v1.PageRequest + (*PageResponse)(nil), // 32: kagent.api.v1.PageResponse + (*durationpb.Duration)(nil), // 33: google.protobuf.Duration } var file_kagent_api_v1_sessions_proto_depIdxs = []int32{ - 2, // 0: kagent.api.v1.Session.agent:type_name -> kagent.api.v1.AgentProvenance - 22, // 1: kagent.api.v1.Session.state:type_name -> kagent.api.v1.RuntimeState - 23, // 2: kagent.api.v1.Session.operation:type_name -> kagent.api.v1.RuntimeOperation + 3, // 0: kagent.api.v1.Session.agent:type_name -> kagent.api.v1.AgentProvenance + 27, // 1: kagent.api.v1.Session.state:type_name -> kagent.api.v1.RuntimeState + 28, // 2: kagent.api.v1.Session.operation:type_name -> kagent.api.v1.RuntimeOperation 0, // 3: kagent.api.v1.Session.failure:type_name -> kagent.api.v1.Failure - 24, // 4: kagent.api.v1.Session.created_at:type_name -> google.protobuf.Timestamp - 24, // 5: kagent.api.v1.Session.updated_at:type_name -> google.protobuf.Timestamp - 25, // 6: kagent.api.v1.AgentProvenance.ref:type_name -> kagent.api.v1.ResourceReference - 25, // 7: kagent.api.v1.CreateSessionRequest.agent:type_name -> kagent.api.v1.ResourceReference - 1, // 8: kagent.api.v1.CreateSessionResponse.session:type_name -> kagent.api.v1.Session - 1, // 9: kagent.api.v1.GetSessionResponse.session:type_name -> kagent.api.v1.Session - 26, // 10: kagent.api.v1.ListSessionsRequest.page:type_name -> kagent.api.v1.PageRequest - 25, // 11: kagent.api.v1.ListSessionsRequest.agent:type_name -> kagent.api.v1.ResourceReference - 1, // 12: kagent.api.v1.ListSessionsResponse.sessions:type_name -> kagent.api.v1.Session - 27, // 13: kagent.api.v1.ListSessionsResponse.page:type_name -> kagent.api.v1.PageResponse - 1, // 14: kagent.api.v1.UpdateSessionNameResponse.session:type_name -> kagent.api.v1.Session - 1, // 15: kagent.api.v1.RevertSessionResponse.session:type_name -> kagent.api.v1.Session - 1, // 16: kagent.api.v1.DeleteSessionResponse.session:type_name -> kagent.api.v1.Session - 24, // 17: kagent.api.v1.SessionShare.created_at:type_name -> google.protobuf.Timestamp - 24, // 18: kagent.api.v1.SessionShare.expires_at:type_name -> google.protobuf.Timestamp - 28, // 19: kagent.api.v1.CreateSessionShareRequest.ttl:type_name -> google.protobuf.Duration - 15, // 20: kagent.api.v1.CreateSessionShareResponse.share:type_name -> kagent.api.v1.SessionShare - 26, // 21: kagent.api.v1.ListSessionSharesRequest.page:type_name -> kagent.api.v1.PageRequest - 15, // 22: kagent.api.v1.ListSessionSharesResponse.shares:type_name -> kagent.api.v1.SessionShare - 27, // 23: kagent.api.v1.ListSessionSharesResponse.page:type_name -> kagent.api.v1.PageResponse - 3, // 24: kagent.api.v1.SessionService.CreateSession:input_type -> kagent.api.v1.CreateSessionRequest - 5, // 25: kagent.api.v1.SessionService.GetSession:input_type -> kagent.api.v1.GetSessionRequest - 7, // 26: kagent.api.v1.SessionService.ListSessions:input_type -> kagent.api.v1.ListSessionsRequest - 9, // 27: kagent.api.v1.SessionService.UpdateSessionName:input_type -> kagent.api.v1.UpdateSessionNameRequest - 11, // 28: kagent.api.v1.SessionService.RevertSession:input_type -> kagent.api.v1.RevertSessionRequest - 13, // 29: kagent.api.v1.SessionService.DeleteSession:input_type -> kagent.api.v1.DeleteSessionRequest - 16, // 30: kagent.api.v1.SessionService.CreateSessionShare:input_type -> kagent.api.v1.CreateSessionShareRequest - 18, // 31: kagent.api.v1.SessionService.ListSessionShares:input_type -> kagent.api.v1.ListSessionSharesRequest - 20, // 32: kagent.api.v1.SessionService.RevokeSessionShare:input_type -> kagent.api.v1.RevokeSessionShareRequest - 4, // 33: kagent.api.v1.SessionService.CreateSession:output_type -> kagent.api.v1.CreateSessionResponse - 6, // 34: kagent.api.v1.SessionService.GetSession:output_type -> kagent.api.v1.GetSessionResponse - 8, // 35: kagent.api.v1.SessionService.ListSessions:output_type -> kagent.api.v1.ListSessionsResponse - 10, // 36: kagent.api.v1.SessionService.UpdateSessionName:output_type -> kagent.api.v1.UpdateSessionNameResponse - 12, // 37: kagent.api.v1.SessionService.RevertSession:output_type -> kagent.api.v1.RevertSessionResponse - 14, // 38: kagent.api.v1.SessionService.DeleteSession:output_type -> kagent.api.v1.DeleteSessionResponse - 17, // 39: kagent.api.v1.SessionService.CreateSessionShare:output_type -> kagent.api.v1.CreateSessionShareResponse - 19, // 40: kagent.api.v1.SessionService.ListSessionShares:output_type -> kagent.api.v1.ListSessionSharesResponse - 21, // 41: kagent.api.v1.SessionService.RevokeSessionShare:output_type -> kagent.api.v1.RevokeSessionShareResponse - 33, // [33:42] is the sub-list for method output_type - 24, // [24:33] is the sub-list for method input_type - 24, // [24:24] is the sub-list for extension type_name - 24, // [24:24] is the sub-list for extension extendee - 0, // [0:24] is the sub-list for field type_name + 29, // 4: kagent.api.v1.Session.created_at:type_name -> google.protobuf.Timestamp + 29, // 5: kagent.api.v1.Session.updated_at:type_name -> google.protobuf.Timestamp + 2, // 6: kagent.api.v1.Session.recap:type_name -> kagent.api.v1.SessionRecap + 29, // 7: kagent.api.v1.SessionRecap.generated_at:type_name -> google.protobuf.Timestamp + 30, // 8: kagent.api.v1.AgentProvenance.ref:type_name -> kagent.api.v1.ResourceReference + 30, // 9: kagent.api.v1.CreateSessionRequest.agent:type_name -> kagent.api.v1.ResourceReference + 1, // 10: kagent.api.v1.CreateSessionResponse.session:type_name -> kagent.api.v1.Session + 1, // 11: kagent.api.v1.GetSessionResponse.session:type_name -> kagent.api.v1.Session + 31, // 12: kagent.api.v1.ListSessionsRequest.page:type_name -> kagent.api.v1.PageRequest + 30, // 13: kagent.api.v1.ListSessionsRequest.agent:type_name -> kagent.api.v1.ResourceReference + 1, // 14: kagent.api.v1.ListSessionsResponse.sessions:type_name -> kagent.api.v1.Session + 32, // 15: kagent.api.v1.ListSessionsResponse.page:type_name -> kagent.api.v1.PageResponse + 1, // 16: kagent.api.v1.UpdateSessionNameResponse.session:type_name -> kagent.api.v1.Session + 1, // 17: kagent.api.v1.SetSessionNameIfEmptyResponse.session:type_name -> kagent.api.v1.Session + 1, // 18: kagent.api.v1.SetSessionRecapResponse.session:type_name -> kagent.api.v1.Session + 1, // 19: kagent.api.v1.RevertSessionResponse.session:type_name -> kagent.api.v1.Session + 1, // 20: kagent.api.v1.DeleteSessionResponse.session:type_name -> kagent.api.v1.Session + 29, // 21: kagent.api.v1.SessionShare.created_at:type_name -> google.protobuf.Timestamp + 29, // 22: kagent.api.v1.SessionShare.expires_at:type_name -> google.protobuf.Timestamp + 33, // 23: kagent.api.v1.CreateSessionShareRequest.ttl:type_name -> google.protobuf.Duration + 20, // 24: kagent.api.v1.CreateSessionShareResponse.share:type_name -> kagent.api.v1.SessionShare + 31, // 25: kagent.api.v1.ListSessionSharesRequest.page:type_name -> kagent.api.v1.PageRequest + 20, // 26: kagent.api.v1.ListSessionSharesResponse.shares:type_name -> kagent.api.v1.SessionShare + 32, // 27: kagent.api.v1.ListSessionSharesResponse.page:type_name -> kagent.api.v1.PageResponse + 4, // 28: kagent.api.v1.SessionService.CreateSession:input_type -> kagent.api.v1.CreateSessionRequest + 6, // 29: kagent.api.v1.SessionService.GetSession:input_type -> kagent.api.v1.GetSessionRequest + 8, // 30: kagent.api.v1.SessionService.ListSessions:input_type -> kagent.api.v1.ListSessionsRequest + 10, // 31: kagent.api.v1.SessionService.UpdateSessionName:input_type -> kagent.api.v1.UpdateSessionNameRequest + 12, // 32: kagent.api.v1.SessionService.SetSessionNameIfEmpty:input_type -> kagent.api.v1.SetSessionNameIfEmptyRequest + 14, // 33: kagent.api.v1.SessionService.SetSessionRecap:input_type -> kagent.api.v1.SetSessionRecapRequest + 16, // 34: kagent.api.v1.SessionService.RevertSession:input_type -> kagent.api.v1.RevertSessionRequest + 18, // 35: kagent.api.v1.SessionService.DeleteSession:input_type -> kagent.api.v1.DeleteSessionRequest + 21, // 36: kagent.api.v1.SessionService.CreateSessionShare:input_type -> kagent.api.v1.CreateSessionShareRequest + 23, // 37: kagent.api.v1.SessionService.ListSessionShares:input_type -> kagent.api.v1.ListSessionSharesRequest + 25, // 38: kagent.api.v1.SessionService.RevokeSessionShare:input_type -> kagent.api.v1.RevokeSessionShareRequest + 5, // 39: kagent.api.v1.SessionService.CreateSession:output_type -> kagent.api.v1.CreateSessionResponse + 7, // 40: kagent.api.v1.SessionService.GetSession:output_type -> kagent.api.v1.GetSessionResponse + 9, // 41: kagent.api.v1.SessionService.ListSessions:output_type -> kagent.api.v1.ListSessionsResponse + 11, // 42: kagent.api.v1.SessionService.UpdateSessionName:output_type -> kagent.api.v1.UpdateSessionNameResponse + 13, // 43: kagent.api.v1.SessionService.SetSessionNameIfEmpty:output_type -> kagent.api.v1.SetSessionNameIfEmptyResponse + 15, // 44: kagent.api.v1.SessionService.SetSessionRecap:output_type -> kagent.api.v1.SetSessionRecapResponse + 17, // 45: kagent.api.v1.SessionService.RevertSession:output_type -> kagent.api.v1.RevertSessionResponse + 19, // 46: kagent.api.v1.SessionService.DeleteSession:output_type -> kagent.api.v1.DeleteSessionResponse + 22, // 47: kagent.api.v1.SessionService.CreateSessionShare:output_type -> kagent.api.v1.CreateSessionShareResponse + 24, // 48: kagent.api.v1.SessionService.ListSessionShares:output_type -> kagent.api.v1.ListSessionSharesResponse + 26, // 49: kagent.api.v1.SessionService.RevokeSessionShare:output_type -> kagent.api.v1.RevokeSessionShareResponse + 39, // [39:50] is the sub-list for method output_type + 28, // [28:39] is the sub-list for method input_type + 28, // [28:28] is the sub-list for extension type_name + 28, // [28:28] is the sub-list for extension extendee + 0, // [0:28] is the sub-list for field type_name } func init() { file_kagent_api_v1_sessions_proto_init() } @@ -1426,7 +1744,7 @@ func file_kagent_api_v1_sessions_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_kagent_api_v1_sessions_proto_rawDesc), len(file_kagent_api_v1_sessions_proto_rawDesc)), NumEnums: 0, - NumMessages: 22, + NumMessages: 27, NumExtensions: 0, NumServices: 1, }, diff --git a/go/api/gen/kagent/api/v1/sessions_grpc.pb.go b/go/api/gen/kagent/api/v1/sessions_grpc.pb.go index 4c262b9426..7c7bb2f1e3 100644 --- a/go/api/gen/kagent/api/v1/sessions_grpc.pb.go +++ b/go/api/gen/kagent/api/v1/sessions_grpc.pb.go @@ -19,15 +19,17 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - SessionService_CreateSession_FullMethodName = "/kagent.api.v1.SessionService/CreateSession" - SessionService_GetSession_FullMethodName = "/kagent.api.v1.SessionService/GetSession" - SessionService_ListSessions_FullMethodName = "/kagent.api.v1.SessionService/ListSessions" - SessionService_UpdateSessionName_FullMethodName = "/kagent.api.v1.SessionService/UpdateSessionName" - SessionService_RevertSession_FullMethodName = "/kagent.api.v1.SessionService/RevertSession" - SessionService_DeleteSession_FullMethodName = "/kagent.api.v1.SessionService/DeleteSession" - SessionService_CreateSessionShare_FullMethodName = "/kagent.api.v1.SessionService/CreateSessionShare" - SessionService_ListSessionShares_FullMethodName = "/kagent.api.v1.SessionService/ListSessionShares" - SessionService_RevokeSessionShare_FullMethodName = "/kagent.api.v1.SessionService/RevokeSessionShare" + SessionService_CreateSession_FullMethodName = "/kagent.api.v1.SessionService/CreateSession" + SessionService_GetSession_FullMethodName = "/kagent.api.v1.SessionService/GetSession" + SessionService_ListSessions_FullMethodName = "/kagent.api.v1.SessionService/ListSessions" + SessionService_UpdateSessionName_FullMethodName = "/kagent.api.v1.SessionService/UpdateSessionName" + SessionService_SetSessionNameIfEmpty_FullMethodName = "/kagent.api.v1.SessionService/SetSessionNameIfEmpty" + SessionService_SetSessionRecap_FullMethodName = "/kagent.api.v1.SessionService/SetSessionRecap" + SessionService_RevertSession_FullMethodName = "/kagent.api.v1.SessionService/RevertSession" + SessionService_DeleteSession_FullMethodName = "/kagent.api.v1.SessionService/DeleteSession" + SessionService_CreateSessionShare_FullMethodName = "/kagent.api.v1.SessionService/CreateSessionShare" + SessionService_ListSessionShares_FullMethodName = "/kagent.api.v1.SessionService/ListSessionShares" + SessionService_RevokeSessionShare_FullMethodName = "/kagent.api.v1.SessionService/RevokeSessionShare" ) // SessionServiceClient is the client API for SessionService service. @@ -44,6 +46,11 @@ type SessionServiceClient interface { GetSession(ctx context.Context, in *GetSessionRequest, opts ...grpc.CallOption) (*GetSessionResponse, error) ListSessions(ctx context.Context, in *ListSessionsRequest, opts ...grpc.CallOption) (*ListSessionsResponse, error) UpdateSessionName(ctx context.Context, in *UpdateSessionNameRequest, opts ...grpc.CallOption) (*UpdateSessionNameResponse, error) + // Names a conversation only while it has no name, so a generated title never + // replaces one a person chose. + SetSessionNameIfEmpty(ctx context.Context, in *SetSessionNameIfEmptyRequest, opts ...grpc.CallOption) (*SetSessionNameIfEmptyResponse, error) + // Stores the latest recap of the conversation, replacing any earlier one. + SetSessionRecap(ctx context.Context, in *SetSessionRecapRequest, opts ...grpc.CallOption) (*SetSessionRecapResponse, error) // Restores a crashed runtime to its last snapshot without replaying its turn. RevertSession(ctx context.Context, in *RevertSessionRequest, opts ...grpc.CallOption) (*RevertSessionResponse, error) DeleteSession(ctx context.Context, in *DeleteSessionRequest, opts ...grpc.CallOption) (*DeleteSessionResponse, error) @@ -100,6 +107,26 @@ func (c *sessionServiceClient) UpdateSessionName(ctx context.Context, in *Update return out, nil } +func (c *sessionServiceClient) SetSessionNameIfEmpty(ctx context.Context, in *SetSessionNameIfEmptyRequest, opts ...grpc.CallOption) (*SetSessionNameIfEmptyResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SetSessionNameIfEmptyResponse) + err := c.cc.Invoke(ctx, SessionService_SetSessionNameIfEmpty_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *sessionServiceClient) SetSessionRecap(ctx context.Context, in *SetSessionRecapRequest, opts ...grpc.CallOption) (*SetSessionRecapResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SetSessionRecapResponse) + err := c.cc.Invoke(ctx, SessionService_SetSessionRecap_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *sessionServiceClient) RevertSession(ctx context.Context, in *RevertSessionRequest, opts ...grpc.CallOption) (*RevertSessionResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(RevertSessionResponse) @@ -164,6 +191,11 @@ type SessionServiceServer interface { GetSession(context.Context, *GetSessionRequest) (*GetSessionResponse, error) ListSessions(context.Context, *ListSessionsRequest) (*ListSessionsResponse, error) UpdateSessionName(context.Context, *UpdateSessionNameRequest) (*UpdateSessionNameResponse, error) + // Names a conversation only while it has no name, so a generated title never + // replaces one a person chose. + SetSessionNameIfEmpty(context.Context, *SetSessionNameIfEmptyRequest) (*SetSessionNameIfEmptyResponse, error) + // Stores the latest recap of the conversation, replacing any earlier one. + SetSessionRecap(context.Context, *SetSessionRecapRequest) (*SetSessionRecapResponse, error) // Restores a crashed runtime to its last snapshot without replaying its turn. RevertSession(context.Context, *RevertSessionRequest) (*RevertSessionResponse, error) DeleteSession(context.Context, *DeleteSessionRequest) (*DeleteSessionResponse, error) @@ -192,6 +224,12 @@ func (UnimplementedSessionServiceServer) ListSessions(context.Context, *ListSess func (UnimplementedSessionServiceServer) UpdateSessionName(context.Context, *UpdateSessionNameRequest) (*UpdateSessionNameResponse, error) { return nil, status.Error(codes.Unimplemented, "method UpdateSessionName not implemented") } +func (UnimplementedSessionServiceServer) SetSessionNameIfEmpty(context.Context, *SetSessionNameIfEmptyRequest) (*SetSessionNameIfEmptyResponse, error) { + return nil, status.Error(codes.Unimplemented, "method SetSessionNameIfEmpty not implemented") +} +func (UnimplementedSessionServiceServer) SetSessionRecap(context.Context, *SetSessionRecapRequest) (*SetSessionRecapResponse, error) { + return nil, status.Error(codes.Unimplemented, "method SetSessionRecap not implemented") +} func (UnimplementedSessionServiceServer) RevertSession(context.Context, *RevertSessionRequest) (*RevertSessionResponse, error) { return nil, status.Error(codes.Unimplemented, "method RevertSession not implemented") } @@ -300,6 +338,42 @@ func _SessionService_UpdateSessionName_Handler(srv interface{}, ctx context.Cont return interceptor(ctx, in, info, handler) } +func _SessionService_SetSessionNameIfEmpty_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SetSessionNameIfEmptyRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SessionServiceServer).SetSessionNameIfEmpty(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SessionService_SetSessionNameIfEmpty_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SessionServiceServer).SetSessionNameIfEmpty(ctx, req.(*SetSessionNameIfEmptyRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SessionService_SetSessionRecap_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SetSessionRecapRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SessionServiceServer).SetSessionRecap(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SessionService_SetSessionRecap_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SessionServiceServer).SetSessionRecap(ctx, req.(*SetSessionRecapRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _SessionService_RevertSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(RevertSessionRequest) if err := dec(in); err != nil { @@ -413,6 +487,14 @@ var SessionService_ServiceDesc = grpc.ServiceDesc{ MethodName: "UpdateSessionName", Handler: _SessionService_UpdateSessionName_Handler, }, + { + MethodName: "SetSessionNameIfEmpty", + Handler: _SessionService_SetSessionNameIfEmpty_Handler, + }, + { + MethodName: "SetSessionRecap", + Handler: _SessionService_SetSessionRecap_Handler, + }, { MethodName: "RevertSession", Handler: _SessionService_RevertSession_Handler, diff --git a/go/core/internal/database/client_session_test.go b/go/core/internal/database/client_session_test.go index 21973bd144..7125932a35 100644 --- a/go/core/internal/database/client_session_test.go +++ b/go/core/internal/database/client_session_test.go @@ -780,6 +780,189 @@ func TestSessionNameRoundTripsAndRenames(t *testing.T) { } } +func TestSetSessionNameIfEmpty(t *testing.T) { + ctx := t.Context() + newClient := func(t *testing.T, name string) (*Client, string) { + client := NewClient(setupTestDB(t), "public") + sessionFixture(t, client, ctx, "team-a", "revision-1", "assistant", "kagent") + id := uuid.NewString() + _, _, err := client.CreateSession(ctx, newSessionRequest(id, "assistant", "kagent", name), id) + require.NoError(t, err) + return client, id + } + + t.Run("Given an unnamed session, when a name is set, assert that it is applied and persisted", func(t *testing.T) { + client, id := newClient(t, "") + session, applied, err := client.SetSessionNameIfEmpty(ctx, id, "alice", "Generated title") + require.NoError(t, err) + require.True(t, applied) + require.Equal(t, "Generated title", session.GetName()) + read, err := client.GetSession(ctx, id, "alice") + require.NoError(t, err) + require.Equal(t, "Generated title", read.GetName()) + }) + + t.Run("Given a named session, when a name is set, assert that the existing name is kept", func(t *testing.T) { + client, id := newClient(t, "Chosen by a person") + session, applied, err := client.SetSessionNameIfEmpty(ctx, id, "alice", "Generated title") + require.NoError(t, err) + require.False(t, applied) + require.Equal(t, "Chosen by a person", session.GetName()) + read, err := client.GetSession(ctx, id, "alice") + require.NoError(t, err) + require.Equal(t, "Chosen by a person", read.GetName()) + }) + + t.Run("Given a session renamed first, when a name is set afterwards, assert that the rename wins", func(t *testing.T) { + client, id := newClient(t, "") + _, err := client.UpdateSessionName(ctx, id, "alice", "Renamed by a person") + require.NoError(t, err) + _, applied, err := client.SetSessionNameIfEmpty(ctx, id, "alice", "Generated title") + require.NoError(t, err) + require.False(t, applied) + read, err := client.GetSession(ctx, id, "alice") + require.NoError(t, err) + require.Equal(t, "Renamed by a person", read.GetName()) + }) + + t.Run("Given a session owned by alice, when another user sets a name, assert that it is not found and unchanged", func(t *testing.T) { + client, id := newClient(t, "") + _, applied, err := client.SetSessionNameIfEmpty(ctx, id, "bob", "Stolen") + require.ErrorIs(t, err, ErrNotFound) + require.False(t, applied) + read, err := client.GetSession(ctx, id, "alice") + require.NoError(t, err) + require.Empty(t, read.GetName()) + }) + + t.Run("Given no such session, when a name is set, assert that it is not found", func(t *testing.T) { + client, _ := newClient(t, "") + _, applied, err := client.SetSessionNameIfEmpty(ctx, uuid.NewString(), "alice", "Nothing") + require.ErrorIs(t, err, ErrNotFound) + require.False(t, applied) + }) + + t.Run("Given a deleted session, when a name is set, assert that it is not found", func(t *testing.T) { + client, id := newClient(t, "") + _, err := markSessionReady(ctx, client, id, "runtime.example") + require.NoError(t, err) + require.NoError(t, deleteSession(ctx, client, id)) + _, applied, err := client.SetSessionNameIfEmpty(ctx, id, "alice", "Resurrect") + require.ErrorIs(t, err, ErrNotFound) + require.False(t, applied) + }) +} + +func TestSetSessionRecap(t *testing.T) { + ctx := t.Context() + newClient := func(t *testing.T) (*Client, string) { + client := NewClient(setupTestDB(t), "public") + sessionFixture(t, client, ctx, "team-a", "revision-1", "assistant", "kagent") + id := uuid.NewString() + _, _, err := client.CreateSession(ctx, newSessionRequest(id, "assistant", "kagent", "Chosen by a person"), id) + require.NoError(t, err) + return client, id + } + + t.Run("Given a session without a recap, when one is set, assert that text, turn count and time are stored", func(t *testing.T) { + client, id := newClient(t) + before := time.Now().Add(-time.Second) + session, err := client.SetSessionRecap(ctx, id, "alice", "Fixed the ingress.", 4) + require.NoError(t, err) + require.Equal(t, "Fixed the ingress.", session.GetRecap().GetText()) + read, err := client.GetSession(ctx, id, "alice") + require.NoError(t, err) + require.Equal(t, "Fixed the ingress.", read.GetRecap().GetText()) + require.Equal(t, int32(4), read.GetRecap().GetTurnCount()) + require.True(t, read.GetRecap().GetGeneratedAt().AsTime().After(before)) + }) + + t.Run("Given a stored recap, when a later one covers more turns, assert that it replaces the earlier one", func(t *testing.T) { + client, id := newClient(t) + _, err := client.SetSessionRecap(ctx, id, "alice", "First recap.", 2) + require.NoError(t, err) + _, err = client.SetSessionRecap(ctx, id, "alice", "Second recap.", 5) + require.NoError(t, err) + read, err := client.GetSession(ctx, id, "alice") + require.NoError(t, err) + require.Equal(t, "Second recap.", read.GetRecap().GetText()) + require.Equal(t, int32(5), read.GetRecap().GetTurnCount()) + }) + + t.Run("Given a stored recap, when one covering the same turns is set, assert that it replaces the stored one", func(t *testing.T) { + client, id := newClient(t) + _, err := client.SetSessionRecap(ctx, id, "alice", "First recap.", 3) + require.NoError(t, err) + _, err = client.SetSessionRecap(ctx, id, "alice", "Retried recap.", 3) + require.NoError(t, err) + read, err := client.GetSession(ctx, id, "alice") + require.NoError(t, err) + require.Equal(t, "Retried recap.", read.GetRecap().GetText()) + }) + + t.Run("Given a recap of turn 5, when a stale write for turn 3 arrives, assert that the stored recap is returned unchanged", func(t *testing.T) { + client, id := newClient(t) + _, err := client.SetSessionRecap(ctx, id, "alice", "Newer recap.", 5) + require.NoError(t, err) + session, err := client.SetSessionRecap(ctx, id, "alice", "Stale recap.", 3) + require.NoError(t, err) + require.Equal(t, "Newer recap.", session.GetRecap().GetText()) + require.Equal(t, int32(5), session.GetRecap().GetTurnCount()) + read, err := client.GetSession(ctx, id, "alice") + require.NoError(t, err) + require.Equal(t, "Newer recap.", read.GetRecap().GetText()) + require.Equal(t, int32(5), read.GetRecap().GetTurnCount()) + }) + + t.Run("Given a ready session, when a recap is set, assert that the name and state are untouched", func(t *testing.T) { + client, id := newClient(t) + _, err := markSessionReady(ctx, client, id, "runtime.example") + require.NoError(t, err) + _, err = client.SetSessionRecap(ctx, id, "alice", "Fixed the ingress.", 4) + require.NoError(t, err) + read, err := client.GetSession(ctx, id, "alice") + require.NoError(t, err) + require.Equal(t, "Chosen by a person", read.GetName()) + require.Equal(t, apiv1.RuntimeState_RUNTIME_STATE_READY, read.GetState()) + require.Equal(t, "Fixed the ingress.", read.GetRecap().GetText()) + }) + + t.Run("Given a session, when a recap is set, assert that its updated time does not change", func(t *testing.T) { + client, id := newClient(t) + before, err := client.GetSession(ctx, id, "alice") + require.NoError(t, err) + _, err = client.SetSessionRecap(ctx, id, "alice", "Fixed the ingress.", 4) + require.NoError(t, err) + after, err := client.GetSession(ctx, id, "alice") + require.NoError(t, err) + require.Equal(t, before.GetUpdatedAt().AsTime(), after.GetUpdatedAt().AsTime()) + }) + + t.Run("Given a session owned by alice, when another user sets a recap, assert that it is not found and unchanged", func(t *testing.T) { + client, id := newClient(t) + _, err := client.SetSessionRecap(ctx, id, "bob", "Stolen.", 1) + require.ErrorIs(t, err, ErrNotFound) + read, err := client.GetSession(ctx, id, "alice") + require.NoError(t, err) + require.Nil(t, read.GetRecap()) + }) + + t.Run("Given no such session, when a recap is set, assert that it is not found", func(t *testing.T) { + client, _ := newClient(t) + _, err := client.SetSessionRecap(ctx, uuid.NewString(), "alice", "Nothing.", 1) + require.ErrorIs(t, err, ErrNotFound) + }) + + t.Run("Given a deleted session, when a recap is set, assert that it is not found", func(t *testing.T) { + client, id := newClient(t) + _, err := markSessionReady(ctx, client, id, "runtime.example") + require.NoError(t, err) + require.NoError(t, deleteSession(ctx, client, id)) + _, err = client.SetSessionRecap(ctx, id, "alice", "Resurrect.", 1) + require.ErrorIs(t, err, ErrNotFound) + }) +} + func TestListSessionsFiltersByAgent(t *testing.T) { c := NewClient(setupTestDB(t), "public") ctx := t.Context() diff --git a/go/core/internal/database/sessions.go b/go/core/internal/database/sessions.go index f85e70648c..fb90a81d49 100644 --- a/go/core/internal/database/sessions.go +++ b/go/core/internal/database/sessions.go @@ -270,6 +270,94 @@ func (c *Client) UpdateSessionName(ctx context.Context, id, userID, name string) return result, nil } +// SetSessionNameIfEmpty names an owned session only while it has no name and +// reports whether it did. Missing, deleted, and foreign sessions return ErrNotFound. +func (c *Client) SetSessionNameIfEmpty(ctx context.Context, id, userID, name string) (*apiv1.Session, bool, error) { + var result *apiv1.Session + applied := false + err := c.withTx(ctx, func(tx pgx.Tx) error { + row, err := lockSession(ctx, tx, id) + if err != nil { + return notFoundOr(err) + } + if row.UserID != userID || row.State == apiv1.RuntimeState_RUNTIME_STATE_DELETED.String() { + return ErrNotFound + } + session, err := toSession(row) + if err != nil { + return err + } + result = session + if session.Name != "" { + return nil + } + session.Name = name + session.UpdatedAt = timestamppb.Now() + data, err := marshalSession(session) + if err != nil { + return err + } + tag, err := tx.Exec(ctx, ` + UPDATE session SET data = $1 WHERE id = $2 + `, data, row.ID) + if err != nil { + return err + } + if tag.RowsAffected() != 1 { + return ErrNotFound + } + applied = true + return nil + }) + if err != nil { + return nil, false, fmt.Errorf("name Session %s: %w", id, err) + } + return result, applied, nil +} + +// SetSessionRecap stores the latest recap of an owned session. A write covering +// fewer turns than the stored recap is ignored. It leaves UpdatedAt alone so a +// recap never reorders the session list. +func (c *Client) SetSessionRecap(ctx context.Context, id, userID, text string, turnCount int32) (*apiv1.Session, error) { + var result *apiv1.Session + err := c.withTx(ctx, func(tx pgx.Tx) error { + row, err := lockSession(ctx, tx, id) + if err != nil { + return notFoundOr(err) + } + if row.UserID != userID || row.State == apiv1.RuntimeState_RUNTIME_STATE_DELETED.String() { + return ErrNotFound + } + session, err := toSession(row) + if err != nil { + return err + } + result = session + if session.GetRecap().GetTurnCount() > turnCount { + return nil + } + session.Recap = &apiv1.SessionRecap{Text: text, TurnCount: turnCount, GeneratedAt: timestamppb.Now()} + data, err := marshalSession(session) + if err != nil { + return err + } + tag, err := tx.Exec(ctx, ` + UPDATE session SET data = $1 WHERE id = $2 + `, data, row.ID) + if err != nil { + return err + } + if tag.RowsAffected() != 1 { + return ErrNotFound + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("set recap of Session %s: %w", id, err) + } + return result, nil +} + type sessionRow struct { runtimeInstanceRow Data []byte diff --git a/go/core/internal/grpcserver/policy.go b/go/core/internal/grpcserver/policy.go index 3141f01d24..3ffaa00cf2 100644 --- a/go/core/internal/grpcserver/policy.go +++ b/go/core/internal/grpcserver/policy.go @@ -86,6 +86,8 @@ func DefaultMethodPolicies() MethodPolicies { // A rename is the only write on this service that is not a lifecycle // operation, and it must not inherit the read mode its neighbours carry. policies[apiv1.SessionService_UpdateSessionName_FullMethodName] = auth.AccessUpdate + policies[apiv1.SessionService_SetSessionNameIfEmpty_FullMethodName] = auth.AccessUpdate + policies[apiv1.SessionService_SetSessionRecap_FullMethodName] = auth.AccessUpdate policies[apiv1.SessionService_RevertSession_FullMethodName] = auth.AccessUpdate policies[apiv1.SessionService_DeleteSession_FullMethodName] = auth.AccessDelete policies[apiv1.SessionService_CreateSessionShare_FullMethodName] = auth.AccessCreate diff --git a/go/core/internal/grpcserver/policy_test.go b/go/core/internal/grpcserver/policy_test.go index b73e03c59c..030580e4c8 100644 --- a/go/core/internal/grpcserver/policy_test.go +++ b/go/core/internal/grpcserver/policy_test.go @@ -38,6 +38,8 @@ func TestSessionServicePoliciesMatchTheirEffect(t *testing.T) { {name: "create", method: apiv1.SessionService_CreateSession_FullMethodName, want: pkgauth.AccessCreate}, {name: "get", method: apiv1.SessionService_GetSession_FullMethodName, want: pkgauth.AccessRead}, {name: "list", method: apiv1.SessionService_ListSessions_FullMethodName, want: pkgauth.AccessRead}, + {name: "set name if empty is a write", method: apiv1.SessionService_SetSessionNameIfEmpty_FullMethodName, want: pkgauth.AccessUpdate}, + {name: "set recap is a write", method: apiv1.SessionService_SetSessionRecap_FullMethodName, want: pkgauth.AccessUpdate}, {name: "rename is a write", method: apiv1.SessionService_UpdateSessionName_FullMethodName, want: pkgauth.AccessUpdate}, {name: "revert", method: apiv1.SessionService_RevertSession_FullMethodName, want: pkgauth.AccessUpdate}, {name: "delete", method: apiv1.SessionService_DeleteSession_FullMethodName, want: pkgauth.AccessDelete}, @@ -76,6 +78,14 @@ func TestReadOnlyShareCannotRenameAConversation(t *testing.T) { name: "read-only share may not rename", method: apiv1.SessionService_UpdateSessionName_FullMethodName, wantCode: codes.PermissionDenied, }, + { + name: "read-only share may not set a name", + method: apiv1.SessionService_SetSessionNameIfEmpty_FullMethodName, wantCode: codes.PermissionDenied, + }, + { + name: "read-only share may not set a recap", + method: apiv1.SessionService_SetSessionRecap_FullMethodName, wantCode: codes.PermissionDenied, + }, } { t.Run(test.name, func(t *testing.T) { shareStore := &testShareStore{ diff --git a/go/core/internal/grpcserver/protovalidate_test.go b/go/core/internal/grpcserver/protovalidate_test.go index 64e1877b20..700f0fac0c 100644 --- a/go/core/internal/grpcserver/protovalidate_test.go +++ b/go/core/internal/grpcserver/protovalidate_test.go @@ -2,6 +2,7 @@ package grpcserver import ( "context" + "strings" "testing" "time" @@ -61,6 +62,20 @@ func TestSessionRequestValidation(t *testing.T) { {"invalid template filter", &apiv1.ListSessionsRequest{Agent: &apiv1.ResourceReference{Namespace: "team-a", Name: "NOT A NAME"}}, false}, {"valid rename", &apiv1.UpdateSessionNameRequest{SessionId: "11111111-1111-4111-8111-111111111111", Name: "New title"}, true}, {"invalid rename id", &apiv1.UpdateSessionNameRequest{SessionId: "not-a-uuid", Name: "New title"}, false}, + {"valid set name if empty", &apiv1.SetSessionNameIfEmptyRequest{SessionId: "11111111-1111-4111-8111-111111111111", Name: "Debugging the ingress"}, true}, + {"set name if empty without id", &apiv1.SetSessionNameIfEmptyRequest{Name: "Debugging the ingress"}, false}, + {"set name if empty with invalid id", &apiv1.SetSessionNameIfEmptyRequest{SessionId: "not-a-uuid", Name: "Debugging the ingress"}, false}, + {"set name if empty with empty name", &apiv1.SetSessionNameIfEmptyRequest{SessionId: "11111111-1111-4111-8111-111111111111"}, false}, + {"set name if empty with leading whitespace", &apiv1.SetSessionNameIfEmptyRequest{SessionId: "11111111-1111-4111-8111-111111111111", Name: " title"}, false}, + {"set name if empty with control character", &apiv1.SetSessionNameIfEmptyRequest{SessionId: "11111111-1111-4111-8111-111111111111", Name: "first\nsecond"}, false}, + {"set name if empty with overlong name", &apiv1.SetSessionNameIfEmptyRequest{SessionId: "11111111-1111-4111-8111-111111111111", Name: strings.Repeat("a", 201)}, false}, + {"valid set recap", &apiv1.SetSessionRecapRequest{SessionId: "11111111-1111-4111-8111-111111111111", Text: "Fixed the ingress.", TurnCount: 1}, true}, + {"set recap with invalid id", &apiv1.SetSessionRecapRequest{SessionId: "not-a-uuid", Text: "Fixed the ingress.", TurnCount: 1}, false}, + {"set recap with empty text", &apiv1.SetSessionRecapRequest{SessionId: "11111111-1111-4111-8111-111111111111", TurnCount: 1}, false}, + {"set recap with maximum text", &apiv1.SetSessionRecapRequest{SessionId: "11111111-1111-4111-8111-111111111111", Text: strings.Repeat("a", 2000), TurnCount: 1}, true}, + {"set recap with overlong text", &apiv1.SetSessionRecapRequest{SessionId: "11111111-1111-4111-8111-111111111111", Text: strings.Repeat("a", 2001), TurnCount: 1}, false}, + {"set recap with zero turns", &apiv1.SetSessionRecapRequest{SessionId: "11111111-1111-4111-8111-111111111111", Text: "Fixed the ingress."}, false}, + {"set recap with negative turns", &apiv1.SetSessionRecapRequest{SessionId: "11111111-1111-4111-8111-111111111111", Text: "Fixed the ingress.", TurnCount: -1}, false}, {"valid checkpoint rename", &apiv1.UpdateCheckpointNameRequest{CheckpointId: "11111111-1111-4111-8111-111111111111", Name: "Before the detour"}, true}, {"empty checkpoint rename", &apiv1.UpdateCheckpointNameRequest{CheckpointId: "11111111-1111-4111-8111-111111111111"}, true}, {"checkpoint without selected task", &apiv1.CreateCheckpointRequest{SessionId: "11111111-1111-4111-8111-111111111111", RequestId: "request"}, false}, @@ -88,6 +103,8 @@ func TestInvalidSessionAndCheckpointIDsNeverReachHandlers(t *testing.T) { requests := []proto.Message{ &apiv1.GetSessionRequest{SessionId: "invalid"}, &apiv1.UpdateSessionNameRequest{SessionId: "invalid"}, + &apiv1.SetSessionNameIfEmptyRequest{SessionId: "invalid", Name: "title"}, + &apiv1.SetSessionRecapRequest{SessionId: "invalid", Text: "recap", TurnCount: 1}, &apiv1.RevertSessionRequest{SessionId: "invalid"}, &apiv1.DeleteSessionRequest{SessionId: "invalid"}, &apiv1.CreateSessionShareRequest{SessionId: "invalid"}, diff --git a/go/core/internal/grpcserver/session.go b/go/core/internal/grpcserver/session.go index ed64db9edc..035bf073a7 100644 --- a/go/core/internal/grpcserver/session.go +++ b/go/core/internal/grpcserver/session.go @@ -51,6 +51,22 @@ func (s *sessionServer) UpdateSessionName(ctx context.Context, request *apiv1.Up return &apiv1.UpdateSessionNameResponse{Session: session}, nil } +func (s *sessionServer) SetSessionNameIfEmpty(ctx context.Context, request *apiv1.SetSessionNameIfEmptyRequest) (*apiv1.SetSessionNameIfEmptyResponse, error) { + session, applied, err := s.service.SetNameIfEmpty(ctx, request.GetSessionId(), request.GetName()) + if err != nil { + return nil, err + } + return &apiv1.SetSessionNameIfEmptyResponse{Session: session, Applied: applied}, nil +} + +func (s *sessionServer) SetSessionRecap(ctx context.Context, request *apiv1.SetSessionRecapRequest) (*apiv1.SetSessionRecapResponse, error) { + session, err := s.service.SetRecap(ctx, request.GetSessionId(), request.GetText(), request.GetTurnCount()) + if err != nil { + return nil, err + } + return &apiv1.SetSessionRecapResponse{Session: session}, nil +} + func (s *sessionServer) RevertSession(ctx context.Context, request *apiv1.RevertSessionRequest) (*apiv1.RevertSessionResponse, error) { session, err := s.service.Revert(ctx, request.GetSessionId()) if err != nil { diff --git a/go/core/internal/grpcserver/session_name_test.go b/go/core/internal/grpcserver/session_name_test.go new file mode 100644 index 0000000000..79ff25ded9 --- /dev/null +++ b/go/core/internal/grpcserver/session_name_test.go @@ -0,0 +1,161 @@ +package grpcserver + +import ( + "context" + "sync" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + + apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" + "github.com/kagent-dev/kagent/go/core/internal/database" + authimpl "github.com/kagent-dev/kagent/go/core/internal/httpserver/auth" + "github.com/kagent-dev/kagent/go/core/internal/jwttest" + "github.com/kagent-dev/kagent/go/core/internal/runtimeauth" + sessionsvc "github.com/kagent-dev/kagent/go/core/internal/service/session" + "github.com/kagent-dev/kagent/go/core/internal/substrate" + pkgauth "github.com/kagent-dev/kagent/go/core/pkg/auth" +) + +// runtimeNameStore holds one Session owned by "owner" and bound to one actor. +type runtimeNameStore struct { + *database.Client + mu sync.Mutex + session *apiv1.Session +} + +func (s *runtimeNameStore) GetSessionForRuntime(_ context.Context, id, uid string) (*apiv1.Session, error) { + if id != s.session.Id || uid != "actor-uid" { + return nil, database.ErrNotFound + } + return s.session, nil +} + +func (s *runtimeNameStore) SetSessionNameIfEmpty(_ context.Context, id, owner, name string) (*apiv1.Session, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + if id != s.session.Id || owner != s.session.Creator { + return nil, false, database.ErrNotFound + } + if s.session.Name != "" { + return s.session, false, nil + } + s.session.Name = name + return s.session, true, nil +} + +func (s *runtimeNameStore) SetSessionRecap(_ context.Context, id, owner, text string, turnCount int32) (*apiv1.Session, error) { + s.mu.Lock() + defer s.mu.Unlock() + if id != s.session.Id || owner != s.session.Creator { + return nil, database.ErrNotFound + } + s.session.Recap = &apiv1.SessionRecap{Text: text, TurnCount: turnCount} + return s.session, nil +} + +func TestRuntimeCallerNamesOnlyItsOwnSession(t *testing.T) { + const ownID = "11111111-1111-4111-8111-111111111111" + const otherID = "22222222-2222-4222-8222-222222222222" + newClient := func(t *testing.T) (apiv1.SessionServiceClient, *runtimeNameStore, *jwttest.Issuer) { + store := &runtimeNameStore{session: &apiv1.Session{ + Id: ownID, Creator: "owner", + A2AAuthority: substrate.ActorHost("team-a", substrate.ActorName(ownID), ""), + Agent: &apiv1.AgentProvenance{Ref: &apiv1.ResourceReference{Namespace: "team-a", Name: "assistant"}}, + }} + issuer := jwttest.New(t) + verifier, err := substrate.NewActorJWTVerifier(t.Context(), issuer.URL, issuer.CAFile, substrate.RuntimeJWTAudience) + require.NoError(t, err) + connection := newConfigurationConnection(t, nil, func(config *Config) { + config.Authenticator = runtimeauth.NewProvider(&authimpl.InsecureAuthenticator{}, verifier, store, issuer.URL) + config.SessionService = sessionsvc.NewService(store, runtimeauth.NewAuthorizer(pkgauth.NoopAuthorizer{}, store), nil) + }) + return apiv1.NewSessionServiceClient(connection), store, issuer + } + actor := func(t *testing.T, issuer *jwttest.Issuer) context.Context { + token := issuer.Token(t, issuer.Claims("team-a", substrate.ActorName(ownID), "actor-uid")) + return metadata.AppendToOutgoingContext(t.Context(), "authorization", "Bearer "+token) + } + + t.Run("Given an unnamed session, when its runtime sets a name, assert that the name is applied", func(t *testing.T) { + client, store, issuer := newClient(t) + response, err := client.SetSessionNameIfEmpty(actor(t, issuer), &apiv1.SetSessionNameIfEmptyRequest{SessionId: ownID, Name: "Generated title"}) + require.NoError(t, err) + require.True(t, response.GetApplied()) + require.Equal(t, "Generated title", response.GetSession().GetName()) + require.Equal(t, "Generated title", store.session.GetName()) + }) + + t.Run("Given a named session, when its runtime sets a name, assert that the existing name is kept", func(t *testing.T) { + client, store, issuer := newClient(t) + store.session.Name = "Chosen by a person" + response, err := client.SetSessionNameIfEmpty(actor(t, issuer), &apiv1.SetSessionNameIfEmptyRequest{SessionId: ownID, Name: "Generated title"}) + require.NoError(t, err) + require.False(t, response.GetApplied()) + require.Equal(t, "Chosen by a person", store.session.GetName()) + }) + + t.Run("Given another session, when a runtime sets its name, assert that the call is denied", func(t *testing.T) { + client, store, issuer := newClient(t) + _, err := client.SetSessionNameIfEmpty(actor(t, issuer), &apiv1.SetSessionNameIfEmptyRequest{SessionId: otherID, Name: "Forged"}) + assertGRPCCode(t, err, codes.PermissionDenied) + require.Empty(t, store.session.GetName()) + }) + + t.Run("Given a session owned by someone else, when another user sets its name, assert that it is not found", func(t *testing.T) { + client, store, _ := newClient(t) + ctx := metadata.NewOutgoingContext(t.Context(), metadata.Pairs("x-user-id", "mallory")) + _, err := client.SetSessionNameIfEmpty(ctx, &apiv1.SetSessionNameIfEmptyRequest{SessionId: ownID, Name: "Forged"}) + assertGRPCCode(t, err, codes.NotFound) + require.Empty(t, store.session.GetName()) + }) +} + +func TestRuntimeCallerStoresRecapOnlyOnItsOwnSession(t *testing.T) { + const ownID = "11111111-1111-4111-8111-111111111111" + const otherID = "22222222-2222-4222-8222-222222222222" + newClient := func(t *testing.T) (apiv1.SessionServiceClient, *runtimeNameStore, *jwttest.Issuer) { + store := &runtimeNameStore{session: &apiv1.Session{ + Id: ownID, Creator: "owner", + A2AAuthority: substrate.ActorHost("team-a", substrate.ActorName(ownID), ""), + Agent: &apiv1.AgentProvenance{Ref: &apiv1.ResourceReference{Namespace: "team-a", Name: "assistant"}}, + }} + issuer := jwttest.New(t) + verifier, err := substrate.NewActorJWTVerifier(t.Context(), issuer.URL, issuer.CAFile, substrate.RuntimeJWTAudience) + require.NoError(t, err) + connection := newConfigurationConnection(t, nil, func(config *Config) { + config.Authenticator = runtimeauth.NewProvider(&authimpl.InsecureAuthenticator{}, verifier, store, issuer.URL) + config.SessionService = sessionsvc.NewService(store, runtimeauth.NewAuthorizer(pkgauth.NoopAuthorizer{}, store), nil) + }) + return apiv1.NewSessionServiceClient(connection), store, issuer + } + actor := func(t *testing.T, issuer *jwttest.Issuer) context.Context { + token := issuer.Token(t, issuer.Claims("team-a", substrate.ActorName(ownID), "actor-uid")) + return metadata.AppendToOutgoingContext(t.Context(), "authorization", "Bearer "+token) + } + + t.Run("Given a session, when its runtime stores a recap, assert that the recap is returned and persisted", func(t *testing.T) { + client, store, issuer := newClient(t) + response, err := client.SetSessionRecap(actor(t, issuer), &apiv1.SetSessionRecapRequest{SessionId: ownID, Text: "Fixed the ingress.", TurnCount: 4}) + require.NoError(t, err) + require.Equal(t, "Fixed the ingress.", response.GetSession().GetRecap().GetText()) + require.Equal(t, int32(4), store.session.GetRecap().GetTurnCount()) + }) + + t.Run("Given another session, when a runtime stores a recap on it, assert that the call is denied", func(t *testing.T) { + client, store, issuer := newClient(t) + _, err := client.SetSessionRecap(actor(t, issuer), &apiv1.SetSessionRecapRequest{SessionId: otherID, Text: "Forged.", TurnCount: 1}) + assertGRPCCode(t, err, codes.PermissionDenied) + require.Nil(t, store.session.GetRecap()) + }) + + t.Run("Given a session owned by someone else, when another user stores a recap, assert that it is not found", func(t *testing.T) { + client, store, _ := newClient(t) + ctx := metadata.NewOutgoingContext(t.Context(), metadata.Pairs("x-user-id", "mallory")) + _, err := client.SetSessionRecap(ctx, &apiv1.SetSessionRecapRequest{SessionId: ownID, Text: "Forged.", TurnCount: 1}) + assertGRPCCode(t, err, codes.NotFound) + require.Nil(t, store.session.GetRecap()) + }) +} diff --git a/go/core/internal/grpcserver/session_test.go b/go/core/internal/grpcserver/session_test.go index 14e426128e..8dd077a846 100644 --- a/go/core/internal/grpcserver/session_test.go +++ b/go/core/internal/grpcserver/session_test.go @@ -43,6 +43,25 @@ func (s *sessionAuthorizationStore) UpdateSessionName(ctx context.Context, id, o return &apiv1.Session{Id: session.Id, Creator: session.Creator, Agent: session.Agent, Name: name}, nil } +func (s *sessionAuthorizationStore) SetSessionNameIfEmpty(ctx context.Context, id, owner, name string) (*apiv1.Session, bool, error) { + session, err := s.GetSession(ctx, id, owner) + if err != nil { + return nil, false, err + } + if session.Name != "" { + return session, false, nil + } + return &apiv1.Session{Id: session.Id, Creator: session.Creator, Agent: session.Agent, Name: name}, true, nil +} + +func (s *sessionAuthorizationStore) SetSessionRecap(ctx context.Context, id, owner, text string, turnCount int32) (*apiv1.Session, error) { + session, err := s.GetSession(ctx, id, owner) + if err != nil { + return nil, err + } + return &apiv1.Session{Id: session.Id, Creator: session.Creator, Agent: session.Agent, Recap: &apiv1.SessionRecap{Text: text, TurnCount: turnCount}}, nil +} + func (s *sessionAuthorizationStore) ListSessions(_ context.Context, query database.SessionQuery) ([]*apiv1.Session, error) { s.lists.Add(1) var rows []*apiv1.Session @@ -142,6 +161,17 @@ func TestSessionPermissionsAndOwnershipThroughGeneratedClient(t *testing.T) { assertGRPCCode(t, err, codes.PermissionDenied) require.Equal(t, before, store.reads.Load()) + named, err := client.SetSessionNameIfEmpty(caller("bob", false), &apiv1.SetSessionNameIfEmptyRequest{SessionId: bobID, Name: "generated"}) + require.NoError(t, err) + require.True(t, named.GetApplied()) + require.Equal(t, "generated", named.GetSession().GetName()) + _, err = client.SetSessionNameIfEmpty(caller("operator", false), &apiv1.SetSessionNameIfEmptyRequest{SessionId: aliceID, Name: "forged"}) + assertGRPCCode(t, err, codes.NotFound) + before = store.reads.Load() + _, err = client.SetSessionNameIfEmpty(caller("viewer", false), &apiv1.SetSessionNameIfEmptyRequest{SessionId: bobID, Name: "forged"}) + assertGRPCCode(t, err, codes.PermissionDenied) + require.Equal(t, before, store.reads.Load()) + shared, err := client.GetSession(caller("bob", true), &apiv1.GetSessionRequest{SessionId: aliceID}) require.NoError(t, err) require.Equal(t, "alice", shared.GetSession().Creator) diff --git a/go/core/internal/mcp/sessions_test.go b/go/core/internal/mcp/sessions_test.go index 16fb6da811..1cc31e7f92 100644 --- a/go/core/internal/mcp/sessions_test.go +++ b/go/core/internal/mcp/sessions_test.go @@ -78,6 +78,30 @@ func (s *sessionStore) UpdateSessionName(_ context.Context, id, _, name string) return session, nil } +func (s *sessionStore) SetSessionNameIfEmpty(_ context.Context, id, _, name string) (*apiv1.Session, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + session, ok := s.byID[id] + if !ok { + return nil, false, database.ErrNotFound + } + if session.Name != "" { + return session, false, nil + } + session.Name = name + return session, true, nil +} + +func (s *sessionStore) SetSessionRecap(_ context.Context, id, _, _ string, _ int32) (*apiv1.Session, error) { + s.mu.Lock() + defer s.mu.Unlock() + session, ok := s.byID[id] + if !ok { + return nil, database.ErrNotFound + } + return session, nil +} + func (s *sessionStore) ListSessions(_ context.Context, query database.SessionQuery) ([]*apiv1.Session, error) { s.mu.Lock() defer s.mu.Unlock() diff --git a/go/core/internal/mcp/tasks_test.go b/go/core/internal/mcp/tasks_test.go index ad562ee138..8d80bde7f4 100644 --- a/go/core/internal/mcp/tasks_test.go +++ b/go/core/internal/mcp/tasks_test.go @@ -550,6 +550,14 @@ func (*fakeSessionStore) UpdateSessionName(context.Context, string, string, stri return nil, database.ErrNotFound } +func (*fakeSessionStore) SetSessionNameIfEmpty(context.Context, string, string, string) (*apiv1.Session, bool, error) { + return nil, false, database.ErrNotFound +} + +func (*fakeSessionStore) SetSessionRecap(context.Context, string, string, string, int32) (*apiv1.Session, error) { + return nil, database.ErrNotFound +} + func (*fakeSessionStore) CreateSessionShare(context.Context, *apiv1.SessionShare, []byte, string) (*apiv1.SessionShare, error) { return nil, database.ErrNotFound } diff --git a/go/core/internal/service/session/service.go b/go/core/internal/service/session/service.go index bb2578a4de..b40f5659b9 100644 --- a/go/core/internal/service/session/service.go +++ b/go/core/internal/service/session/service.go @@ -32,6 +32,8 @@ type store interface { GetSessionByID(context.Context, string) (*apiv1.Session, error) ListSessions(context.Context, database.SessionQuery) ([]*apiv1.Session, error) UpdateSessionName(context.Context, string, string, string) (*apiv1.Session, error) + SetSessionNameIfEmpty(context.Context, string, string, string) (*apiv1.Session, bool, error) + SetSessionRecap(context.Context, string, string, string, int32) (*apiv1.Session, error) CreateSessionShare(context.Context, *apiv1.SessionShare, []byte, string) (*apiv1.SessionShare, error) ListSessionShares(context.Context, string, string, string, int) ([]*apiv1.SessionShare, error) DeleteSessionShare(context.Context, string, string) error @@ -196,6 +198,48 @@ func (s *Service) Rename(ctx context.Context, id, name string) (*apiv1.Session, return session, nil } +// SetNameIfEmpty names the conversation only while it has no name, and reports +// whether it did. It authorizes as a write, like Rename. +func (s *Service) SetNameIfEmpty(ctx context.Context, id, name string) (*apiv1.Session, bool, error) { + id, err := validateIdentity(id) + if err != nil { + return nil, false, err + } + creator, err := s.authorize(ctx, auth.VerbUpdate, id) + if err != nil { + return nil, false, err + } + session, applied, err := s.store.SetSessionNameIfEmpty(ctx, id, creator, name) + if errors.Is(err, database.ErrNotFound) { + return nil, false, serviceerrors.NewNotFound("Session not found", err) + } + if err != nil { + return nil, false, serviceerrors.NewInternal("Failed to name Session", err) + } + return session, applied, nil +} + +// SetRecap stores the latest recap of the conversation. It authorizes as a +// write, like Rename. +func (s *Service) SetRecap(ctx context.Context, id, text string, turnCount int32) (*apiv1.Session, error) { + id, err := validateIdentity(id) + if err != nil { + return nil, err + } + creator, err := s.authorize(ctx, auth.VerbUpdate, id) + if err != nil { + return nil, err + } + session, err := s.store.SetSessionRecap(ctx, id, creator, text, turnCount) + if errors.Is(err, database.ErrNotFound) { + return nil, serviceerrors.NewNotFound("Session not found", err) + } + if err != nil { + return nil, serviceerrors.NewInternal("Failed to store recap", err) + } + return session, nil +} + func (s *Service) List(ctx context.Context, request ListRequest) (ListResult, error) { pageSize := request.PageSize if pageSize == 0 { diff --git a/go/core/internal/service/session/service_test.go b/go/core/internal/service/session/service_test.go index 8e312517f1..01c47b066a 100644 --- a/go/core/internal/service/session/service_test.go +++ b/go/core/internal/service/session/service_test.go @@ -51,6 +51,15 @@ type serviceTestStore struct { renameName string renameUserID string renameErr error + setNameUserID string + setNameCalls int + recapCalls int + recapUserID string + recapText string + recapTurnCount int32 + recapResult *apiv1.Session + setNameResult *apiv1.Session + setNameApplied bool getCreator string getID string unscoped bool @@ -108,6 +117,24 @@ func (s *serviceTestStore) UpdateSessionName(_ context.Context, id, userID, name return s.renamed, nil } +func (s *serviceTestStore) SetSessionNameIfEmpty(_ context.Context, _, userID, _ string) (*apiv1.Session, bool, error) { + s.setNameCalls++ + s.setNameUserID = userID + if s.renameErr != nil { + return nil, false, s.renameErr + } + return s.setNameResult, s.setNameApplied, nil +} + +func (s *serviceTestStore) SetSessionRecap(_ context.Context, _, userID, text string, turnCount int32) (*apiv1.Session, error) { + s.recapCalls++ + s.recapUserID, s.recapText, s.recapTurnCount = userID, text, turnCount + if s.renameErr != nil { + return nil, s.renameErr + } + return s.recapResult, nil +} + func (s *serviceTestStore) CreateSessionShare(_ context.Context, share *apiv1.SessionShare, tokenHash []byte, userID string) (*apiv1.SessionShare, error) { s.share, s.tokenHash, s.shareUserID = share, tokenHash, userID return s.share, s.shareErr @@ -445,6 +472,107 @@ func TestServiceRenameRequiresWriteAuthorizationAndScopesToTheOwner(t *testing.T }) } +func TestServiceSetNameIfEmptyAuthorizesAsAWriteAndScopesToTheOwner(t *testing.T) { + sessionID := "11111111-1111-4111-8111-111111111111" + + t.Run("Given an unauthorized caller, when it sets a name, assert that the store is not reached", func(t *testing.T) { + store := &serviceTestStore{} + service := NewService(store, serviceTestAuthorizer{err: errors.New("denied")}, serviceTestWorkflow{}) + _, _, err := service.SetNameIfEmpty(serviceTestContext("alice"), sessionID, "Generated title") + if !serviceerrors.IsCode(err, serviceerrors.CodePermissionDenied) { + t.Fatalf("SetNameIfEmpty() error = %v, want code %s", err, serviceerrors.CodePermissionDenied) + } + if store.setNameCalls != 0 { + t.Fatal("SetNameIfEmpty() reached the store despite being unauthorized") + } + }) + + t.Run("Given an authorized owner, when it sets a name, assert that the update verb is checked and the result is returned", func(t *testing.T) { + authorizer := &recordingAuthorizer{} + store := &serviceTestStore{setNameResult: &apiv1.Session{Id: sessionID, Name: "Generated title"}, setNameApplied: true} + service := NewService(store, authorizer, serviceTestWorkflow{}) + session, applied, err := service.SetNameIfEmpty(serviceTestContext("alice"), sessionID, "Generated title") + if err != nil { + t.Fatal(err) + } + if authorizer.verb != auth.VerbUpdate || store.setNameUserID != "alice" || !applied || session.GetName() != "Generated title" { + t.Fatalf("verb = %q, owner = %q, applied = %t, session = %+v", authorizer.verb, store.setNameUserID, applied, session) + } + }) + + t.Run("Given a named session, when the name is set again, assert that applied is false", func(t *testing.T) { + store := &serviceTestStore{setNameResult: &apiv1.Session{Id: sessionID, Name: "Chosen by a person"}} + service := NewService(store, serviceTestAuthorizer{}, serviceTestWorkflow{}) + session, applied, err := service.SetNameIfEmpty(serviceTestContext("alice"), sessionID, "Generated title") + if err != nil || applied || session.GetName() != "Chosen by a person" { + t.Fatalf("SetNameIfEmpty() = %+v, applied %t, error %v", session, applied, err) + } + }) + + t.Run("Given a missing session, when a name is set, assert that the error is not found", func(t *testing.T) { + store := &serviceTestStore{renameErr: database.ErrNotFound} + service := NewService(store, serviceTestAuthorizer{}, serviceTestWorkflow{}) + _, _, err := service.SetNameIfEmpty(serviceTestContext("alice"), sessionID, "Generated title") + if !serviceerrors.IsCode(err, serviceerrors.CodeNotFound) { + t.Fatalf("SetNameIfEmpty() error = %v, want code %s", err, serviceerrors.CodeNotFound) + } + }) + + t.Run("Given a malformed id, when a name is set, assert that the argument is rejected", func(t *testing.T) { + service := NewService(&serviceTestStore{}, serviceTestAuthorizer{}, serviceTestWorkflow{}) + _, _, err := service.SetNameIfEmpty(serviceTestContext("alice"), "not-a-uuid", "Generated title") + if !serviceerrors.IsCode(err, serviceerrors.CodeInvalidArgument) { + t.Fatalf("SetNameIfEmpty() error = %v, want code %s", err, serviceerrors.CodeInvalidArgument) + } + }) +} + +func TestServiceSetRecapAuthorizesAsAWriteAndScopesToTheOwner(t *testing.T) { + sessionID := "11111111-1111-4111-8111-111111111111" + + t.Run("Given an unauthorized caller, when it sets a recap, assert that the store is not reached", func(t *testing.T) { + store := &serviceTestStore{} + service := NewService(store, serviceTestAuthorizer{err: errors.New("denied")}, serviceTestWorkflow{}) + _, err := service.SetRecap(serviceTestContext("alice"), sessionID, "Fixed the ingress.", 4) + if !serviceerrors.IsCode(err, serviceerrors.CodePermissionDenied) { + t.Fatalf("SetRecap() error = %v, want code %s", err, serviceerrors.CodePermissionDenied) + } + if store.recapCalls != 0 { + t.Fatal("SetRecap() reached the store despite being unauthorized") + } + }) + + t.Run("Given an authorized owner, when it sets a recap, assert that the update verb is checked and the owner scopes the write", func(t *testing.T) { + authorizer := &recordingAuthorizer{} + store := &serviceTestStore{recapResult: &apiv1.Session{Id: sessionID, Recap: &apiv1.SessionRecap{Text: "Fixed the ingress.", TurnCount: 4}}} + service := NewService(store, authorizer, serviceTestWorkflow{}) + session, err := service.SetRecap(serviceTestContext("alice"), sessionID, "Fixed the ingress.", 4) + if err != nil { + t.Fatal(err) + } + if authorizer.verb != auth.VerbUpdate || store.recapUserID != "alice" || store.recapText != "Fixed the ingress." || store.recapTurnCount != 4 || session.GetRecap().GetText() != "Fixed the ingress." { + t.Fatalf("verb = %q, owner = %q, text = %q, turns = %d, session = %+v", authorizer.verb, store.recapUserID, store.recapText, store.recapTurnCount, session) + } + }) + + t.Run("Given a missing session, when a recap is set, assert that the error is not found", func(t *testing.T) { + store := &serviceTestStore{renameErr: database.ErrNotFound} + service := NewService(store, serviceTestAuthorizer{}, serviceTestWorkflow{}) + _, err := service.SetRecap(serviceTestContext("alice"), sessionID, "Fixed the ingress.", 4) + if !serviceerrors.IsCode(err, serviceerrors.CodeNotFound) { + t.Fatalf("SetRecap() error = %v, want code %s", err, serviceerrors.CodeNotFound) + } + }) + + t.Run("Given a malformed id, when a recap is set, assert that the argument is rejected", func(t *testing.T) { + service := NewService(&serviceTestStore{}, serviceTestAuthorizer{}, serviceTestWorkflow{}) + _, err := service.SetRecap(serviceTestContext("alice"), "not-a-uuid", "Fixed the ingress.", 4) + if !serviceerrors.IsCode(err, serviceerrors.CodeInvalidArgument) { + t.Fatalf("SetRecap() error = %v, want code %s", err, serviceerrors.CodeInvalidArgument) + } + }) +} + type recordingAuthorizer struct { verb auth.Verb resource auth.Resource diff --git a/proto/kagent/api/v1/sessions.proto b/proto/kagent/api/v1/sessions.proto index 93651ce5c0..9569714d98 100644 --- a/proto/kagent/api/v1/sessions.proto +++ b/proto/kagent/api/v1/sessions.proto @@ -20,6 +20,11 @@ service SessionService { rpc GetSession(GetSessionRequest) returns (GetSessionResponse); rpc ListSessions(ListSessionsRequest) returns (ListSessionsResponse); rpc UpdateSessionName(UpdateSessionNameRequest) returns (UpdateSessionNameResponse); + // Names a conversation only while it has no name, so a generated title never + // replaces one a person chose. + rpc SetSessionNameIfEmpty(SetSessionNameIfEmptyRequest) returns (SetSessionNameIfEmptyResponse); + // Stores the latest recap of the conversation, replacing any earlier one. + rpc SetSessionRecap(SetSessionRecapRequest) returns (SetSessionRecapResponse); // Restores a crashed runtime to its last snapshot without replaying its turn. rpc RevertSession(RevertSessionRequest) returns (RevertSessionResponse); rpc DeleteSession(DeleteSessionRequest) returns (DeleteSessionResponse); @@ -50,6 +55,17 @@ message Session { // Output only. A2A conversation ID, equal to this session's ID. // Forks receive a new conversation ID and new task IDs. string context_id = 14; + // Output only. Latest generated summary of the conversation. Unset until a + // runtime writes one. + SessionRecap recap = 16; +} + +message SessionRecap { + string text = 1; + // Number of turns the recap covers. A reader compares it with the + // conversation's turn count to tell whether the recap is current. + int32 turn_count = 2; + google.protobuf.Timestamp generated_at = 3; } message AgentProvenance { @@ -109,6 +125,34 @@ message UpdateSessionNameResponse { Session session = 1; } +message SetSessionNameIfEmptyRequest { + string session_id = 1 [(buf.validate.field).string.uuid = true]; + string name = 2 [(buf.validate.field).string = { + min_len: 1 + max_len: 200 + pattern: "^[^\\p{Z}\\p{Cc}](?:[^\\p{Cc}]*[^\\p{Z}\\p{Cc}])?$" + }]; +} + +message SetSessionNameIfEmptyResponse { + Session session = 1; + // True when this call set the name; false when the Session already had one. + bool applied = 2; +} + +message SetSessionRecapRequest { + string session_id = 1 [(buf.validate.field).string.uuid = true]; + string text = 2 [(buf.validate.field).string = { + min_len: 1 + max_len: 2000 + }]; + int32 turn_count = 3 [(buf.validate.field).int32.gte = 1]; +} + +message SetSessionRecapResponse { + Session session = 1; +} + message RevertSessionRequest { string session_id = 1 [(buf.validate.field).string.uuid = true]; } diff --git a/ui/playwright/helpers/app.ts b/ui/playwright/helpers/app.ts index 0465025589..c19e26c81e 100644 --- a/ui/playwright/helpers/app.ts +++ b/ui/playwright/helpers/app.ts @@ -46,6 +46,10 @@ export const instances = { idle: "b28e4f13-5c66-4d90-8f2b-77a1e9c34d05", /** Failed, with a reason the conversation's record page shows. */ failed: "d4b02f87-3a55-4c18-9e6b-1f70c9a8e332", + /** Three turns and a recap that covers them. */ + recapCurrent: "c71a5d3e-92b4-4f08-a6e1-5d0b83f2c917", + /** Four turns and a recap that covers only three. */ + recapStale: "e03f8b64-17c9-4d52-b8a0-6a94d1e7f25b", /** Somebody else's: listable under its agent, and not openable. */ someoneElses: "8e5f2b09-6c14-4a7d-83b0-9d1c7e40f5a6", } as const; diff --git a/ui/playwright/tests/chat/agent-rail.spec.ts b/ui/playwright/tests/chat/agent-rail.spec.ts index 67e66090a8..2323d879bf 100644 --- a/ui/playwright/tests/chat/agent-rail.spec.ts +++ b/ui/playwright/tests/chat/agent-rail.spec.ts @@ -11,7 +11,7 @@ import { loadPage, withScenario, } from "../../helpers/app"; -import { expectTurnFinished } from "../../helpers/chat"; +import { expectTurnFinished, sendMessage } from "../../helpers/chat"; import { dialog, pressOnce, pressUntil } from "../../helpers/resource"; /** @@ -774,3 +774,22 @@ test("chat agent rail: a failed read says so, and is not an empty list", async ( ).toBeVisible(); }); }); + +/** + * The runtime names a session during its first turn, so the rail should show that name + * when the turn ends — not the first-message title until a reload. + */ +test("chat agent rail: a name written during a turn replaces the first-message title without a reload", async ({ + page, +}) => { + await page.goto(`${agentChat(AUTO_TITLED)}?chat=names`); + const row = page.getByTestId(`chat-session-${AUTO_TITLED}`); + await expect(row).toContainText("Summarise last night's deploy", { timeout: 30_000 }); + await expect(row).not.toContainText("Pod count check"); + + const turn = await sendMessage(page, "How many pods are running?"); + await expectTurnFinished(page, turn); + + await expect(row).toContainText("Pod count check"); + await expect(row).not.toContainText("Summarise last night's deploy"); +}); diff --git a/ui/playwright/tests/chat/recap.spec.ts b/ui/playwright/tests/chat/recap.spec.ts new file mode 100644 index 0000000000..d5f5cca9f4 --- /dev/null +++ b/ui/playwright/tests/chat/recap.spec.ts @@ -0,0 +1,70 @@ +import type { Page } from "@playwright/test"; +import { test, expect } from "../../fixtures/test"; +import { agentChat, instances, loadPage } from "../../helpers/app"; +import { sendMessage } from "../../helpers/chat"; + +/** + * The recap card: a stored summary shown after the last message once the reader has + * been away from the chat for three minutes. + * + * The clock is Playwright's, so the three minutes pass instantly and nothing waits. + */ + +const AWAY_MS = 3 * 60 * 1000; + +/** An open conversation on a clock the test controls. */ +async function openChat(page: Page, id: string): Promise { + await page.clock.install(); + await loadPage(page, agentChat(id)); + await expect(page.getByTestId("chat-message").first()).toBeVisible(); +} + +/** Time passes with nobody in the pane, then the pointer comes back. */ +async function goAwayAndReturn(page: Page): Promise { + await page.clock.fastForward(AWAY_MS); + await page.getByTestId("chat-transcript").hover(); +} + +test("chat recap: a key press in the composer counts as returning", async ({ page }) => { + await openChat(page, instances.recapCurrent); + await page.clock.fastForward(AWAY_MS); + await page.getByRole("textbox").last().focus(); + await page.keyboard.press("a"); + await expect(page.getByRole("region", { name: "Recap" })).toBeVisible(); +}); + +test("chat recap: shows once the reader returns, and not otherwise", async ({ page }) => { + const recap = page.getByRole("region", { name: "Recap" }); + + await test.step("1. it is absent while the reader has not been away", async () => { + await openChat(page, instances.recapCurrent); + await expect(page.getByTestId("chat-message")).toHaveCount(6); + await page.getByTestId("chat-transcript").hover(); + await expect(recap).toHaveCount(0); + }); + + await test.step("2. it appears after the last message when they return from three minutes away", async () => { + await goAwayAndReturn(page); + await expect(recap).toBeVisible(); + await expect(recap).toContainText("liveness probe"); + const followsLastMessage = await recap.evaluate((card) => { + const messages = document.querySelectorAll('[data-testid="chat-message"]'); + const last = messages[messages.length - 1]; + return Boolean(last.compareDocumentPosition(card) & Node.DOCUMENT_POSITION_FOLLOWING); + }); + expect(followsLastMessage, "the recap should sit after the last message").toBe(true); + }); + + await test.step("3. sending a message dismisses it", async () => { + await sendMessage(page, "One more question"); + await expect(page.getByTestId("chat-message").filter({ hasText: "One more question" })).toBeVisible(); + await expect(recap).toHaveCount(0); + }); + + await test.step("4. it is absent when it no longer covers every turn", async () => { + await openChat(page, instances.recapStale); + await expect(page.getByTestId("chat-message")).toHaveCount(8); + await goAwayAndReturn(page); + await expect(recap).toHaveCount(0); + }); +}); diff --git a/ui/src/api/chat/mockChatClient.ts b/ui/src/api/chat/mockChatClient.ts index bf9f3b2d43..a8b03a89ea 100644 --- a/ui/src/api/chat/mockChatClient.ts +++ b/ui/src/api/chat/mockChatClient.ts @@ -13,7 +13,7 @@ */ import { currentChatScenario } from "@/mocks/scenario"; -import { allAgentInstances, instanceShareForToken } from "@/mocks/state"; +import { allAgentInstances, instanceShareForToken, saveAgentInstance } from "@/mocks/state"; import { ApiError } from "../ApiError"; import { agentInstanceShareToken } from "../shareToken"; import { HITL_EXTENSION_URI, type PendingRequest } from "./hitl"; @@ -29,6 +29,9 @@ import type { } from "./types"; import { LinkFile, mediaTypeOf } from "./attachments"; +/** What the `names` scenario calls a session once its turn is done. */ +const GENERATED_NAME = "Pod count check"; + const TIMING = { ok: { step: 300, word: 45 }, // Slow enough that a turn can be reliably cancelled mid-stream, which is the @@ -37,6 +40,7 @@ const TIMING = { error: { step: 300, word: 45 }, asks: { step: 300, word: 45 }, "asks-text": { step: 300, word: 45 }, + names: { step: 300, word: 45 }, } as const; /** @@ -408,6 +412,11 @@ export class MockChatClient implements ChatClient { return; } + // The runtime names an unnamed session during its first turn, before the final event. + if (scenario === "names" && instance && !instance.name.trim()) { + saveAgentInstance({ ...instance, name: GENERATED_NAME, updatedAt: new Date().toISOString() }); + } + yield { type: "status", state: "completed", taskId }; } @@ -647,8 +656,21 @@ const SEEDED_TRANSCRIPTS: Record ChatMessage[]> = { "seed-task-2", ), ], + "c71a5d3e-92b4-4f08-a6e1-5d0b83f2c917": () => seededTurns("recap-current", 3), + "e03f8b64-17c9-4d52-b8a0-6a94d1e7f25b": () => seededTurns("recap-stale", 4), }; +/** `count` question-and-answer turns, for conversations that need a history of a given length. */ +function seededTurns(prefix: string, count: number): ChatMessage[] { + return Array.from({ length: count }, (_, i) => { + const task = `${prefix}-task-${i + 1}`; + return [ + message(`${prefix}-${i + 1}-user`, "user", `Question ${i + 1}`, task), + message(`${prefix}-${i + 1}-reply`, "agent", `Answer ${i + 1}`, task), + ]; + }).flat(); +} + function message( id: string, role: ChatMessage["role"], diff --git a/ui/src/api/domain/agentInstances.ts b/ui/src/api/domain/agentInstances.ts index 6c85dde927..c5f7694e7f 100644 --- a/ui/src/api/domain/agentInstances.ts +++ b/ui/src/api/domain/agentInstances.ts @@ -73,6 +73,15 @@ export interface AgentInstanceFailure { message?: string; } +/** A runtime-written summary of a conversation. */ +export interface ConversationRecap { + text: string; + /** How many turns the recap covers. */ + turnCount: number; + /** RFC3339, or empty when the record carried no timestamp. */ + generatedAt: string; +} + /** * One conversation with an agent. Mirrors the `AgentInstance` proto message. * @@ -107,6 +116,8 @@ export interface AgentInstance { operation: AgentInstanceOperation; /** Set only when the controller recorded a failure. */ failure?: AgentInstanceFailure; + /** Set once a runtime has written one. */ + recap?: ConversationRecap; /** RFC3339, or empty when the record carried no timestamp. */ createdAt: string; /** RFC3339, or empty when the record carried no timestamp. */ diff --git a/ui/src/api/grpc/operations.ts b/ui/src/api/grpc/operations.ts index f7e7916b05..9df8431665 100644 --- a/ui/src/api/grpc/operations.ts +++ b/ui/src/api/grpc/operations.ts @@ -605,6 +605,13 @@ function toAgentInstance(instance: PbSession): AgentInstance { message: orUndefined(instance.failure.message), } : undefined, + recap: instance.recap + ? { + text: instance.recap.text, + turnCount: instance.recap.turnCount, + generatedAt: isoFrom(instance.recap.generatedAt), + } + : undefined, createdAt: isoFrom(instance.createdAt), updatedAt: isoFrom(instance.updatedAt), }; diff --git a/ui/src/api/hooks/useConversationTitles.ts b/ui/src/api/hooks/useConversationTitles.ts index 3556631de7..79be45ca55 100644 --- a/ui/src/api/hooks/useConversationTitles.ts +++ b/ui/src/api/hooks/useConversationTitles.ts @@ -29,8 +29,8 @@ const TITLE_BUDGET = 30; * them. The gateway now answers a task read from the store whatever state the * instance is in, because that is where the transcript lives. * - * Display only, exactly as on the chat page: nothing is written back. A stored - * auto-title would be a name nobody chose, indistinguishable from one somebody did. + * Display only, exactly as on the chat page: the UI writes nothing back. The runtime + * names a session after its first turn; this covers sessions still unnamed. * * Failures are silent and per-conversation. A title is a convenience over an id that * already identifies the row, so a conversation whose read fails keeps its id rather diff --git a/ui/src/api/hooks/useRefreshConversationsOnTurnEnd.test.tsx b/ui/src/api/hooks/useRefreshConversationsOnTurnEnd.test.tsx new file mode 100644 index 0000000000..1df89bdc41 --- /dev/null +++ b/ui/src/api/hooks/useRefreshConversationsOnTurnEnd.test.tsx @@ -0,0 +1,43 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { SWRConfig } from "swr"; +import useSWR from "swr"; +import { describe, expect, it } from "vitest"; +import type { ChatPhase } from "./useChat"; +import { useRefreshConversationsOnTurnEnd } from "./useRefreshConversationsOnTurnEnd"; + +function setup() { + const reads = { list: 0, one: 0, other: 0 }; + const wrapper = ({ children }: { children: ReactNode }) => ( + new Map(), dedupingInterval: 0 }}>{children} + ); + const view = renderHook( + ({ phase }: { phase: ChatPhase }) => { + useSWR(["agentInstances.list", false], async () => ++reads.list, { + revalidateOnFocus: false, + }); + useSWR(["agentInstances.get", "a"], async () => ++reads.one, { revalidateOnFocus: false }); + useSWR(["models.list"], async () => ++reads.other, { revalidateOnFocus: false }); + useRefreshConversationsOnTurnEnd(phase); + }, + { wrapper, initialProps: { phase: "idle" as ChatPhase } }, + ); + return { reads, ...view }; +} + +describe("useRefreshConversationsOnTurnEnd", () => { + it("re-reads the conversation reads once when a turn ends, and nothing else", async () => { + const { reads, rerender } = setup(); + await waitFor(() => expect(reads).toEqual({ list: 1, one: 1, other: 1 })); + + rerender({ phase: "streaming" }); + rerender({ phase: "streaming" }); + expect(reads).toEqual({ list: 1, one: 1, other: 1 }); + + rerender({ phase: "idle" }); + await waitFor(() => expect(reads).toEqual({ list: 2, one: 2, other: 1 })); + + rerender({ phase: "idle" }); + expect(reads).toEqual({ list: 2, one: 2, other: 1 }); + }); +}); diff --git a/ui/src/api/hooks/useRefreshConversationsOnTurnEnd.ts b/ui/src/api/hooks/useRefreshConversationsOnTurnEnd.ts new file mode 100644 index 0000000000..f3ae010ae4 --- /dev/null +++ b/ui/src/api/hooks/useRefreshConversationsOnTurnEnd.ts @@ -0,0 +1,20 @@ +import { useEffect, useRef } from "react"; +import type { ChatPhase } from "./useChat"; +import { useInvalidateConversations } from "./useInvalidateConversations"; + +/** + * Re-reads the conversation lists once when a turn ends. + * + * The runtime names a session during its first turn, so the new name exists by the + * final event but nothing else would fetch it. Not per event: a list refresh mid-turn + * re-renders the chat and can abort the send in flight. + */ +export function useRefreshConversationsOnTurnEnd(phase: ChatPhase): void { + const invalidate = useInvalidateConversations(); + const previous = useRef(phase); + useEffect(() => { + const ended = previous.current === "streaming" && phase !== "streaming"; + previous.current = phase; + if (ended) void invalidate(); + }, [phase, invalidate]); +} diff --git a/ui/src/components/chat/ChatTranscript.tsx b/ui/src/components/chat/ChatTranscript.tsx index 0ac76403b5..8169065e0d 100644 --- a/ui/src/components/chat/ChatTranscript.tsx +++ b/ui/src/components/chat/ChatTranscript.tsx @@ -1,13 +1,16 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import type { ReactNode } from "react"; +import type { ReactNode, RefObject } from "react"; import { Alert, Button, Empty, Skeleton, Tag, Tooltip } from "antd"; import { ChevronDown } from "lucide-react"; import { useTheme } from "@emotion/react"; -import type { ChatController, ChatTurnPhase, Checkpoint } from "@/api"; +import type { ChatController, ChatTurnPhase, Checkpoint, ConversationRecap } from "@/api"; import { AskUserPrompt } from "./AskUserPrompt"; import { ChatMessageItem } from "./ChatMessageItem"; import { CheckpointDivider } from "./CheckpointDivider"; import { groupByCheckpoint } from "./messageCheckpoints"; +import { userTurnCount } from "./messageText"; +import { RecapCard } from "./RecapCard"; +import { useAwayRecap } from "./useAwayRecap"; import { scrollbarStyles } from "@/components/agent/controlStyles"; /** Stable, so a transcript with no boundaries does not regroup on every render. */ @@ -64,6 +67,9 @@ export function ChatTranscript({ onManageTaskPush, checkpointsById, checkpointByMessage, + recap, + recapTarget, + onAway, }: { chat: ChatController; /** Suppresses replies, approvals, cancellation, and retry controls. */ @@ -87,6 +93,12 @@ export function ChatTranscript({ checkpointsById?: ReadonlyMap; /** Which boundary each message sits inside, for the messages that sit inside one. */ checkpointByMessage?: ReadonlyMap; + /** The conversation's stored recap, shown after the last message when the reader returns. */ + recap?: ConversationRecap; + /** Where activity counts as being in the chat; the page passes the pane holding the composer too. */ + recapTarget?: RefObject; + /** Called when the reader comes back from an absence. */ + onAway?: () => void; /** * An `ask_user` answer has just gone. * @@ -120,6 +132,8 @@ export function ChatTranscript({ const bottomRef = useRef(null); /** The box that scrolls, which is this component's own — see the observer below. */ const scrollRef = useRef(null); + const turnCount = useMemo(() => userTurnCount(chat.messages), [chat.messages]); + const { show: showRecap, dismiss: dismissRecap } = useAwayRecap({ recap, turnCount, target: recapTarget ?? scrollRef, onAway }); const [isAtBottom, setAtBottom] = useState(true); /** How far short of the end still counts as being at it. */ const AT_BOTTOM_SLACK = 32; @@ -407,6 +421,8 @@ export function ChatTranscript({ }) )} + {showRecap && recap ? : null} + {statusLabel ? ( + + , + ); +} + +describe("RecapCard", () => { + it("is a labelled region carrying the recap text", () => { + renderCard(); + const card = screen.getByRole("region", { name: "Recap" }); + expect(within(card).getByText(RECAP.text)).toBeInTheDocument(); + }); + + it("says when it was made, in a machine-readable time", () => { + renderCard(); + const card = screen.getByRole("region", { name: "Recap" }); + expect(within(card).getByText(/^Written /)).toBeInTheDocument(); + expect(card.querySelector("time")).toHaveAttribute("datetime", RECAP.generatedAt); + }); + + it("is not an agent reply", () => { + renderCard(); + expect(screen.queryByTestId("chat-message")).not.toBeInTheDocument(); + expect(screen.getByTestId("chat-recap")).not.toHaveAttribute("data-role"); + }); + + it("calls onDismiss when the dismiss button is clicked", () => { + const onDismiss = vi.fn(); + renderCard(onDismiss); + fireEvent.click(screen.getByRole("button", { name: "Dismiss recap" })); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/src/components/chat/RecapCard.tsx b/ui/src/components/chat/RecapCard.tsx new file mode 100644 index 0000000000..6bfc9bed1f --- /dev/null +++ b/ui/src/components/chat/RecapCard.tsx @@ -0,0 +1,67 @@ +import { Typography } from "antd"; +import { Sparkles, X } from "lucide-react"; +import { useTheme } from "@emotion/react"; +import type { ConversationRecap } from "@/api"; +import { relativeAge } from "@/components/agent-instances/instanceLabels"; + +const { Text } = Typography; + +/** The stored recap of a conversation, set apart from the replies around it. */ +export function RecapCard({ recap, onDismiss }: { recap: ConversationRecap; onDismiss: () => void }) { + const theme = useTheme(); + return ( +
+
+ + + Recap + + +
+ {recap.text} + +
+ ); +} diff --git a/ui/src/components/chat/messageText.test.ts b/ui/src/components/chat/messageText.test.ts new file mode 100644 index 0000000000..a3b97eaa20 --- /dev/null +++ b/ui/src/components/chat/messageText.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import type { ChatMessage, ChatPart } from "@/api"; +import { userTurnCount } from "./messageText"; + +const message = (role: ChatMessage["role"], parts: ChatPart[]): ChatMessage => ({ + id: `${role}-${parts.length}`, + role, + parts, + createdAt: "2025-01-01T00:00:00Z", +}); + +const typed = (text: string) => message("user", [{ kind: "text", text }]); + +describe("userTurnCount", () => { + it("counts user messages that carry typed text", () => { + expect( + userTurnCount([typed("one"), message("agent", [{ kind: "text", text: "reply" }]), typed("two")]), + ).toBe(2); + }); + + it("skips a tool-approval answer", () => { + const approval = message("user", [ + { + kind: "tool_approval", + approval: { tools: [], decisions: [], askedBy: "agent" }, + } as ChatPart, + ]); + expect(userTurnCount([typed("one"), approval])).toBe(1); + }); + + it("skips an ask_user answer", () => { + const answer = message("user", [ + { + kind: "ask_user", + interaction: { questions: [], answers: [], askedBy: "agent" }, + } as ChatPart, + ]); + expect(userTurnCount([typed("one"), answer])).toBe(1); + }); + + it("skips a message that only sends files", () => { + const file = message("user", [{ kind: "file", name: "a.png", mediaType: "image/png" }]); + expect(userTurnCount([typed("one"), file])).toBe(1); + }); +}); diff --git a/ui/src/components/chat/messageText.ts b/ui/src/components/chat/messageText.ts index 7a644c4806..ae708d3fb2 100644 --- a/ui/src/components/chat/messageText.ts +++ b/ui/src/components/chat/messageText.ts @@ -22,3 +22,16 @@ export function messageSummary(message: ChatMessage): string { .join(", ") ); } + +/** + * The user turns the runtime counts: messages with typed text. Tool-approval and + * ask_user answers and file-only sends carry no text event, so they are left out. + */ +export function userTurnCount(messages: readonly ChatMessage[]): number { + return messages.filter( + (message) => + message.role === "user" && + messageText(message).trim() !== "" && + !message.parts.some((part) => part.kind === "tool_approval" || part.kind === "ask_user"), + ).length; +} diff --git a/ui/src/components/chat/useAwayRecap.test.tsx b/ui/src/components/chat/useAwayRecap.test.tsx new file mode 100644 index 0000000000..d67c995fd1 --- /dev/null +++ b/ui/src/components/chat/useAwayRecap.test.tsx @@ -0,0 +1,249 @@ +import { act, fireEvent, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ConversationRecap } from "@/api"; +import { RECAP_IDLE_MS, useAwayRecap } from "./useAwayRecap"; + +const RECAP: ConversationRecap = { + text: "Checkout was crashlooping on a failing liveness probe.", + turnCount: 3, + generatedAt: "2026-08-21T10:30:00Z", +}; + +interface Props { + recap: ConversationRecap | undefined; + turnCount: number; +} + +let pane: HTMLDivElement; + +/** One hook on the shared pane, with the clock the fake timers drive. */ +function mount(initial: Props, onAway?: () => void) { + return renderHook( + (props: Props) => useAwayRecap({ ...props, target: { current: pane }, now: () => Date.now(), onAway }), + { initialProps: initial }, + ); +} + +const wait = (ms: number) => act(() => void vi.advanceTimersByTime(ms)); + +const INTERACTIONS: Record void> = { + "pointer move": (el) => void fireEvent.pointerMove(el), + "key press": (el) => void fireEvent.keyDown(el, { key: "a" }), + scroll: (el) => void fireEvent.scroll(el), + focusin: (el) => void fireEvent.focusIn(el), +}; +const interact = () => act(() => INTERACTIONS["pointer move"](pane)); + +function setVisibility(state: "visible" | "hidden") { + Object.defineProperty(document, "visibilityState", { value: state, configurable: true }); + act(() => void document.dispatchEvent(new Event("visibilitychange"))); +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(0); + pane = document.createElement("div"); + document.body.appendChild(pane); +}); + +afterEach(() => { + pane.remove(); + setVisibility("visible"); + vi.useRealTimers(); +}); + +describe("useAwayRecap", () => { + it("is not shown before the reader has been away for three minutes", () => { + const { result } = mount({ recap: RECAP, turnCount: 3 }); + wait(RECAP_IDLE_MS - 1); + interact(); + expect(result.current.show).toBe(false); + // Three minutes of silence after that interaction is what turns it on. + wait(RECAP_IDLE_MS); + interact(); + expect(result.current.show).toBe(true); + }); + + it("waits for the first interaction rather than appearing on a timer", () => { + const { result } = mount({ recap: RECAP, turnCount: 3 }); + wait(RECAP_IDLE_MS * 2); + expect(result.current.show).toBe(false); + interact(); + expect(result.current.show).toBe(true); + }); + + it.each(Object.keys(INTERACTIONS))("counts %s as coming back", (kind) => { + const { result } = mount({ recap: RECAP, turnCount: 3 }); + wait(RECAP_IDLE_MS); + act(() => INTERACTIONS[kind](pane)); + expect(result.current.show).toBe(true); + }); + + it("does not count an interaction inside the window as an absence", () => { + const { result } = mount({ recap: RECAP, turnCount: 3 }); + wait(RECAP_IDLE_MS - 1000); + interact(); + wait(1000); + interact(); + expect(result.current.show).toBe(false); + // Three minutes of silence after the last interaction is an absence. + wait(RECAP_IDLE_MS); + interact(); + expect(result.current.show).toBe(true); + }); + + it("counts a key press in the composer, beside the transcript, as coming back", () => { + const composer = document.createElement("textarea"); + pane.appendChild(composer); + const { result } = mount({ recap: RECAP, turnCount: 3 }); + wait(RECAP_IDLE_MS); + act(() => void fireEvent.keyDown(composer, { key: "a" })); + expect(result.current.show).toBe(true); + }); + + it("counts a hidden tab as away", () => { + const { result } = mount({ recap: RECAP, turnCount: 3 }); + setVisibility("hidden"); + wait(RECAP_IDLE_MS); + setVisibility("visible"); + expect(result.current.show).toBe(false); + interact(); + expect(result.current.show).toBe(true); + }); + + it("counts a blurred window as away", () => { + const { result } = mount({ recap: RECAP, turnCount: 3 }); + act(() => void window.dispatchEvent(new Event("blur"))); + wait(RECAP_IDLE_MS); + act(() => void window.dispatchEvent(new Event("focus"))); + expect(result.current.show).toBe(false); + interact(); + expect(result.current.show).toBe(true); + }); + + it("does not count a short blur as away", () => { + const { result } = mount({ recap: RECAP, turnCount: 3 }); + act(() => void window.dispatchEvent(new Event("blur"))); + wait(RECAP_IDLE_MS - 1000); + act(() => void window.dispatchEvent(new Event("focus"))); + interact(); + expect(result.current.show).toBe(false); + wait(RECAP_IDLE_MS); + interact(); + expect(result.current.show).toBe(true); + }); + + it("never shows a stale recap, though a current one beside it shows", () => { + const stale = mount({ recap: RECAP, turnCount: 4 }); + const current = mount({ recap: RECAP, turnCount: 3 }); + wait(RECAP_IDLE_MS); + interact(); + expect(current.result.current.show).toBe(true); + expect(stale.result.current.show).toBe(false); + }); + + it("treats a recap covering more turns than the transcript holds as current", () => { + const { result } = mount({ recap: { ...RECAP, turnCount: 4 }, turnCount: 3 }); + wait(RECAP_IDLE_MS); + interact(); + expect(result.current.show).toBe(true); + }); + + it("never shows when there is no recap, though one beside it shows", () => { + const none = mount({ recap: undefined, turnCount: 3 }); + const some = mount({ recap: RECAP, turnCount: 3 }); + wait(RECAP_IDLE_MS); + interact(); + expect(some.result.current.show).toBe(true); + expect(none.result.current.show).toBe(false); + }); + + it("shows once per absence: further interaction neither hides nor repeats it", () => { + const { result } = mount({ recap: RECAP, turnCount: 3 }); + wait(RECAP_IDLE_MS); + interact(); + expect(result.current.show).toBe(true); + wait(60_000); + interact(); + expect(result.current.show).toBe(true); + }); + + it("is dismissed by a send, and stays dismissed for the rest of that absence", () => { + const { result, rerender } = mount({ recap: RECAP, turnCount: 3 }); + wait(RECAP_IDLE_MS); + interact(); + expect(result.current.show).toBe(true); + + // The reader sends, and the runtime writes a recap covering the new turn. + rerender({ recap: { ...RECAP, turnCount: 4 }, turnCount: 4 }); + interact(); + expect(result.current.show).toBe(false); + }); + + it("shows again after a later absence", () => { + const { result, rerender } = mount({ recap: RECAP, turnCount: 3 }); + wait(RECAP_IDLE_MS); + interact(); + expect(result.current.show).toBe(true); + rerender({ recap: { ...RECAP, turnCount: 4 }, turnCount: 4 }); + expect(result.current.show).toBe(false); + + wait(RECAP_IDLE_MS); + interact(); + expect(result.current.show).toBe(true); + }); + + it("hides the recap when it is dismissed", () => { + const { result } = mount({ recap: RECAP, turnCount: 3 }); + wait(RECAP_IDLE_MS); + interact(); + expect(result.current.show).toBe(true); + + act(() => result.current.dismiss()); + expect(result.current.show).toBe(false); + }); + + it("stays dismissed through quick interaction, and shows again after a later absence", () => { + const { result } = mount({ recap: RECAP, turnCount: 3 }); + wait(RECAP_IDLE_MS); + interact(); + act(() => result.current.dismiss()); + + wait(RECAP_IDLE_MS - 1000); + interact(); + expect(result.current.show).toBe(false); + + wait(RECAP_IDLE_MS); + interact(); + expect(result.current.show).toBe(true); + }); +}); + +describe("useAwayRecap onAway", () => { + it("calls back once when the reader comes back from an absence", () => { + const onAway = vi.fn(); + mount({ recap: RECAP, turnCount: 3 }, onAway); + wait(RECAP_IDLE_MS); + interact(); + interact(); + expect(onAway).toHaveBeenCalledTimes(1); + }); + + it("does not call back on a quick interaction", () => { + const onAway = vi.fn(); + mount({ recap: RECAP, turnCount: 3 }, onAway); + wait(RECAP_IDLE_MS - 1000); + interact(); + expect(onAway).not.toHaveBeenCalled(); + }); + + it("calls back again after a later absence", () => { + const onAway = vi.fn(); + mount({ recap: RECAP, turnCount: 3 }, onAway); + wait(RECAP_IDLE_MS); + interact(); + wait(RECAP_IDLE_MS); + interact(); + expect(onAway).toHaveBeenCalledTimes(2); + }); +}); diff --git a/ui/src/components/chat/useAwayRecap.ts b/ui/src/components/chat/useAwayRecap.ts new file mode 100644 index 0000000000..370881c0f9 --- /dev/null +++ b/ui/src/components/chat/useAwayRecap.ts @@ -0,0 +1,74 @@ +import { useEffect, useRef, useState } from "react"; +import type { RefObject } from "react"; +import type { ConversationRecap } from "@/api"; + +/** How long the reader must be away from the chat pane before a recap is offered. */ +export const RECAP_IDLE_MS = 180_000; + +const INTERACTIONS = ["pointermove", "keydown", "scroll", "focusin"] as const; + +/** Whether to show the stored recap: current, and the reader has just come back. */ +export function useAwayRecap({ + recap, + turnCount, + target, + now = Date.now, + onAway, +}: { + recap: ConversationRecap | undefined; + turnCount: number; + /** The whole chat pane (transcript and composer), whose pointer, key, scroll and focus events count as being here. */ + target: RefObject; + /** Injected so tests control the clock. */ + now?: () => number; + /** Called when the reader comes back from an absence, so the caller can fetch the latest recap. */ + onAway?: () => void; +}): { show: boolean; dismiss: () => void } { + // The turn count when the reader came back. A later send moves past it, which dismisses. + const [shownAtTurns, setShownAtTurns] = useState(null); + const turnsRef = useRef(turnCount); + const nowRef = useRef(now); + const onAwayRef = useRef(onAway); + useEffect(() => { + turnsRef.current = turnCount; + nowRef.current = now; + onAwayRef.current = onAway; + }); + + // The pane may mount after this hook does (a transcript still loading has none), so + // each commit checks whether it has appeared or changed. + const attached = useRef<{ element: HTMLElement; detach: () => void } | null>(null); + useEffect(() => { + const element = target.current; + if (attached.current?.element === element) return; + attached.current?.detach(); + attached.current = null; + if (!element) return; + // A hidden tab or blurred window raises no interactions, so time spent there ages this. + let lastInteraction = nowRef.current(); + const onInteraction = () => { + const time = nowRef.current(); + if (time - lastInteraction >= RECAP_IDLE_MS) { + setShownAtTurns(turnsRef.current); + onAwayRef.current?.(); + } + lastInteraction = time; + }; + for (const type of INTERACTIONS) element.addEventListener(type, onInteraction, { passive: true }); + attached.current = { + element, + detach: () => { + for (const type of INTERACTIONS) element.removeEventListener(type, onInteraction); + }, + }; + }); + useEffect(() => () => { + attached.current?.detach(); + attached.current = null; + }, []); + + return { + show: shownAtTurns === turnCount && recap !== undefined && recap.turnCount >= turnCount, + dismiss: () => setShownAtTurns(null), + }; +} diff --git a/ui/src/generated/kagent/api/v1/sessions_pb.ts b/ui/src/generated/kagent/api/v1/sessions_pb.ts index fdc14c14d4..1b45c5be28 100644 --- a/ui/src/generated/kagent/api/v1/sessions_pb.ts +++ b/ui/src/generated/kagent/api/v1/sessions_pb.ts @@ -17,7 +17,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file kagent/api/v1/sessions.proto. */ export const file_kagent_api_v1_sessions: GenFile = /*@__PURE__*/ - fileDesc("ChxrYWdlbnQvYXBpL3YxL3Nlc3Npb25zLnByb3RvEg1rYWdlbnQuYXBpLnYxIioKB0ZhaWx1cmUSDgoGcmVhc29uGAEgASgJEg8KB21lc3NhZ2UYAiABKAkikgMKB1Nlc3Npb24SCgoCaWQYASABKAkSDwoHY3JlYXRvchgCIAEoCRItCgVhZ2VudBgPIAEoCzIeLmthZ2VudC5hcGkudjEuQWdlbnRQcm92ZW5hbmNlEhkKEXByZXBhcmVkX3JldmlzaW9uGAUgASgJEhUKDWEyYV9hdXRob3JpdHkYBiABKAkSKgoFc3RhdGUYByABKA4yGy5rYWdlbnQuYXBpLnYxLlJ1bnRpbWVTdGF0ZRIyCglvcGVyYXRpb24YCCABKA4yHy5rYWdlbnQuYXBpLnYxLlJ1bnRpbWVPcGVyYXRpb24SJwoHZmFpbHVyZRgJIAEoCzIWLmthZ2VudC5hcGkudjEuRmFpbHVyZRIuCgpjcmVhdGVkX2F0GAogASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgp1cGRhdGVkX2F0GAsgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIMCgRuYW1lGA0gASgJEhIKCmNvbnRleHRfaWQYDiABKAkiTQoPQWdlbnRQcm92ZW5hbmNlEi0KA3JlZhgBIAEoCzIgLmthZ2VudC5hcGkudjEuUmVzb3VyY2VSZWZlcmVuY2USCwoDdWlkGAIgASgJIrwBChRDcmVhdGVTZXNzaW9uUmVxdWVzdBI3CgVhZ2VudBgFIAEoCzIgLmthZ2VudC5hcGkudjEuUmVzb3VyY2VSZWZlcmVuY2VCBrpIA8gBARIeCgpyZXF1ZXN0X2lkGAMgASgJQgq6SAdyBRABGIABEksKBG5hbWUYBCABKAlCPbpIOnI4GMgBMjNeKD86JHxbXlxwe1p9XHB7Q2N9XSg/OlteXHB7Q2N9XSpbXlxwe1p9XHB7Q2N9XSk/KSQiQAoVQ3JlYXRlU2Vzc2lvblJlc3BvbnNlEicKB3Nlc3Npb24YASABKAsyFi5rYWdlbnQuYXBpLnYxLlNlc3Npb24iMQoRR2V0U2Vzc2lvblJlcXVlc3QSHAoKc2Vzc2lvbl9pZBgBIAEoCUIIukgFcgOwAQEiPQoSR2V0U2Vzc2lvblJlc3BvbnNlEicKB3Nlc3Npb24YASABKAsyFi5rYWdlbnQuYXBpLnYxLlNlc3Npb24ihgEKE0xpc3RTZXNzaW9uc1JlcXVlc3QSFAoMYWxsX2NyZWF0b3JzGAIgASgIEigKBHBhZ2UYAyABKAsyGi5rYWdlbnQuYXBpLnYxLlBhZ2VSZXF1ZXN0Ei8KBWFnZW50GAYgASgLMiAua2FnZW50LmFwaS52MS5SZXNvdXJjZVJlZmVyZW5jZSJrChRMaXN0U2Vzc2lvbnNSZXNwb25zZRIoCghzZXNzaW9ucxgBIAMoCzIWLmthZ2VudC5hcGkudjEuU2Vzc2lvbhIpCgRwYWdlGAIgASgLMhsua2FnZW50LmFwaS52MS5QYWdlUmVzcG9uc2UihQEKGFVwZGF0ZVNlc3Npb25OYW1lUmVxdWVzdBIcCgpzZXNzaW9uX2lkGAEgASgJQgi6SAVyA7ABARJLCgRuYW1lGAIgASgJQj26SDpyOBjIATIzXig/OiR8W15ccHtafVxwe0NjfV0oPzpbXlxwe0NjfV0qW15ccHtafVxwe0NjfV0pPykkIkQKGVVwZGF0ZVNlc3Npb25OYW1lUmVzcG9uc2USJwoHc2Vzc2lvbhgBIAEoCzIWLmthZ2VudC5hcGkudjEuU2Vzc2lvbiI0ChRSZXZlcnRTZXNzaW9uUmVxdWVzdBIcCgpzZXNzaW9uX2lkGAEgASgJQgi6SAVyA7ABASJAChVSZXZlcnRTZXNzaW9uUmVzcG9uc2USJwoHc2Vzc2lvbhgBIAEoCzIWLmthZ2VudC5hcGkudjEuU2Vzc2lvbiJDChREZWxldGVTZXNzaW9uUmVxdWVzdBIcCgpzZXNzaW9uX2lkGAEgASgJQgi6SAVyA7ABARINCgVmb3JjZRgCIAEoCCJAChVEZWxldGVTZXNzaW9uUmVzcG9uc2USJwoHc2Vzc2lvbhgBIAEoCzIWLmthZ2VudC5hcGkudjEuU2Vzc2lvbiKOAQoMU2Vzc2lvblNoYXJlEgoKAmlkGAEgASgJEhIKCnNlc3Npb25faWQYAiABKAkSLgoKY3JlYXRlZF9hdBgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLgoKZXhwaXJlc19hdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiawoZQ3JlYXRlU2Vzc2lvblNoYXJlUmVxdWVzdBIcCgpzZXNzaW9uX2lkGAEgASgJQgi6SAVyA7ABARIwCgN0dGwYAiABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb25CCLpIBaoBAioAIlcKGkNyZWF0ZVNlc3Npb25TaGFyZVJlc3BvbnNlEioKBXNoYXJlGAEgASgLMhsua2FnZW50LmFwaS52MS5TZXNzaW9uU2hhcmUSDQoFdG9rZW4YAiABKAkiYgoYTGlzdFNlc3Npb25TaGFyZXNSZXF1ZXN0EhwKCnNlc3Npb25faWQYASABKAlCCLpIBXIDsAEBEigKBHBhZ2UYAiABKAsyGi5rYWdlbnQuYXBpLnYxLlBhZ2VSZXF1ZXN0InMKGUxpc3RTZXNzaW9uU2hhcmVzUmVzcG9uc2USKwoGc2hhcmVzGAEgAygLMhsua2FnZW50LmFwaS52MS5TZXNzaW9uU2hhcmUSKQoEcGFnZRgCIAEoCzIbLmthZ2VudC5hcGkudjEuUGFnZVJlc3BvbnNlIjcKGVJldm9rZVNlc3Npb25TaGFyZVJlcXVlc3QSGgoIc2hhcmVfaWQYASABKAlCCLpIBXIDsAEBIhwKGlJldm9rZVNlc3Npb25TaGFyZVJlc3BvbnNlMvYGCg5TZXNzaW9uU2VydmljZRJaCg1DcmVhdGVTZXNzaW9uEiMua2FnZW50LmFwaS52MS5DcmVhdGVTZXNzaW9uUmVxdWVzdBokLmthZ2VudC5hcGkudjEuQ3JlYXRlU2Vzc2lvblJlc3BvbnNlElEKCkdldFNlc3Npb24SIC5rYWdlbnQuYXBpLnYxLkdldFNlc3Npb25SZXF1ZXN0GiEua2FnZW50LmFwaS52MS5HZXRTZXNzaW9uUmVzcG9uc2USVwoMTGlzdFNlc3Npb25zEiIua2FnZW50LmFwaS52MS5MaXN0U2Vzc2lvbnNSZXF1ZXN0GiMua2FnZW50LmFwaS52MS5MaXN0U2Vzc2lvbnNSZXNwb25zZRJmChFVcGRhdGVTZXNzaW9uTmFtZRInLmthZ2VudC5hcGkudjEuVXBkYXRlU2Vzc2lvbk5hbWVSZXF1ZXN0Gigua2FnZW50LmFwaS52MS5VcGRhdGVTZXNzaW9uTmFtZVJlc3BvbnNlEloKDVJldmVydFNlc3Npb24SIy5rYWdlbnQuYXBpLnYxLlJldmVydFNlc3Npb25SZXF1ZXN0GiQua2FnZW50LmFwaS52MS5SZXZlcnRTZXNzaW9uUmVzcG9uc2USWgoNRGVsZXRlU2Vzc2lvbhIjLmthZ2VudC5hcGkudjEuRGVsZXRlU2Vzc2lvblJlcXVlc3QaJC5rYWdlbnQuYXBpLnYxLkRlbGV0ZVNlc3Npb25SZXNwb25zZRJpChJDcmVhdGVTZXNzaW9uU2hhcmUSKC5rYWdlbnQuYXBpLnYxLkNyZWF0ZVNlc3Npb25TaGFyZVJlcXVlc3QaKS5rYWdlbnQuYXBpLnYxLkNyZWF0ZVNlc3Npb25TaGFyZVJlc3BvbnNlEmYKEUxpc3RTZXNzaW9uU2hhcmVzEicua2FnZW50LmFwaS52MS5MaXN0U2Vzc2lvblNoYXJlc1JlcXVlc3QaKC5rYWdlbnQuYXBpLnYxLkxpc3RTZXNzaW9uU2hhcmVzUmVzcG9uc2USaQoSUmV2b2tlU2Vzc2lvblNoYXJlEigua2FnZW50LmFwaS52MS5SZXZva2VTZXNzaW9uU2hhcmVSZXF1ZXN0Gikua2FnZW50LmFwaS52MS5SZXZva2VTZXNzaW9uU2hhcmVSZXNwb25zZUI9WjtnaXRodWIuY29tL2thZ2VudC1kZXYva2FnZW50L2dvL2FwaS9nZW4va2FnZW50L2FwaS92MTthcGl2MWIGcHJvdG8z", [file_buf_validate_validate, file_google_protobuf_duration, file_google_protobuf_timestamp, file_kagent_api_v1_common, file_kagent_api_v1_runtime]); + fileDesc("ChxrYWdlbnQvYXBpL3YxL3Nlc3Npb25zLnByb3RvEg1rYWdlbnQuYXBpLnYxIioKB0ZhaWx1cmUSDgoGcmVhc29uGAEgASgJEg8KB21lc3NhZ2UYAiABKAkivgMKB1Nlc3Npb24SCgoCaWQYASABKAkSDwoHY3JlYXRvchgCIAEoCRItCgVhZ2VudBgPIAEoCzIeLmthZ2VudC5hcGkudjEuQWdlbnRQcm92ZW5hbmNlEhkKEXByZXBhcmVkX3JldmlzaW9uGAUgASgJEhUKDWEyYV9hdXRob3JpdHkYBiABKAkSKgoFc3RhdGUYByABKA4yGy5rYWdlbnQuYXBpLnYxLlJ1bnRpbWVTdGF0ZRIyCglvcGVyYXRpb24YCCABKA4yHy5rYWdlbnQuYXBpLnYxLlJ1bnRpbWVPcGVyYXRpb24SJwoHZmFpbHVyZRgJIAEoCzIWLmthZ2VudC5hcGkudjEuRmFpbHVyZRIuCgpjcmVhdGVkX2F0GAogASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgp1cGRhdGVkX2F0GAsgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIMCgRuYW1lGA0gASgJEhIKCmNvbnRleHRfaWQYDiABKAkSKgoFcmVjYXAYECABKAsyGy5rYWdlbnQuYXBpLnYxLlNlc3Npb25SZWNhcCJiCgxTZXNzaW9uUmVjYXASDAoEdGV4dBgBIAEoCRISCgp0dXJuX2NvdW50GAIgASgFEjAKDGdlbmVyYXRlZF9hdBgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiTQoPQWdlbnRQcm92ZW5hbmNlEi0KA3JlZhgBIAEoCzIgLmthZ2VudC5hcGkudjEuUmVzb3VyY2VSZWZlcmVuY2USCwoDdWlkGAIgASgJIrwBChRDcmVhdGVTZXNzaW9uUmVxdWVzdBI3CgVhZ2VudBgFIAEoCzIgLmthZ2VudC5hcGkudjEuUmVzb3VyY2VSZWZlcmVuY2VCBrpIA8gBARIeCgpyZXF1ZXN0X2lkGAMgASgJQgq6SAdyBRABGIABEksKBG5hbWUYBCABKAlCPbpIOnI4GMgBMjNeKD86JHxbXlxwe1p9XHB7Q2N9XSg/OlteXHB7Q2N9XSpbXlxwe1p9XHB7Q2N9XSk/KSQiQAoVQ3JlYXRlU2Vzc2lvblJlc3BvbnNlEicKB3Nlc3Npb24YASABKAsyFi5rYWdlbnQuYXBpLnYxLlNlc3Npb24iMQoRR2V0U2Vzc2lvblJlcXVlc3QSHAoKc2Vzc2lvbl9pZBgBIAEoCUIIukgFcgOwAQEiPQoSR2V0U2Vzc2lvblJlc3BvbnNlEicKB3Nlc3Npb24YASABKAsyFi5rYWdlbnQuYXBpLnYxLlNlc3Npb24ihgEKE0xpc3RTZXNzaW9uc1JlcXVlc3QSFAoMYWxsX2NyZWF0b3JzGAIgASgIEigKBHBhZ2UYAyABKAsyGi5rYWdlbnQuYXBpLnYxLlBhZ2VSZXF1ZXN0Ei8KBWFnZW50GAYgASgLMiAua2FnZW50LmFwaS52MS5SZXNvdXJjZVJlZmVyZW5jZSJrChRMaXN0U2Vzc2lvbnNSZXNwb25zZRIoCghzZXNzaW9ucxgBIAMoCzIWLmthZ2VudC5hcGkudjEuU2Vzc2lvbhIpCgRwYWdlGAIgASgLMhsua2FnZW50LmFwaS52MS5QYWdlUmVzcG9uc2UihQEKGFVwZGF0ZVNlc3Npb25OYW1lUmVxdWVzdBIcCgpzZXNzaW9uX2lkGAEgASgJQgi6SAVyA7ABARJLCgRuYW1lGAIgASgJQj26SDpyOBjIATIzXig/OiR8W15ccHtafVxwe0NjfV0oPzpbXlxwe0NjfV0qW15ccHtafVxwe0NjfV0pPykkIkQKGVVwZGF0ZVNlc3Npb25OYW1lUmVzcG9uc2USJwoHc2Vzc2lvbhgBIAEoCzIWLmthZ2VudC5hcGkudjEuU2Vzc2lvbiKFAQocU2V0U2Vzc2lvbk5hbWVJZkVtcHR5UmVxdWVzdBIcCgpzZXNzaW9uX2lkGAEgASgJQgi6SAVyA7ABARJHCgRuYW1lGAIgASgJQjm6SDZyNBABGMgBMi1eW15ccHtafVxwe0NjfV0oPzpbXlxwe0NjfV0qW15ccHtafVxwe0NjfV0pPyQiWQodU2V0U2Vzc2lvbk5hbWVJZkVtcHR5UmVzcG9uc2USJwoHc2Vzc2lvbhgBIAEoCzIWLmthZ2VudC5hcGkudjEuU2Vzc2lvbhIPCgdhcHBsaWVkGAIgASgIIm0KFlNldFNlc3Npb25SZWNhcFJlcXVlc3QSHAoKc2Vzc2lvbl9pZBgBIAEoCUIIukgFcgOwAQESGAoEdGV4dBgCIAEoCUIKukgHcgUQARjQDxIbCgp0dXJuX2NvdW50GAMgASgFQge6SAQaAigBIkIKF1NldFNlc3Npb25SZWNhcFJlc3BvbnNlEicKB3Nlc3Npb24YASABKAsyFi5rYWdlbnQuYXBpLnYxLlNlc3Npb24iNAoUUmV2ZXJ0U2Vzc2lvblJlcXVlc3QSHAoKc2Vzc2lvbl9pZBgBIAEoCUIIukgFcgOwAQEiQAoVUmV2ZXJ0U2Vzc2lvblJlc3BvbnNlEicKB3Nlc3Npb24YASABKAsyFi5rYWdlbnQuYXBpLnYxLlNlc3Npb24iQwoURGVsZXRlU2Vzc2lvblJlcXVlc3QSHAoKc2Vzc2lvbl9pZBgBIAEoCUIIukgFcgOwAQESDQoFZm9yY2UYAiABKAgiQAoVRGVsZXRlU2Vzc2lvblJlc3BvbnNlEicKB3Nlc3Npb24YASABKAsyFi5rYWdlbnQuYXBpLnYxLlNlc3Npb24ijgEKDFNlc3Npb25TaGFyZRIKCgJpZBgBIAEoCRISCgpzZXNzaW9uX2lkGAIgASgJEi4KCmNyZWF0ZWRfYXQYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCmV4cGlyZXNfYXQYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wImsKGUNyZWF0ZVNlc3Npb25TaGFyZVJlcXVlc3QSHAoKc2Vzc2lvbl9pZBgBIAEoCUIIukgFcgOwAQESMAoDdHRsGAIgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uQgi6SAWqAQIqACJXChpDcmVhdGVTZXNzaW9uU2hhcmVSZXNwb25zZRIqCgVzaGFyZRgBIAEoCzIbLmthZ2VudC5hcGkudjEuU2Vzc2lvblNoYXJlEg0KBXRva2VuGAIgASgJImIKGExpc3RTZXNzaW9uU2hhcmVzUmVxdWVzdBIcCgpzZXNzaW9uX2lkGAEgASgJQgi6SAVyA7ABARIoCgRwYWdlGAIgASgLMhoua2FnZW50LmFwaS52MS5QYWdlUmVxdWVzdCJzChlMaXN0U2Vzc2lvblNoYXJlc1Jlc3BvbnNlEisKBnNoYXJlcxgBIAMoCzIbLmthZ2VudC5hcGkudjEuU2Vzc2lvblNoYXJlEikKBHBhZ2UYAiABKAsyGy5rYWdlbnQuYXBpLnYxLlBhZ2VSZXNwb25zZSI3ChlSZXZva2VTZXNzaW9uU2hhcmVSZXF1ZXN0EhoKCHNoYXJlX2lkGAEgASgJQgi6SAVyA7ABASIcChpSZXZva2VTZXNzaW9uU2hhcmVSZXNwb25zZTLMCAoOU2Vzc2lvblNlcnZpY2USWgoNQ3JlYXRlU2Vzc2lvbhIjLmthZ2VudC5hcGkudjEuQ3JlYXRlU2Vzc2lvblJlcXVlc3QaJC5rYWdlbnQuYXBpLnYxLkNyZWF0ZVNlc3Npb25SZXNwb25zZRJRCgpHZXRTZXNzaW9uEiAua2FnZW50LmFwaS52MS5HZXRTZXNzaW9uUmVxdWVzdBohLmthZ2VudC5hcGkudjEuR2V0U2Vzc2lvblJlc3BvbnNlElcKDExpc3RTZXNzaW9ucxIiLmthZ2VudC5hcGkudjEuTGlzdFNlc3Npb25zUmVxdWVzdBojLmthZ2VudC5hcGkudjEuTGlzdFNlc3Npb25zUmVzcG9uc2USZgoRVXBkYXRlU2Vzc2lvbk5hbWUSJy5rYWdlbnQuYXBpLnYxLlVwZGF0ZVNlc3Npb25OYW1lUmVxdWVzdBooLmthZ2VudC5hcGkudjEuVXBkYXRlU2Vzc2lvbk5hbWVSZXNwb25zZRJyChVTZXRTZXNzaW9uTmFtZUlmRW1wdHkSKy5rYWdlbnQuYXBpLnYxLlNldFNlc3Npb25OYW1lSWZFbXB0eVJlcXVlc3QaLC5rYWdlbnQuYXBpLnYxLlNldFNlc3Npb25OYW1lSWZFbXB0eVJlc3BvbnNlEmAKD1NldFNlc3Npb25SZWNhcBIlLmthZ2VudC5hcGkudjEuU2V0U2Vzc2lvblJlY2FwUmVxdWVzdBomLmthZ2VudC5hcGkudjEuU2V0U2Vzc2lvblJlY2FwUmVzcG9uc2USWgoNUmV2ZXJ0U2Vzc2lvbhIjLmthZ2VudC5hcGkudjEuUmV2ZXJ0U2Vzc2lvblJlcXVlc3QaJC5rYWdlbnQuYXBpLnYxLlJldmVydFNlc3Npb25SZXNwb25zZRJaCg1EZWxldGVTZXNzaW9uEiMua2FnZW50LmFwaS52MS5EZWxldGVTZXNzaW9uUmVxdWVzdBokLmthZ2VudC5hcGkudjEuRGVsZXRlU2Vzc2lvblJlc3BvbnNlEmkKEkNyZWF0ZVNlc3Npb25TaGFyZRIoLmthZ2VudC5hcGkudjEuQ3JlYXRlU2Vzc2lvblNoYXJlUmVxdWVzdBopLmthZ2VudC5hcGkudjEuQ3JlYXRlU2Vzc2lvblNoYXJlUmVzcG9uc2USZgoRTGlzdFNlc3Npb25TaGFyZXMSJy5rYWdlbnQuYXBpLnYxLkxpc3RTZXNzaW9uU2hhcmVzUmVxdWVzdBooLmthZ2VudC5hcGkudjEuTGlzdFNlc3Npb25TaGFyZXNSZXNwb25zZRJpChJSZXZva2VTZXNzaW9uU2hhcmUSKC5rYWdlbnQuYXBpLnYxLlJldm9rZVNlc3Npb25TaGFyZVJlcXVlc3QaKS5rYWdlbnQuYXBpLnYxLlJldm9rZVNlc3Npb25TaGFyZVJlc3BvbnNlQj1aO2dpdGh1Yi5jb20va2FnZW50LWRldi9rYWdlbnQvZ28vYXBpL2dlbi9rYWdlbnQvYXBpL3YxO2FwaXYxYgZwcm90bzM", [file_buf_validate_validate, file_google_protobuf_duration, file_google_protobuf_timestamp, file_kagent_api_v1_common, file_kagent_api_v1_runtime]); /** * @generated from message kagent.api.v1.Failure @@ -111,6 +111,14 @@ export type Session = Message<"kagent.api.v1.Session"> & { * @generated from field: string context_id = 14; */ contextId: string; + + /** + * Output only. Latest generated summary of the conversation. Unset until a + * runtime writes one. + * + * @generated from field: kagent.api.v1.SessionRecap recap = 16; + */ + recap?: SessionRecap | undefined; }; /** @@ -120,6 +128,36 @@ export type Session = Message<"kagent.api.v1.Session"> & { export const SessionSchema: GenMessage = /*@__PURE__*/ messageDesc(file_kagent_api_v1_sessions, 1); +/** + * @generated from message kagent.api.v1.SessionRecap + */ +export type SessionRecap = Message<"kagent.api.v1.SessionRecap"> & { + /** + * @generated from field: string text = 1; + */ + text: string; + + /** + * Number of turns the recap covers. A reader compares it with the + * conversation's turn count to tell whether the recap is current. + * + * @generated from field: int32 turn_count = 2; + */ + turnCount: number; + + /** + * @generated from field: google.protobuf.Timestamp generated_at = 3; + */ + generatedAt?: Timestamp | undefined; +}; + +/** + * Describes the message kagent.api.v1.SessionRecap. + * Use `create(SessionRecapSchema)` to create a new message. + */ +export const SessionRecapSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_kagent_api_v1_sessions, 2); + /** * @generated from message kagent.api.v1.AgentProvenance */ @@ -140,7 +178,7 @@ export type AgentProvenance = Message<"kagent.api.v1.AgentProvenance"> & { * Use `create(AgentProvenanceSchema)` to create a new message. */ export const AgentProvenanceSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 2); + messageDesc(file_kagent_api_v1_sessions, 3); /** * @generated from message kagent.api.v1.CreateSessionRequest @@ -169,7 +207,7 @@ export type CreateSessionRequest = Message<"kagent.api.v1.CreateSessionRequest"> * Use `create(CreateSessionRequestSchema)` to create a new message. */ export const CreateSessionRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 3); + messageDesc(file_kagent_api_v1_sessions, 4); /** * @generated from message kagent.api.v1.CreateSessionResponse @@ -186,7 +224,7 @@ export type CreateSessionResponse = Message<"kagent.api.v1.CreateSessionResponse * Use `create(CreateSessionResponseSchema)` to create a new message. */ export const CreateSessionResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 4); + messageDesc(file_kagent_api_v1_sessions, 5); /** * @generated from message kagent.api.v1.GetSessionRequest @@ -203,7 +241,7 @@ export type GetSessionRequest = Message<"kagent.api.v1.GetSessionRequest"> & { * Use `create(GetSessionRequestSchema)` to create a new message. */ export const GetSessionRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 5); + messageDesc(file_kagent_api_v1_sessions, 6); /** * @generated from message kagent.api.v1.GetSessionResponse @@ -220,7 +258,7 @@ export type GetSessionResponse = Message<"kagent.api.v1.GetSessionResponse"> & { * Use `create(GetSessionResponseSchema)` to create a new message. */ export const GetSessionResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 6); + messageDesc(file_kagent_api_v1_sessions, 7); /** * @generated from message kagent.api.v1.ListSessionsRequest @@ -251,7 +289,7 @@ export type ListSessionsRequest = Message<"kagent.api.v1.ListSessionsRequest"> & * Use `create(ListSessionsRequestSchema)` to create a new message. */ export const ListSessionsRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 7); + messageDesc(file_kagent_api_v1_sessions, 8); /** * @generated from message kagent.api.v1.ListSessionsResponse @@ -273,7 +311,7 @@ export type ListSessionsResponse = Message<"kagent.api.v1.ListSessionsResponse"> * Use `create(ListSessionsResponseSchema)` to create a new message. */ export const ListSessionsResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 8); + messageDesc(file_kagent_api_v1_sessions, 9); /** * @generated from message kagent.api.v1.UpdateSessionNameRequest @@ -298,7 +336,7 @@ export type UpdateSessionNameRequest = Message<"kagent.api.v1.UpdateSessionNameR * Use `create(UpdateSessionNameRequestSchema)` to create a new message. */ export const UpdateSessionNameRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 9); + messageDesc(file_kagent_api_v1_sessions, 10); /** * @generated from message kagent.api.v1.UpdateSessionNameResponse @@ -315,7 +353,97 @@ export type UpdateSessionNameResponse = Message<"kagent.api.v1.UpdateSessionName * Use `create(UpdateSessionNameResponseSchema)` to create a new message. */ export const UpdateSessionNameResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 10); + messageDesc(file_kagent_api_v1_sessions, 11); + +/** + * @generated from message kagent.api.v1.SetSessionNameIfEmptyRequest + */ +export type SetSessionNameIfEmptyRequest = Message<"kagent.api.v1.SetSessionNameIfEmptyRequest"> & { + /** + * @generated from field: string session_id = 1; + */ + sessionId: string; + + /** + * @generated from field: string name = 2; + */ + name: string; +}; + +/** + * Describes the message kagent.api.v1.SetSessionNameIfEmptyRequest. + * Use `create(SetSessionNameIfEmptyRequestSchema)` to create a new message. + */ +export const SetSessionNameIfEmptyRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_kagent_api_v1_sessions, 12); + +/** + * @generated from message kagent.api.v1.SetSessionNameIfEmptyResponse + */ +export type SetSessionNameIfEmptyResponse = Message<"kagent.api.v1.SetSessionNameIfEmptyResponse"> & { + /** + * @generated from field: kagent.api.v1.Session session = 1; + */ + session?: Session | undefined; + + /** + * True when this call set the name; false when the Session already had one. + * + * @generated from field: bool applied = 2; + */ + applied: boolean; +}; + +/** + * Describes the message kagent.api.v1.SetSessionNameIfEmptyResponse. + * Use `create(SetSessionNameIfEmptyResponseSchema)` to create a new message. + */ +export const SetSessionNameIfEmptyResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_kagent_api_v1_sessions, 13); + +/** + * @generated from message kagent.api.v1.SetSessionRecapRequest + */ +export type SetSessionRecapRequest = Message<"kagent.api.v1.SetSessionRecapRequest"> & { + /** + * @generated from field: string session_id = 1; + */ + sessionId: string; + + /** + * @generated from field: string text = 2; + */ + text: string; + + /** + * @generated from field: int32 turn_count = 3; + */ + turnCount: number; +}; + +/** + * Describes the message kagent.api.v1.SetSessionRecapRequest. + * Use `create(SetSessionRecapRequestSchema)` to create a new message. + */ +export const SetSessionRecapRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_kagent_api_v1_sessions, 14); + +/** + * @generated from message kagent.api.v1.SetSessionRecapResponse + */ +export type SetSessionRecapResponse = Message<"kagent.api.v1.SetSessionRecapResponse"> & { + /** + * @generated from field: kagent.api.v1.Session session = 1; + */ + session?: Session | undefined; +}; + +/** + * Describes the message kagent.api.v1.SetSessionRecapResponse. + * Use `create(SetSessionRecapResponseSchema)` to create a new message. + */ +export const SetSessionRecapResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_kagent_api_v1_sessions, 15); /** * @generated from message kagent.api.v1.RevertSessionRequest @@ -332,7 +460,7 @@ export type RevertSessionRequest = Message<"kagent.api.v1.RevertSessionRequest"> * Use `create(RevertSessionRequestSchema)` to create a new message. */ export const RevertSessionRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 11); + messageDesc(file_kagent_api_v1_sessions, 16); /** * @generated from message kagent.api.v1.RevertSessionResponse @@ -349,7 +477,7 @@ export type RevertSessionResponse = Message<"kagent.api.v1.RevertSessionResponse * Use `create(RevertSessionResponseSchema)` to create a new message. */ export const RevertSessionResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 12); + messageDesc(file_kagent_api_v1_sessions, 17); /** * @generated from message kagent.api.v1.DeleteSessionRequest @@ -374,7 +502,7 @@ export type DeleteSessionRequest = Message<"kagent.api.v1.DeleteSessionRequest"> * Use `create(DeleteSessionRequestSchema)` to create a new message. */ export const DeleteSessionRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 13); + messageDesc(file_kagent_api_v1_sessions, 18); /** * @generated from message kagent.api.v1.DeleteSessionResponse @@ -391,7 +519,7 @@ export type DeleteSessionResponse = Message<"kagent.api.v1.DeleteSessionResponse * Use `create(DeleteSessionResponseSchema)` to create a new message. */ export const DeleteSessionResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 14); + messageDesc(file_kagent_api_v1_sessions, 19); /** * Grants read-only access to a session, including A2A get, list, and subscribe. @@ -427,7 +555,7 @@ export type SessionShare = Message<"kagent.api.v1.SessionShare"> & { * Use `create(SessionShareSchema)` to create a new message. */ export const SessionShareSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 15); + messageDesc(file_kagent_api_v1_sessions, 20); /** * @generated from message kagent.api.v1.CreateSessionShareRequest @@ -453,7 +581,7 @@ export type CreateSessionShareRequest = Message<"kagent.api.v1.CreateSessionShar * Use `create(CreateSessionShareRequestSchema)` to create a new message. */ export const CreateSessionShareRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 16); + messageDesc(file_kagent_api_v1_sessions, 21); /** * @generated from message kagent.api.v1.CreateSessionShareResponse @@ -477,7 +605,7 @@ export type CreateSessionShareResponse = Message<"kagent.api.v1.CreateSessionSha * Use `create(CreateSessionShareResponseSchema)` to create a new message. */ export const CreateSessionShareResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 17); + messageDesc(file_kagent_api_v1_sessions, 22); /** * @generated from message kagent.api.v1.ListSessionSharesRequest @@ -499,7 +627,7 @@ export type ListSessionSharesRequest = Message<"kagent.api.v1.ListSessionSharesR * Use `create(ListSessionSharesRequestSchema)` to create a new message. */ export const ListSessionSharesRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 18); + messageDesc(file_kagent_api_v1_sessions, 23); /** * @generated from message kagent.api.v1.ListSessionSharesResponse @@ -521,7 +649,7 @@ export type ListSessionSharesResponse = Message<"kagent.api.v1.ListSessionShares * Use `create(ListSessionSharesResponseSchema)` to create a new message. */ export const ListSessionSharesResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 19); + messageDesc(file_kagent_api_v1_sessions, 24); /** * @generated from message kagent.api.v1.RevokeSessionShareRequest @@ -538,7 +666,7 @@ export type RevokeSessionShareRequest = Message<"kagent.api.v1.RevokeSessionShar * Use `create(RevokeSessionShareRequestSchema)` to create a new message. */ export const RevokeSessionShareRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 20); + messageDesc(file_kagent_api_v1_sessions, 25); /** * @generated from message kagent.api.v1.RevokeSessionShareResponse @@ -551,7 +679,7 @@ export type RevokeSessionShareResponse = Message<"kagent.api.v1.RevokeSessionSha * Use `create(RevokeSessionShareResponseSchema)` to create a new message. */ export const RevokeSessionShareResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_sessions, 21); + messageDesc(file_kagent_api_v1_sessions, 26); /** * Lifecycle mutations execute inline. After a retryable error, repeat the same @@ -595,6 +723,27 @@ export const SessionService: GenService<{ input: typeof UpdateSessionNameRequestSchema; output: typeof UpdateSessionNameResponseSchema; }, + /** + * Names a conversation only while it has no name, so a generated title never + * replaces one a person chose. + * + * @generated from rpc kagent.api.v1.SessionService.SetSessionNameIfEmpty + */ + setSessionNameIfEmpty: { + methodKind: "unary"; + input: typeof SetSessionNameIfEmptyRequestSchema; + output: typeof SetSessionNameIfEmptyResponseSchema; + }, + /** + * Stores the latest recap of the conversation, replacing any earlier one. + * + * @generated from rpc kagent.api.v1.SessionService.SetSessionRecap + */ + setSessionRecap: { + methodKind: "unary"; + input: typeof SetSessionRecapRequestSchema; + output: typeof SetSessionRecapResponseSchema; + }, /** * Restores a crashed runtime to its last snapshot without replaying its turn. * diff --git a/ui/src/mocks/fixtures.ts b/ui/src/mocks/fixtures.ts index 9e494dcda8..1a998c6ebf 100644 --- a/ui/src/mocks/fixtures.ts +++ b/ui/src/mocks/fixtures.ts @@ -592,6 +592,42 @@ export const mockAgentInstances: AgentInstance[] = [ createdAt: "2026-08-20T15:30:00Z", updatedAt: "2026-08-20T15:44:00Z", }, + { + // Three turns, and a recap that covers all three. + id: "c71a5d3e-92b4-4f08-a6e1-5d0b83f2c917", + name: "Recap, current", + creator: MOCK_INSTANCE_CREATOR, + agent: "kagent/triage-on-claude", + preparedRevision: "rev-2b91d0e", + a2aAuthority: undefined, + state: "ready", + operation: "unspecified", + recap: { + text: "Checkout was crashlooping on a failing liveness probe. Raised the probe delay and the pods settled.", + turnCount: 3, + generatedAt: "2026-08-21T10:30:00Z", + }, + createdAt: "2026-08-21T10:00:00Z", + updatedAt: "2026-08-21T10:30:00Z", + }, + { + // Four turns, and a recap that stops at the third. + id: "e03f8b64-17c9-4d52-b8a0-6a94d1e7f25b", + name: "Recap, stale", + creator: MOCK_INSTANCE_CREATOR, + agent: "kagent/triage-on-claude", + preparedRevision: "rev-2b91d0e", + a2aAuthority: undefined, + state: "ready", + operation: "unspecified", + recap: { + text: "Checkout was crashlooping on a failing liveness probe. Raised the probe delay and the pods settled.", + turnCount: 3, + generatedAt: "2026-08-21T10:30:00Z", + }, + createdAt: "2026-08-21T11:00:00Z", + updatedAt: "2026-08-21T11:40:00Z", + }, ]; /** diff --git a/ui/src/mocks/scenario.ts b/ui/src/mocks/scenario.ts index 14d82de7b1..d02107c71a 100644 --- a/ui/src/mocks/scenario.ts +++ b/ui/src/mocks/scenario.ts @@ -92,8 +92,11 @@ function isScenario(value: string | null): value is MockScenario { * a scenario of its own because the shape decides the controls: `asks` offers choices, * `asks-text` offers one prose field — the only shape where the field can take the * caret on arrival and Enter can mean "send". + * + * `names` is a turn that ends like `ok`, after the runtime has named the session from + * it, as the Go runtime does during the first turn. Only a session with no name gets one. */ -export const CHAT_SCENARIOS = ["ok", "error", "slow", "asks", "asks-text"] as const; +export const CHAT_SCENARIOS = ["ok", "error", "slow", "asks", "asks-text", "names"] as const; export type ChatScenario = (typeof CHAT_SCENARIOS)[number]; diff --git a/ui/src/mocks/transport.ts b/ui/src/mocks/transport.ts index 14a1cc3c91..a543bd5416 100644 --- a/ui/src/mocks/transport.ts +++ b/ui/src/mocks/transport.ts @@ -662,6 +662,13 @@ function agentInstanceMessage( failure: row.failure ? { reason: row.failure.reason ?? "", message: row.failure.message ?? "" } : undefined, + recap: row.recap + ? { + text: row.recap.text, + turnCount: row.recap.turnCount, + generatedAt: stamp(row.recap.generatedAt), + } + : undefined, createdAt: stamp(row.createdAt), updatedAt: stamp(row.updatedAt), }; diff --git a/ui/src/pages/AgentChatPage.tsx b/ui/src/pages/AgentChatPage.tsx index e2241495de..42b9b25b5b 100644 --- a/ui/src/pages/AgentChatPage.tsx +++ b/ui/src/pages/AgentChatPage.tsx @@ -25,6 +25,7 @@ import { } from "@/api"; import { autoTitleFrom } from "@/components/agent-instances/instanceLabels"; import { useLiveTranscript } from "@/api/hooks/useLiveTranscript"; +import { useRefreshConversationsOnTurnEnd } from "@/api/hooks/useRefreshConversationsOnTurnEnd"; import { useInvalidateConversations } from "@/api/hooks/useInvalidateConversations"; import { useCheckpoints } from "@/api/hooks/useCheckpoints"; import type { Checkpoint } from "@/api"; @@ -120,6 +121,9 @@ export function AgentChatPage() { isBusy: chat.phase === "streaming", }); + // Picks up the name the runtime wrote during the turn. + useRefreshConversationsOnTurnEnd(chat.phase); + /** * What to call this conversation when nobody has named it. * @@ -357,6 +361,7 @@ export function AgentChatPage() { /** The message box, so the caret can be handed back to it after a question. */ const composerRef = useRef(null); + const chatPaneRef = useRef(null); /* * Opening a conversation puts the caret in its box. @@ -548,6 +553,7 @@ export function AgentChatPage() { ) : null}
void invalidateConversations()} chat={chat} isLoading={instance.isLoading} sessionId={id} @@ -650,6 +658,8 @@ export function AgentChatPage() { }} checkpointsById={checkpointsById} checkpointByMessage={checkpointByMessage} + recap={instance.data?.recap} + recapTarget={chatPaneRef} // The question is answered in a field inside the transcript, and once it // has been, the next thing typed is an ordinary message. The transcript // has no business knowing the composer exists, so the page it belongs to