From 4ffe2a2a687b4873c5870d3a09c79cb97cde7647 Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Tue, 28 Jul 2026 18:53:24 -0500 Subject: [PATCH 1/4] chore: go mod tidy --- go.sum | 2 -- 1 file changed, 2 deletions(-) diff --git a/go.sum b/go.sum index 15bf108..7e7058b 100644 --- a/go.sum +++ b/go.sum @@ -139,8 +139,6 @@ github.com/mvm-sh/mvm v0.5.0 h1:XWII2Y8RLEzvFMuBWSacg6KLUTUp2weBin4lUlFrV+Y= github.com/mvm-sh/mvm v0.5.0/go.mod h1:2h9+ibS1DzdkMRNjGVs+pkU0blZZXDrEIUpcI93p4XA= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/odvcencio/gotreesitter v0.47.0 h1:M4n0d9JPqHUGeZubI8Rzd21cxKqCwA33ULSqoFFznGc= -github.com/odvcencio/gotreesitter v0.47.0/go.mod h1:hBVkghd0paaYAVwd2087vfwdeU984bQbMo9LvpE0moo= github.com/odvcencio/gotreesitter v0.47.1 h1:legFCs1A3HIpBNmaGW5oYj2QexAouxTshBlGifl9HSw= github.com/odvcencio/gotreesitter v0.47.1/go.mod h1:hBVkghd0paaYAVwd2087vfwdeU984bQbMo9LvpE0moo= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= From 2f5ebda6cc86ed2bbbf08a3001d03ae049868e41 Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Tue, 28 Jul 2026 19:39:05 -0500 Subject: [PATCH 2/4] fix(terminal): allow agent inspection during active prompts --- internal/terminal/agent_tasks.go | 142 +++++-- .../agent_tasks_behavior_internal_test.go | 387 +++++++++++++++++- internal/terminal/app.go | 2 + internal/terminal/async_events.go | 20 +- internal/terminal/input.go | 131 +++++- internal/terminal/input_escape.go | 4 +- internal/terminal/message_layout.go | 2 +- internal/terminal/session_view.go | 152 +++++++ .../terminal/session_view_internal_test.go | 56 +++ 9 files changed, 843 insertions(+), 53 deletions(-) create mode 100644 internal/terminal/session_view.go create mode 100644 internal/terminal/session_view_internal_test.go diff --git a/internal/terminal/agent_tasks.go b/internal/terminal/agent_tasks.go index 64e76c8..29cdd0f 100644 --- a/internal/terminal/agent_tasks.go +++ b/internal/terminal/agent_tasks.go @@ -669,6 +669,13 @@ func (app *App) postAgentTaskStreamEvent(ctx context.Context, event *database.Ta } func (app *App) handleAgentTaskTerminalEvent(ctx context.Context, taskID string) { + if app.inspectingWhilePromptRuns() && app.runtime != nil { + task, found, err := app.runtime.AgentTask(ctx, taskID) + if err == nil && found { + app.deliverAgentTaskCompletion(ctx, task) + } + } + if len(app.agentTaskSessionStack) == 0 { app.refreshVisibleAgentTasks(ctx) @@ -951,7 +958,27 @@ func (app *App) deliverAgentTaskCompletion(ctx context.Context, task *database.A return } - app.deliverAgentTaskCompletionText(ctx, task.Task.ID, completion) + app.withSessionView(task.Task.OwnerSessionID, func() { + app.deliverAgentTaskCompletionText(ctx, task.Task.ID, completion) + }) +} + +func (app *App) deliverAgentTaskCompletionEvent(ctx context.Context, taskID, completion string) { + ownerSessionID := app.sessionID + if app.runtime != nil { + task, found, err := app.runtime.AgentTask(ctx, taskID) + if err != nil || !found || task.Task.OwnerSessionID == "" { + app.setStatus("agent result owner could not be resolved") + + return + } + + ownerSessionID = task.Task.OwnerSessionID + } + + app.withSessionView(ownerSessionID, func() { + app.deliverAgentTaskCompletionText(ctx, taskID, completion) + }) } func (app *App) deliverAgentTaskCompletionText(ctx context.Context, taskID, completion string) { @@ -1454,12 +1481,8 @@ func taskMeta(task *database.TaskEntity, now time.Time) string { } func (app *App) inspectAgentTask(ctx context.Context, taskID string) error { - if app.busy() || app.activePrompt != nil { - return errors.New("cannot inspect an agent task while a prompt is active") - } - - if app.runtime == nil { - return terminalError(errors.New("runtime is not configured"), agentTaskLoadOperation) + if err := app.validateAgentTaskInspection(); err != nil { + return err } task, found, err := app.runtime.AgentTask(ctx, taskID) @@ -1480,22 +1503,89 @@ func (app *App) inspectAgentTask(ctx context.Context, taskID string) error { ) } - settings, settingsFound, err := app.sessionSettings(ctx, task.ChildSessionID) + if app.activePrompt != nil && len(app.agentTaskSessionStack) > 0 && + app.activePrompt.SessionID == app.sessionID { + return errors.New("cannot leave an inspected agent session while its prompt is active") + } + + if err := app.switchToAgentTaskSession( + ctx, + task.ChildSessionID, + nextSessionStack, + !isTerminalAgentTaskState(task.Task.State), + ); err != nil { + return err + } + + app.watchInspectedTaskIfRunning(ctx, task) + + app.closePanel() + app.addSystemMessage("inspecting agent task: " + taskID + "; use /agents back to return") + + return nil +} + +func (app *App) validateAgentTaskInspection() error { + if app.authWorking || app.compacting || (app.working && app.activePrompt == nil) { + return errors.New("cannot inspect an agent task while another operation is active") + } + + if app.runtime == nil { + return terminalError(errors.New("runtime is not configured"), agentTaskLoadOperation) + } + + return nil +} + +func (app *App) switchToAgentTaskSession( + ctx context.Context, + sessionID string, + sessionStack []string, + preserveTransientState bool, +) error { + settings, settingsFound, err := app.sessionSettings(ctx, sessionID) if err != nil { return terminalError(err, "load agent session") } - messages, err := app.sessionMessages(ctx, task.ChildSessionID) + messages, err := app.sessionMessages(ctx, sessionID) if err != nil { return terminalError(err, "load agent session") } app.stopAgentTaskWatches() - app.agentTaskSessionStack = nextSessionStack - app.sessionID = task.ChildSessionID - app.pendingParentID = nil - app.resetMessages() - app.resetStreamingBlocks() + app.saveSessionView() + app.agentTaskSessionStack = sessionStack + + if app.restoreSessionView(sessionID) { + promptHistory := app.promptHistory + promptHistoryDraft := app.promptHistoryDraft + promptHistoryIndex := app.promptHistoryIndex + app.transcript.History = nil + app.transcript.LineCache.reset() + app.appendSessionMessages(messages) + app.promptHistory = promptHistory + app.promptHistoryDraft = promptHistoryDraft + app.promptHistoryIndex = promptHistoryIndex + messages = nil + + if !preserveTransientState { + app.resetStreamingBlocks() + app.streamingText = "" + app.streamingThinkingText = "" + app.streamedToolEvents = 0 + } + } else { + app.sessionID = sessionID + app.pendingParentID = nil + app.resetMessages() + app.resetStreamingBlocks() + app.liveAgentCompletions = nil + app.queuedMessages = nil + app.hiddenQueuedMessages = nil + app.composerBuffer = tui.NewTextArea() + app.statusMessage = "" + } if settingsFound { app.applySessionSettings(&settings) @@ -1503,11 +1593,6 @@ func (app *App) inspectAgentTask(ctx context.Context, taskID string) error { app.appendSessionMessages(messages) - app.watchInspectedTaskIfRunning(ctx, task) - - app.closePanel() - app.addSystemMessage("inspecting agent task: " + taskID + "; use /agents back to return") - return nil } @@ -1562,17 +1647,22 @@ func (app *App) leaveAgentTaskSession(ctx context.Context) error { } app.stopAgentTaskWatches() - app.sessionID = parentSessionID + app.saveSessionView() app.agentTaskSessionStack = app.agentTaskSessionStack[:last] - app.pendingParentID = nil - app.resetMessages() - app.resetStreamingBlocks() - if settingsFound { - app.applySessionSettings(&settings) + if !app.restoreSessionView(parentSessionID) { + app.sessionID = parentSessionID + app.pendingParentID = nil + app.resetMessages() + app.resetStreamingBlocks() + + if settingsFound { + app.applySessionSettings(&settings) + } + + app.appendSessionMessages(messages) } - app.appendSessionMessages(messages) app.addSystemMessage("returned to parent session") if len(app.agentTaskSessionStack) == 0 { diff --git a/internal/terminal/agent_tasks_behavior_internal_test.go b/internal/terminal/agent_tasks_behavior_internal_test.go index 277f82a..5f21915 100644 --- a/internal/terminal/agent_tasks_behavior_internal_test.go +++ b/internal/terminal/agent_tasks_behavior_internal_test.go @@ -654,9 +654,11 @@ func TestInspectAndLeaveAgentTaskSession(t *testing.T) { app.transcript.Streaming.Blocks = []chatMessage{newChatMessage(transcript.RoleAssistant, "child stream")} app.runningToolBlocks = []runningToolBlock{testRunningToolBlock(testToolRead, "")} require.NoError(t, app.leaveAgentTaskSession(t.Context())) - assert.Empty(t, app.transcript.Streaming.Blocks) - assert.Empty(t, app.runningToolBlocks) - assert.Zero(t, app.scrollOffset) + require.Len(t, app.transcript.Streaming.Blocks, 1) + assert.Equal(t, "stale stream", app.transcript.Streaming.Blocks[0].Content) + require.Len(t, app.runningToolBlocks, 1) + assert.Equal(t, testToolRead, app.runningToolBlocks[0].Call.Name) + assert.Equal(t, 12, app.scrollOffset) assert.Equal(t, parent.ID, app.sessionID) assert.Empty(t, app.agentTaskSessionStack) assert.Contains(t, app.transcript.History[len(app.transcript.History)-1].Content, "returned to parent") @@ -668,6 +670,76 @@ func TestInspectAndLeaveAgentTaskSession(t *testing.T) { assert.Contains(t, err.Error(), "outside the current inspection path") } +func TestRevisitAgentTaskSessionRefreshesDurableTranscript(t *testing.T) { + t.Parallel() + + fixture := newAgentTaskSessionPair(t) + task := behaviorAgentTask(behaviorTaskID, database.TaskSucceeded) + task.Task.OwnerSessionID = fixture.parent.ID + task.ChildSessionID = fixture.child.ID + stub := newAgentTaskControllerStub(map[string]*database.AgentTaskEntity{task.Task.ID: &task}, nil) + + app := newRenderTestApp(t) + app.runtime = assistant.NewRuntimeForTest(func(options *assistant.RuntimeTestOptions) { + options.Sessions = fixture.sessions + }) + app.runtime.SetAgentTaskController(stub) + app.sessionID = fixture.parent.ID + + require.NoError(t, app.inspectAgentTask(t.Context(), behaviorTaskID)) + app.transcript.Streaming.Blocks = []chatMessage{ + newChatMessage(transcript.RoleAssistant, "retained transient stream"), + } + app.runningToolBlocks = []runningToolBlock{testRunningToolBlock(testToolRead, "")} + require.NoError(t, app.leaveAgentTaskSession(t.Context())) + + _, err := fixture.sessions.AppendMessage(t.Context(), fixture.child.ID, nil, &database.MessageEntity{ + Timestamp: time.Now().UTC(), + Role: database.RoleAssistant, + Content: "new durable child message", + Provider: "", + Model: "", + }) + require.NoError(t, err) + + require.NoError(t, app.inspectAgentTask(t.Context(), behaviorTaskID)) + assert.Empty(t, app.transcript.Streaming.Blocks) + assert.Empty(t, app.runningToolBlocks) + assert.Contains(t, app.transcript.History[0].Content, "new durable child message") +} + +func TestRevisitRunningAgentTaskPreservesTransientState(t *testing.T) { + t.Parallel() + + fixture := newAgentTaskSessionPair(t) + task := behaviorAgentTask(behaviorTaskID, database.TaskRunning) + task.Task.OwnerSessionID = fixture.parent.ID + task.ChildSessionID = fixture.child.ID + stub := newAgentTaskControllerStub(map[string]*database.AgentTaskEntity{task.Task.ID: &task}, nil) + + app := newRenderTestApp(t) + app.runtime = assistant.NewRuntimeForTest(func(options *assistant.RuntimeTestOptions) { + options.Sessions = fixture.sessions + }) + app.runtime.SetAgentTaskController(stub) + app.sessionID = fixture.parent.ID + + require.NoError(t, app.inspectAgentTask(t.Context(), behaviorTaskID)) + defer app.stopAgentTaskWatches() + + app.transcript.Streaming.Blocks = []chatMessage{ + newChatMessage(transcript.RoleAssistant, "retained transient stream"), + } + app.runningToolBlocks = []runningToolBlock{testRunningToolBlock(testToolRead, "")} + require.NoError(t, app.leaveAgentTaskSession(t.Context())) + require.NoError(t, app.inspectAgentTask(t.Context(), behaviorTaskID)) + + require.Len(t, app.transcript.Streaming.Blocks, 1) + assert.Equal(t, "retained transient stream", app.transcript.Streaming.Blocks[0].Content) + require.Len(t, app.runningToolBlocks, 1) + assert.Equal(t, testToolRead, app.runningToolBlocks[0].Call.Name) +} + func TestInspectAgentTaskSwitchesBetweenSiblingSessions(t *testing.T) { t.Parallel() @@ -824,21 +896,316 @@ func TestAltEscapeLeavesAgentTaskSession(t *testing.T) { assert.Empty(t, app.agentTaskSessionStack) } -func TestInspectAgentTaskRejectsActivePrompt(t *testing.T) { +func TestInspectAgentTaskWhileParentPromptIsActive(t *testing.T) { t.Parallel() + fixture := newAgentTaskSessionPair(t) + task := behaviorAgentTask(behaviorTaskID, database.TaskRunning) + task.Task.OwnerSessionID = fixture.parent.ID + task.ChildSessionID = fixture.child.ID + stub := newAgentTaskControllerStub(map[string]*database.AgentTaskEntity{task.Task.ID: &task}, nil) + app := newRenderTestApp(t) - app.sessionID = sessionCommandsParentID + app.runtime = assistant.NewRuntimeForTest(func(options *assistant.RuntimeTestOptions) { + options.Sessions = fixture.sessions + }) + app.runtime.SetAgentTaskController(stub) + app.sessionID = fixture.parent.ID app.activePrompt = newTestActivePrompt(nil) - app.agentTasks = []database.AgentTaskEntity{behaviorAgentTask(behaviorTaskID, database.TaskRunning)} + app.activePrompt.SessionID = fixture.parent.ID + app.working = true + app.composerBuffer.SetText("parent draft") + app.addSystemMessage("parent partial response") - err := app.inspectAgentTask(t.Context(), behaviorTaskID) + require.NoError(t, app.inspectAgentTask(t.Context(), behaviorTaskID)) + defer app.stopAgentTaskWatches() + + assert.Equal(t, fixture.child.ID, app.sessionID) + assert.Equal(t, fixture.parent.ID, app.activePrompt.SessionID) + assert.Equal(t, []string{fixture.parent.ID}, app.agentTaskSessionStack) + assert.True(t, app.inspectingWhilePromptRuns()) + assert.Empty(t, app.composerBuffer.TextValue()) + + promptID := app.activePrompt.ID + app.handlePromptAsyncEvent(t.Context(), asyncTestEvent( + asyncEventPromptDelta, + "parent streamed response", + "", + promptID, + )) + assert.Empty(t, app.transcript.Streaming.Blocks) + + app.applyInspectedAgentTaskEvent(t.Context(), behaviorTaskID, taskStreamPayload(t, assistant.StreamEvent{ + ToolCallEvent: nil, ToolEvent: nil, Usage: nil, + Kind: assistant.StreamEventTextDelta, Text: "child streamed response", + })) + require.Len(t, app.transcript.Streaming.Blocks, 1) + assert.Equal(t, "child streamed response", app.transcript.Streaming.Blocks[0].Content) + + response := newTestPromptResponse("parent completed response") + response.SessionID = fixture.parent.ID + app.handlePromptAsyncEvent(t.Context(), &asyncEvent{ + Response: response, ToolCallEvent: nil, ToolEvent: nil, Usage: nil, + Kind: asyncEventPromptDone, Provider: "", Text: "", PromptID: promptID, + }) + assert.Equal(t, fixture.child.ID, app.sessionID) + assert.Nil(t, app.activePrompt) + + require.NoError(t, app.leaveAgentTaskSession(t.Context())) + assert.Equal(t, fixture.parent.ID, app.sessionID) + assert.Equal(t, "parent draft", app.composerBuffer.TextValue()) + require.GreaterOrEqual(t, len(app.transcript.History), 3) + assert.Equal(t, "parent partial response", app.transcript.History[0].Content) + assert.Equal(t, "parent completed response", app.transcript.History[len(app.transcript.History)-2].Content) +} + +func TestAgentTaskCompletionRoutesToParentWhileChildIsInspected(t *testing.T) { + t.Parallel() + + fixture := newAgentTaskSessionPair(t) + task := behaviorAgentTask(behaviorTaskID, database.TaskSucceeded) + task.Task.OwnerSessionID = fixture.parent.ID + task.ChildSessionID = fixture.child.ID + task.Task.Result = "parent completion" + stub := newAgentTaskControllerStub(map[string]*database.AgentTaskEntity{task.Task.ID: &task}, nil) + + app := newRenderTestApp(t) + app.runtime = assistant.NewRuntimeForTest(func(options *assistant.RuntimeTestOptions) { + options.Sessions = fixture.sessions + }) + app.runtime.SetAgentTaskController(stub) + app.sessionID = fixture.parent.ID + app.activePrompt = newTestActivePrompt(nil) + app.activePrompt.SessionID = fixture.parent.ID + app.working = true + + require.NoError(t, app.inspectAgentTask(t.Context(), behaviorTaskID)) + defer app.stopAgentTaskWatches() + + app.handlePromptAsyncEvent(t.Context(), &asyncEvent{ + Response: nil, ToolCallEvent: nil, ToolEvent: nil, Usage: nil, + Kind: asyncEventAgentTaskChanged, Provider: "", Text: behaviorTaskID, PromptID: 0, + }) + + assert.Equal(t, fixture.child.ID, app.sessionID) + assert.Empty(t, app.liveAgentCompletions) + assert.Empty(t, app.hiddenQueuedMessages) + + childMessages, err := app.sessionMessages(t.Context(), fixture.child.ID) + require.NoError(t, err) + assert.Empty(t, childMessages) + + parentMessages, err := app.sessionMessages(t.Context(), fixture.parent.ID) + require.NoError(t, err) + require.Len(t, parentMessages, 1) + assert.Contains(t, parentMessages[0].Content, "parent completion") + + require.NoError(t, app.leaveAgentTaskSession(t.Context())) + require.Len(t, app.liveAgentCompletions, 1) + assert.Contains(t, app.liveAgentCompletions[0].Content, "parent completion") + require.Len(t, app.hiddenQueuedMessages, 1) + assert.Contains(t, app.hiddenQueuedMessages[0], "parent completion") +} + +func TestActivePromptInspectionAllowsNestedTaskSelection(t *testing.T) { + t.Parallel() + + fixture := newAgentTaskSessionPair(t) + parentTask := behaviorAgentTask(behaviorTaskID, database.TaskRunning) + parentTask.Task.OwnerSessionID = fixture.parent.ID + parentTask.ChildSessionID = fixture.child.ID + nestedTask := behaviorAgentTask("nested-task", database.TaskRunning) + nestedTask.Task.OwnerSessionID = fixture.child.ID + nestedTask.ChildSessionID = "nested-session" + stub := newAgentTaskControllerStub(map[string]*database.AgentTaskEntity{ + behaviorTaskID: &parentTask, + "nested-task": &nestedTask, + }, nil) + + app := newRenderTestApp(t) + app.runtime = assistant.NewRuntimeForTest(func(options *assistant.RuntimeTestOptions) { + options.Sessions = fixture.sessions + }) + app.runtime.SetAgentTaskController(stub) + app.sessionID = fixture.parent.ID + app.activePrompt = newTestActivePrompt(nil) + app.activePrompt.SessionID = fixture.parent.ID + app.working = true + + require.NoError(t, app.inspectAgentTask(t.Context(), behaviorTaskID)) + defer app.stopAgentTaskWatches() + + app.agentTasks = []database.AgentTaskEntity{nestedTask} + app.agentTaskSummarySelection = agentTaskSummarySelection{ItemIndex: 0, Active: true} + + result := app.handleReadOnlyInspectionPriorityKey( + t.Context(), + tcell.NewEventKey(tcell.KeyEnter, "", tcell.ModNone), + ) + + assert.True(t, result.handled) + require.NoError(t, result.err) + assert.Equal(t, "nested-session", app.sessionID) + assert.Equal(t, []string{fixture.parent.ID, fixture.child.ID}, app.agentTaskSessionStack) +} + +func TestDoubleEscapeCancelsParentPromptWithoutLeavingInspection(t *testing.T) { + t.Parallel() + + fixture := newAgentTaskSessionPair(t) + task := behaviorAgentTask(behaviorTaskID, database.TaskRunning) + task.Task.OwnerSessionID = fixture.parent.ID + task.ChildSessionID = fixture.child.ID + stub := newAgentTaskControllerStub(map[string]*database.AgentTaskEntity{task.Task.ID: &task}, nil) + + app := newRenderTestApp(t) + app.runtime = assistant.NewRuntimeForTest(func(options *assistant.RuntimeTestOptions) { + options.Sessions = fixture.sessions + }) + app.runtime.SetAgentTaskController(stub) + app.sessionID = fixture.parent.ID + canceled := false + app.activePrompt = newTestActivePrompt(func() { canceled = true }) + app.activePrompt.SessionID = fixture.parent.ID + app.working = true + app.composerBuffer.SetText("parent draft") + + require.NoError(t, app.inspectAgentTask(t.Context(), behaviorTaskID)) + defer app.stopAgentTaskWatches() + + pressTerminalKey(t, app, tcell.KeyEscape, "") + assert.False(t, canceled) + assert.Contains(t, app.statusMessage, "escape again to interrupt") + pressTerminalKey(t, app, tcell.KeyEscape, "") + + assert.True(t, canceled) + assert.Equal(t, fixture.child.ID, app.sessionID) + assert.Equal(t, []string{fixture.parent.ID}, app.agentTaskSessionStack) + require.NotNil(t, app.activePrompt) + assert.True(t, app.activePrompt.Canceled) + assert.Empty(t, app.composerBuffer.TextValue()) + + require.NoError(t, app.leaveAgentTaskSession(t.Context())) + assert.Equal(t, "parent draft", app.composerBuffer.TextValue()) +} + +func TestInspectionPanelEscapeClosesPanelBeforeLeavingSession(t *testing.T) { + t.Parallel() + + app := newRenderTestApp(t) + app.sessionID = "child" + app.agentTaskSessionStack = []string{sessionCommandsParentID} + app.openPanel(panel.New(panelHotkeys, "Hotkeys", "", nil, true)) + + pressTerminalKey(t, app, tcell.KeyEscape, "") + + assert.Equal(t, modeChat, app.mode) + assert.Nil(t, app.panel) + assert.Equal(t, "child", app.sessionID) + assert.Equal(t, []string{sessionCommandsParentID}, app.agentTaskSessionStack) +} + +func TestAltEscapeLeavesInspectionWithoutCancelingParentPrompt(t *testing.T) { + t.Parallel() + + fixture := newAgentTaskSessionPair(t) + task := behaviorAgentTask(behaviorTaskID, database.TaskRunning) + task.Task.OwnerSessionID = fixture.parent.ID + task.ChildSessionID = fixture.child.ID + stub := newAgentTaskControllerStub(map[string]*database.AgentTaskEntity{task.Task.ID: &task}, nil) + app := newRenderTestApp(t) + app.runtime = assistant.NewRuntimeForTest(func(options *assistant.RuntimeTestOptions) { + options.Sessions = fixture.sessions + }) + app.runtime.SetAgentTaskController(stub) + app.sessionID = fixture.parent.ID + canceled := false + app.activePrompt = newTestActivePrompt(func() { canceled = true }) + app.activePrompt.SessionID = fixture.parent.ID + app.working = true + + require.NoError(t, app.inspectAgentTask(t.Context(), behaviorTaskID)) + defer app.stopAgentTaskWatches() + + shouldQuit, err := app.handleKey( + t.Context(), + tcell.NewEventKey(tcell.KeyEscape, "", tcell.ModAlt), + ) + require.NoError(t, err) + assert.False(t, shouldQuit) + assert.False(t, canceled) + assert.Equal(t, fixture.parent.ID, app.sessionID) + require.NotNil(t, app.activePrompt) + assert.Equal(t, fixture.parent.ID, app.activePrompt.SessionID) + assert.True(t, app.working) +} + +func TestActivePromptInspectionBlocksGlobalAndExtensionShortcuts(t *testing.T) { + t.Parallel() + + fixture := newAgentTaskSessionPair(t) + task := behaviorAgentTask(behaviorTaskID, database.TaskRunning) + task.Task.OwnerSessionID = fixture.parent.ID + task.ChildSessionID = fixture.child.ID + stub := newAgentTaskControllerStub(map[string]*database.AgentTaskEntity{task.Task.ID: &task}, nil) + + app := newRenderTestApp(t) + app.runtime = assistant.NewRuntimeForTest(func(options *assistant.RuntimeTestOptions) { + options.Sessions = fixture.sessions + }) + app.runtime.SetAgentTaskController(stub) + app.sessionID = fixture.parent.ID + app.activePrompt = newTestActivePrompt(nil) + app.activePrompt.SessionID = fixture.parent.ID + app.working = true + + require.NoError(t, app.inspectAgentTask(t.Context(), behaviorTaskID)) + defer app.stopAgentTaskWatches() + + thinkingLevel := app.currentThinkingLevel() + toolsExpanded := app.toolsExpanded + + for _, event := range []*tcell.EventKey{ + tcell.NewEventKey(tcell.KeyBacktab, "", tcell.ModShift), + tcell.NewEventKey(tcell.KeyRune, "o", tcell.ModCtrl), + tcell.NewEventKey(tcell.KeyF1, "", tcell.ModNone), + } { + shouldQuit, err := app.handleKey(t.Context(), event) + require.NoError(t, err) + assert.False(t, shouldQuit) + } + + assert.Equal(t, thinkingLevel, app.currentThinkingLevel()) + assert.Equal(t, toolsExpanded, app.toolsExpanded) + assert.Equal(t, "agent task inspection is read-only while the parent response runs", app.statusMessage) +} + +func TestInspectAgentTaskRejectsLeavingPromptOwningInspectedSession(t *testing.T) { + t.Parallel() + + fixture := newAgentTaskSessionPair(t) + task := behaviorAgentTask(behaviorTaskID, database.TaskRunning) + task.Task.OwnerSessionID = fixture.parent.ID + task.ChildSessionID = fixture.child.ID + stub := newAgentTaskControllerStub(map[string]*database.AgentTaskEntity{task.Task.ID: &task}, nil) + + app := newRenderTestApp(t) + app.runtime = assistant.NewRuntimeForTest(func(options *assistant.RuntimeTestOptions) { + options.Sessions = fixture.sessions + }) + app.runtime.SetAgentTaskController(stub) + app.sessionID = fixture.child.ID + app.agentTaskSessionStack = []string{fixture.parent.ID} + app.activePrompt = newTestActivePrompt(nil) + app.activePrompt.SessionID = fixture.child.ID + app.working = true + + err := app.inspectAgentTask(t.Context(), behaviorTaskID) require.Error(t, err) assert.Contains(t, err.Error(), "prompt is active") - assert.Equal(t, sessionCommandsParentID, app.sessionID) - assert.Empty(t, app.agentTaskSessionStack) - assert.Len(t, app.agentTasks, 1) + assert.Equal(t, fixture.child.ID, app.sessionID) + assert.Equal(t, []string{fixture.parent.ID}, app.agentTaskSessionStack) } func TestInspectedAgentTaskCompletionUpdatesRetainedSummary(t *testing.T) { diff --git a/internal/terminal/app.go b/internal/terminal/app.go index f5a28cf..515c519 100644 --- a/internal/terminal/app.go +++ b/internal/terminal/app.go @@ -159,6 +159,7 @@ type App struct { theme terminalTheme selectedPanelKind panel.Kind sessionID string + sessionViews map[string]sessionViewState agentTaskSessionStack []string agentTaskSummaryOwnerID string statusMessage string @@ -275,6 +276,7 @@ func newApp(screen terminalScreen, options *RunOptions) *App { panel: nil, cwd: options.CWD, sessionID: options.SessionID, + sessionViews: map[string]sessionViewState{}, agentTaskSessionStack: []string{}, pendingParentID: nil, activePrompt: nil, diff --git a/internal/terminal/async_events.go b/internal/terminal/async_events.go index d8d590b..65a3e82 100644 --- a/internal/terminal/async_events.go +++ b/internal/terminal/async_events.go @@ -233,7 +233,7 @@ func (app *App) handlePromptAsyncEvent(ctx context.Context, payload *asyncEvent) return case asyncEventAgentTaskCompleted: - app.deliverAgentTaskCompletionText(ctx, payload.Provider, payload.Text) + app.deliverAgentTaskCompletionEvent(ctx, payload.Provider, payload.Text) return case asyncEventAuthURL, @@ -259,11 +259,18 @@ func (app *App) handlePromptAsyncEvent(ctx context.Context, payload *asyncEvent) return } - if app.handlePromptLifecycleEvent(ctx, payload) { - return + ownerSessionID := "" + if app.activePrompt != nil { + ownerSessionID = app.activePrompt.SessionID } - app.handlePromptStreamEvent(ctx, payload) + app.withSessionView(ownerSessionID, func() { + if app.handlePromptLifecycleEvent(ctx, payload) { + return + } + + app.handlePromptStreamEvent(ctx, payload) + }) } func (app *App) ignorePromptEvent(payload *asyncEvent) bool { @@ -576,8 +583,13 @@ func (app *App) applyPromptUserEntry(_ context.Context, sessionID, entryID strin return } + previousSessionID := app.activePrompt.SessionID app.activePrompt.SessionID = sessionID app.activePrompt.UserEntryID = entryID + + if app.sessionID == previousSessionID { + app.sessionID = sessionID + } } func (app *App) applyPromptError(ctx context.Context, message string, promptID uint64) { diff --git a/internal/terminal/input.go b/internal/terminal/input.go index f340d07..3b556fa 100644 --- a/internal/terminal/input.go +++ b/internal/terminal/input.go @@ -2,6 +2,7 @@ package terminal import ( "context" + "time" "github.com/gdamore/tcell/v3" @@ -48,41 +49,103 @@ type keyHandlingResult struct { } func (app *App) handlePriorityKey(ctx context.Context, event *tcell.EventKey) keyHandlingResult { - if app.handleWorkingInterruptKey(ctx, event) { + if result := app.handleInspectionAndModalPriorityKey(ctx, event); result.handled || result.err != nil { + return result + } + + if app.handleAutocompletePriorityKey(event) { return keyHandlingResult{err: nil, shouldQuit: false, handled: true} } - if handled, shouldQuit := app.handleForceExitKey(event); handled { - return keyHandlingResult{err: nil, shouldQuit: shouldQuit, handled: true} + if result := app.handleInlineListsAndExtensionKey(ctx, event); result.handled || result.err != nil { + return result } - if app.handleAgentTaskSessionEscape(ctx, event) { + if app.handlePreEditorKey(ctx, event) { return keyHandlingResult{err: nil, shouldQuit: false, handled: true} } - if result := app.handlePanelPriorityKey(ctx, event); result.handled || result.err != nil { + return keyHandlingResult{err: nil, shouldQuit: false, handled: false} +} + +func (app *App) handleInspectionAndModalPriorityKey( + ctx context.Context, + event *tcell.EventKey, +) keyHandlingResult { + if result := app.handleInterruptPriorityKey(ctx, event); result.handled { + return result + } + + if result := app.handleModalPriorityKey(ctx, event); result.handled || result.err != nil { return result } - if app.handleAutocompletePriorityKey(event) { + if app.handleInspectionAutocompleteEscape(event) || app.handleAgentTaskSessionEscape(ctx, event) { return keyHandlingResult{err: nil, shouldQuit: false, handled: true} } - if result := app.handleInlineListsAndExtensionKey(ctx, event); result.handled || result.err != nil { - return result + if app.inspectingWhilePromptRuns() { + return app.handleReadOnlyInspectionPriorityKey(ctx, event) } - if app.handlePreEditorKey(ctx, event) { + return keyHandlingResult{err: nil, shouldQuit: false, handled: false} +} + +func (app *App) handleModalPriorityKey(ctx context.Context, event *tcell.EventKey) keyHandlingResult { + if app.mode != modePanel || app.panel == nil { + return keyHandlingResult{err: nil, shouldQuit: false, handled: false} + } + + if isEscapeKey(event) || !app.inspectingWhilePromptRuns() { + return app.handlePanelPriorityKey(ctx, event) + } + + app.setStatus("agent task inspection is read-only while the parent response runs") + + return keyHandlingResult{err: nil, shouldQuit: false, handled: true} +} + +func (app *App) handleInterruptPriorityKey(ctx context.Context, event *tcell.EventKey) keyHandlingResult { + if app.handleWorkingInterruptKey(ctx, event) { return keyHandlingResult{err: nil, shouldQuit: false, handled: true} } + if handled, shouldQuit := app.handleForceExitKey(event); handled { + return keyHandlingResult{err: nil, shouldQuit: shouldQuit, handled: true} + } + return keyHandlingResult{err: nil, shouldQuit: false, handled: false} } +func (app *App) handleReadOnlyInspectionPriorityKey( + ctx context.Context, + event *tcell.EventKey, +) keyHandlingResult { + if !app.inspectingWhilePromptRuns() { + return keyHandlingResult{err: nil, shouldQuit: false, handled: false} + } + + if handled, err := app.handleAgentTaskSummaryPriorityKey(ctx, event); handled || err != nil { + return keyHandlingResult{err: err, shouldQuit: false, handled: true} + } + + if app.agentTaskSummaryFocused() || app.handleTranscriptScroll(event) { + return keyHandlingResult{err: nil, shouldQuit: false, handled: true} + } + + app.setStatus("agent task inspection is read-only while the parent response runs") + + return keyHandlingResult{err: nil, shouldQuit: false, handled: true} +} + func (app *App) handleInlineListsAndExtensionKey( ctx context.Context, event *tcell.EventKey, ) keyHandlingResult { + if app.inspectingWhilePromptRuns() { + return app.handleReadOnlyInspectionPriorityKey(ctx, event) + } + if handled, err := app.handleAgentTaskSummaryPriorityKey(ctx, event); handled || err != nil { return keyHandlingResult{err: err, shouldQuit: false, handled: true} } @@ -115,7 +178,39 @@ func (app *App) handleAgentTaskSessionEscape(ctx context.Context, event *tcell.E return false } - app.handleEscapePresses(ctx, escapePressCount(event)) + if event.Modifiers()&tcell.ModAlt != 0 { + app.lastEscape = time.Time{} + if err := app.leaveAgentTaskSession(ctx); err != nil { + app.setStatus(err.Error()) + } + + return true + } + + if time.Since(app.lastEscape) > doubleEscapeDelay { + app.lastEscape = time.Now() + if app.inspectingWhilePromptRuns() { + app.setStatus("escape again to interrupt; Alt+Escape returns to parent session") + } else { + app.setStatus("escape again to return to parent session") + } + + return true + } + + app.lastEscape = time.Time{} + if app.inspectingWhilePromptRuns() { + ownerSessionID := app.activePrompt.SessionID + app.withSessionView(ownerSessionID, func() { + app.cancelActivePrompt(ctx) + }) + + return true + } + + if err := app.leaveAgentTaskSession(ctx); err != nil { + app.setStatus(err.Error()) + } return true } @@ -128,6 +223,16 @@ func (app *App) handlePanelPriorityKey(ctx context.Context, event *tcell.EventKe return keyHandlingResult{err: app.handlePanelKey(ctx, event), shouldQuit: false, handled: true} } +func (app *App) handleInspectionAutocompleteEscape(event *tcell.EventKey) bool { + if !app.inspectingWhilePromptRuns() || !app.autocompleteActive() || !isEscapeKey(event) { + return false + } + + app.closeAutocomplete() + + return true +} + func (app *App) handleAutocompletePriorityKey(event *tcell.EventKey) bool { return app.handleAutocompleteEscape(event) || app.handleFocusedAutocompleteKey(event) } @@ -151,6 +256,12 @@ func (app *App) handleFocusedAutocompleteKey(event *tcell.EventKey) bool { } func (app *App) handleInputKey(ctx context.Context, event *tcell.EventKey) (bool, error) { + if app.inspectingWhilePromptRuns() { + app.setStatus("agent task inspection is read-only while the parent response runs") + + return false, nil + } + if app.keys.matches(event, actionInputClear) && !app.composerBuffer.Empty() { app.composerBuffer.Clear() app.resetPromptHistoryNavigation() diff --git a/internal/terminal/input_escape.go b/internal/terminal/input_escape.go index d0759f8..c6e0606 100644 --- a/internal/terminal/input_escape.go +++ b/internal/terminal/input_escape.go @@ -23,7 +23,7 @@ func (app *App) handleEscape(ctx context.Context) { } func (app *App) handleEscapePresses(ctx context.Context, presses int) { - if app.working || app.compacting { + if (app.working || app.compacting) && !app.inspectingWhilePromptRuns() { app.handleWorkingEscape(ctx, presses) return @@ -64,7 +64,7 @@ func (app *App) handleEscapePresses(ctx context.Context, presses int) { } func (app *App) handleWorkingInterruptKey(ctx context.Context, event *tcell.EventKey) bool { - if (!app.working && !app.compacting) || !isEscapeKey(event) { + if app.inspectingWhilePromptRuns() || (!app.working && !app.compacting) || !isEscapeKey(event) { return false } diff --git a/internal/terminal/message_layout.go b/internal/terminal/message_layout.go index ec1d69f..9037eca 100644 --- a/internal/terminal/message_layout.go +++ b/internal/terminal/message_layout.go @@ -197,7 +197,7 @@ func (app *App) dynamicMessageLineGroups(width int) [][]tui.Line { groups = append(groups, app.renderRunningToolBlock(width, &app.runningToolBlocks[index].Call)) } - if app.busy() { + if app.busy() && !app.inspectingWhilePromptRuns() { groups = append(groups, app.renderWorkingIndicator(width)) } diff --git a/internal/terminal/session_view.go b/internal/terminal/session_view.go new file mode 100644 index 0000000..c19a8ac --- /dev/null +++ b/internal/terminal/session_view.go @@ -0,0 +1,152 @@ +package terminal + +import ( + "maps" + "slices" + "time" + + "github.com/omarluq/librecode/internal/model" + "github.com/omarluq/librecode/internal/tui" +) + +// sessionViewState preserves presentation state while another session is being +// inspected. Prompt ownership remains on activePrompt and is intentionally not +// part of a view. +type sessionViewState struct { + lastEscape time.Time + pendingParentID *string + scopedEnabled map[string]bool + promptHistoryDraft string + streamingThinkingText string + streamingText string + statusMessage string + runningToolBlocks []runningToolBlock + queuedMessages []string + liveAgentCompletions []chatMessage + promptHistory []string + hiddenQueuedMessages []string + scopedOrder []string + settings sessionSettingsDocument + composerBuffer tui.TextArea + transcript transcriptState + tokenUsage model.TokenUsage + selection mouseSelection + transcriptList transcriptListSelection + streamedToolEvents int + promptHistoryIndex int + scrollOffset int + autocompleteSelection int + escapePresses int + autocompleteClosed bool +} + +func (app *App) saveSessionView() { + if app.sessionID == "" { + return + } + + if app.sessionViews == nil { + app.sessionViews = make(map[string]sessionViewState) + } + + app.sessionViews[app.sessionID] = sessionViewState{ + pendingParentID: cloneStringPtr(app.pendingParentID), + transcript: app.transcript, + runningToolBlocks: app.runningToolBlocks, + liveAgentCompletions: app.liveAgentCompletions, + queuedMessages: app.queuedMessages, + hiddenQueuedMessages: app.hiddenQueuedMessages, + promptHistory: app.promptHistory, + promptHistoryDraft: app.promptHistoryDraft, + tokenUsage: cloneTerminalUsage(app.tokenUsage), + composerBuffer: cloneComposerBuffer(app.composerBuffer), + selection: app.selection, + transcriptList: app.transcriptList, + streamingText: app.streamingText, + streamingThinkingText: app.streamingThinkingText, + scopedEnabled: maps.Clone(app.scopedEnabled), + scopedOrder: slices.Clone(app.scopedOrder), + settings: app.currentSessionSettings(), + streamedToolEvents: app.streamedToolEvents, + promptHistoryIndex: app.promptHistoryIndex, + scrollOffset: app.scrollOffset, + statusMessage: app.statusMessage, + autocompleteSelection: app.autocompleteSelection, + autocompleteClosed: app.autocompleteClosed, + escapePresses: app.escapePresses, + lastEscape: app.lastEscape, + } +} + +func (app *App) restoreSessionView(sessionID string) bool { + view, found := app.sessionViews[sessionID] + if !found { + return false + } + + app.sessionID = sessionID + app.pendingParentID = cloneStringPtr(view.pendingParentID) + app.transcript = view.transcript + app.runningToolBlocks = view.runningToolBlocks + app.liveAgentCompletions = view.liveAgentCompletions + app.queuedMessages = view.queuedMessages + app.hiddenQueuedMessages = view.hiddenQueuedMessages + app.promptHistory = view.promptHistory + app.promptHistoryDraft = view.promptHistoryDraft + app.tokenUsage = cloneTerminalUsage(view.tokenUsage) + app.composerBuffer = cloneComposerBuffer(view.composerBuffer) + app.selection = view.selection + app.transcriptList = view.transcriptList + app.streamingText = view.streamingText + app.streamingThinkingText = view.streamingThinkingText + app.scopedEnabled = maps.Clone(view.scopedEnabled) + app.scopedOrder = slices.Clone(view.scopedOrder) + app.streamedToolEvents = view.streamedToolEvents + app.promptHistoryIndex = view.promptHistoryIndex + app.scrollOffset = view.scrollOffset + app.statusMessage = view.statusMessage + app.autocompleteSelection = view.autocompleteSelection + app.autocompleteClosed = view.autocompleteClosed + app.escapePresses = view.escapePresses + app.lastEscape = view.lastEscape + app.applySessionSettings(&view.settings) + + return true +} + +// withSessionView routes an event to its owning session without changing the +// session the user is viewing. The terminal event loop serializes these state +// transitions, so callbacks cannot observe a partially switched view. +func (app *App) inspectingWhilePromptRuns() bool { + return app.activePrompt != nil && app.activePrompt.SessionID != "" && app.activePrompt.SessionID != app.sessionID +} + +func (app *App) withSessionView(sessionID string, apply func()) bool { + if sessionID == "" || sessionID == app.sessionID { + apply() + + return true + } + + displayedSessionID := app.sessionID + app.saveSessionView() + + if !app.restoreSessionView(sessionID) { + app.restoreSessionView(displayedSessionID) + + return false + } + + apply() + app.saveSessionView() + app.restoreSessionView(displayedSessionID) + + return true +} + +func cloneComposerBuffer(buffer tui.TextArea) tui.TextArea { + buffer.Metadata = maps.Clone(buffer.Metadata) + buffer.Chars = slices.Clone(buffer.Chars) + + return buffer +} diff --git a/internal/terminal/session_view_internal_test.go b/internal/terminal/session_view_internal_test.go new file mode 100644 index 0000000..89573d9 --- /dev/null +++ b/internal/terminal/session_view_internal_test.go @@ -0,0 +1,56 @@ +package terminal + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPromptUserEntryAssignsDisplayedNewSession(t *testing.T) { + t.Parallel() + + app := newRenderTestApp(t) + app.activePrompt = newTestActivePrompt(nil) + app.activePrompt.ID = 7 + app.working = true + + app.handlePromptAsyncEvent(t.Context(), asyncTestEvent( + asyncEventPromptUserEntry, + "created-session", + "user-entry", + 7, + )) + app.handlePromptAsyncEvent(t.Context(), asyncTestEvent( + asyncEventPromptDelta, + "", + "streamed response", + 7, + )) + + require.NotNil(t, app.activePrompt) + assert.Equal(t, "created-session", app.sessionID) + assert.Equal(t, "created-session", app.activePrompt.SessionID) + require.Len(t, app.transcript.Streaming.Blocks, 1) + assert.Equal(t, "streamed response", app.transcript.Streaming.Blocks[0].Content) + assert.False(t, app.inspectingWhilePromptRuns()) +} + +func TestWithSessionViewRejectsEventWhenOwnerViewIsMissing(t *testing.T) { + t.Parallel() + + app := newRenderTestApp(t) + app.sessionID = "displayed-session" + called := false + + applied := app.withSessionView("missing-session", func() { + called = true + + app.addSystemMessage("misrouted event") + }) + + assert.False(t, applied) + assert.False(t, called) + assert.Equal(t, "displayed-session", app.sessionID) + assert.Empty(t, app.transcript.History) +} From cc0867f0387da81e12c7795981691ea5c0789c87 Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Tue, 28 Jul 2026 22:56:37 -0500 Subject: [PATCH 3/4] chore: dedup tests --- .../agent_tasks_behavior_internal_test.go | 180 ++++++------------ internal/terminal/input.go | 14 +- 2 files changed, 72 insertions(+), 122 deletions(-) diff --git a/internal/terminal/agent_tasks_behavior_internal_test.go b/internal/terminal/agent_tasks_behavior_internal_test.go index 5f21915..b1b3645 100644 --- a/internal/terminal/agent_tasks_behavior_internal_test.go +++ b/internal/terminal/agent_tasks_behavior_internal_test.go @@ -186,6 +186,42 @@ func newAgentTaskSessionPair(t *testing.T) agentTaskSessionPair { return agentTaskSessionPair{connection: connection, sessions: sessions, parent: parent, child: child} } +func newAgentTaskSessionTestApp( + t *testing.T, + state database.TaskState, +) (agentTaskSessionPair, database.AgentTaskEntity, *App) { + t.Helper() + + fixture := newAgentTaskSessionPair(t) + task := behaviorAgentTask(behaviorTaskID, state) + task.Task.OwnerSessionID = fixture.parent.ID + task.ChildSessionID = fixture.child.ID + stub := newAgentTaskControllerStub(map[string]*database.AgentTaskEntity{task.Task.ID: &task}, nil) + app := newRenderTestApp(t) + app.runtime = assistant.NewRuntimeForTest(func(options *assistant.RuntimeTestOptions) { + options.Sessions = fixture.sessions + }) + app.runtime.SetAgentTaskController(stub) + app.sessionID = fixture.parent.ID + + return fixture, task, app +} + +func newActivePromptInspectionTestApp( + t *testing.T, + state database.TaskState, + cancel context.CancelFunc, +) (agentTaskSessionPair, database.AgentTaskEntity, *App) { + t.Helper() + + fixture, task, app := newAgentTaskSessionTestApp(t, state) + app.activePrompt = newTestActivePrompt(cancel) + app.activePrompt.SessionID = fixture.parent.ID + app.working = true + + return fixture, task, app +} + func TestAgentTaskPureBehavior(t *testing.T) { t.Parallel() @@ -673,18 +709,7 @@ func TestInspectAndLeaveAgentTaskSession(t *testing.T) { func TestRevisitAgentTaskSessionRefreshesDurableTranscript(t *testing.T) { t.Parallel() - fixture := newAgentTaskSessionPair(t) - task := behaviorAgentTask(behaviorTaskID, database.TaskSucceeded) - task.Task.OwnerSessionID = fixture.parent.ID - task.ChildSessionID = fixture.child.ID - stub := newAgentTaskControllerStub(map[string]*database.AgentTaskEntity{task.Task.ID: &task}, nil) - - app := newRenderTestApp(t) - app.runtime = assistant.NewRuntimeForTest(func(options *assistant.RuntimeTestOptions) { - options.Sessions = fixture.sessions - }) - app.runtime.SetAgentTaskController(stub) - app.sessionID = fixture.parent.ID + fixture, _, app := newAgentTaskSessionTestApp(t, database.TaskSucceeded) require.NoError(t, app.inspectAgentTask(t.Context(), behaviorTaskID)) app.transcript.Streaming.Blocks = []chatMessage{ @@ -711,18 +736,7 @@ func TestRevisitAgentTaskSessionRefreshesDurableTranscript(t *testing.T) { func TestRevisitRunningAgentTaskPreservesTransientState(t *testing.T) { t.Parallel() - fixture := newAgentTaskSessionPair(t) - task := behaviorAgentTask(behaviorTaskID, database.TaskRunning) - task.Task.OwnerSessionID = fixture.parent.ID - task.ChildSessionID = fixture.child.ID - stub := newAgentTaskControllerStub(map[string]*database.AgentTaskEntity{task.Task.ID: &task}, nil) - - app := newRenderTestApp(t) - app.runtime = assistant.NewRuntimeForTest(func(options *assistant.RuntimeTestOptions) { - options.Sessions = fixture.sessions - }) - app.runtime.SetAgentTaskController(stub) - app.sessionID = fixture.parent.ID + _, _, app := newAgentTaskSessionTestApp(t, database.TaskRunning) require.NoError(t, app.inspectAgentTask(t.Context(), behaviorTaskID)) defer app.stopAgentTaskWatches() @@ -899,21 +913,7 @@ func TestAltEscapeLeavesAgentTaskSession(t *testing.T) { func TestInspectAgentTaskWhileParentPromptIsActive(t *testing.T) { t.Parallel() - fixture := newAgentTaskSessionPair(t) - task := behaviorAgentTask(behaviorTaskID, database.TaskRunning) - task.Task.OwnerSessionID = fixture.parent.ID - task.ChildSessionID = fixture.child.ID - stub := newAgentTaskControllerStub(map[string]*database.AgentTaskEntity{task.Task.ID: &task}, nil) - - app := newRenderTestApp(t) - app.runtime = assistant.NewRuntimeForTest(func(options *assistant.RuntimeTestOptions) { - options.Sessions = fixture.sessions - }) - app.runtime.SetAgentTaskController(stub) - app.sessionID = fixture.parent.ID - app.activePrompt = newTestActivePrompt(nil) - app.activePrompt.SessionID = fixture.parent.ID - app.working = true + fixture, _, app := newActivePromptInspectionTestApp(t, database.TaskRunning, nil) app.composerBuffer.SetText("parent draft") app.addSystemMessage("parent partial response") @@ -962,22 +962,12 @@ func TestInspectAgentTaskWhileParentPromptIsActive(t *testing.T) { func TestAgentTaskCompletionRoutesToParentWhileChildIsInspected(t *testing.T) { t.Parallel() - fixture := newAgentTaskSessionPair(t) - task := behaviorAgentTask(behaviorTaskID, database.TaskSucceeded) - task.Task.OwnerSessionID = fixture.parent.ID - task.ChildSessionID = fixture.child.ID + fixture, task, app := newActivePromptInspectionTestApp(t, database.TaskSucceeded, nil) task.Task.Result = "parent completion" - stub := newAgentTaskControllerStub(map[string]*database.AgentTaskEntity{task.Task.ID: &task}, nil) - - app := newRenderTestApp(t) - app.runtime = assistant.NewRuntimeForTest(func(options *assistant.RuntimeTestOptions) { - options.Sessions = fixture.sessions - }) - app.runtime.SetAgentTaskController(stub) - app.sessionID = fixture.parent.ID - app.activePrompt = newTestActivePrompt(nil) - app.activePrompt.SessionID = fixture.parent.ID - app.working = true + app.runtime.SetAgentTaskController(newAgentTaskControllerStub( + map[string]*database.AgentTaskEntity{task.Task.ID: &task}, + nil, + )) require.NoError(t, app.inspectAgentTask(t.Context(), behaviorTaskID)) defer app.stopAgentTaskWatches() @@ -1010,27 +1000,17 @@ func TestAgentTaskCompletionRoutesToParentWhileChildIsInspected(t *testing.T) { func TestActivePromptInspectionAllowsNestedTaskSelection(t *testing.T) { t.Parallel() - fixture := newAgentTaskSessionPair(t) - parentTask := behaviorAgentTask(behaviorTaskID, database.TaskRunning) - parentTask.Task.OwnerSessionID = fixture.parent.ID - parentTask.ChildSessionID = fixture.child.ID + fixture, parentTask, app := newActivePromptInspectionTestApp(t, database.TaskRunning, nil) nestedTask := behaviorAgentTask("nested-task", database.TaskRunning) nestedTask.Task.OwnerSessionID = fixture.child.ID nestedTask.ChildSessionID = "nested-session" - stub := newAgentTaskControllerStub(map[string]*database.AgentTaskEntity{ - behaviorTaskID: &parentTask, - "nested-task": &nestedTask, - }, nil) - - app := newRenderTestApp(t) - app.runtime = assistant.NewRuntimeForTest(func(options *assistant.RuntimeTestOptions) { - options.Sessions = fixture.sessions - }) - app.runtime.SetAgentTaskController(stub) - app.sessionID = fixture.parent.ID - app.activePrompt = newTestActivePrompt(nil) - app.activePrompt.SessionID = fixture.parent.ID - app.working = true + app.runtime.SetAgentTaskController(newAgentTaskControllerStub( + map[string]*database.AgentTaskEntity{ + behaviorTaskID: &parentTask, + "nested-task": &nestedTask, + }, + nil, + )) require.NoError(t, app.inspectAgentTask(t.Context(), behaviorTaskID)) defer app.stopAgentTaskWatches() @@ -1052,22 +1032,12 @@ func TestActivePromptInspectionAllowsNestedTaskSelection(t *testing.T) { func TestDoubleEscapeCancelsParentPromptWithoutLeavingInspection(t *testing.T) { t.Parallel() - fixture := newAgentTaskSessionPair(t) - task := behaviorAgentTask(behaviorTaskID, database.TaskRunning) - task.Task.OwnerSessionID = fixture.parent.ID - task.ChildSessionID = fixture.child.ID - stub := newAgentTaskControllerStub(map[string]*database.AgentTaskEntity{task.Task.ID: &task}, nil) - - app := newRenderTestApp(t) - app.runtime = assistant.NewRuntimeForTest(func(options *assistant.RuntimeTestOptions) { - options.Sessions = fixture.sessions - }) - app.runtime.SetAgentTaskController(stub) - app.sessionID = fixture.parent.ID canceled := false - app.activePrompt = newTestActivePrompt(func() { canceled = true }) - app.activePrompt.SessionID = fixture.parent.ID - app.working = true + fixture, _, app := newActivePromptInspectionTestApp( + t, + database.TaskRunning, + func() { canceled = true }, + ) app.composerBuffer.SetText("parent draft") require.NoError(t, app.inspectAgentTask(t.Context(), behaviorTaskID)) @@ -1108,22 +1078,12 @@ func TestInspectionPanelEscapeClosesPanelBeforeLeavingSession(t *testing.T) { func TestAltEscapeLeavesInspectionWithoutCancelingParentPrompt(t *testing.T) { t.Parallel() - fixture := newAgentTaskSessionPair(t) - task := behaviorAgentTask(behaviorTaskID, database.TaskRunning) - task.Task.OwnerSessionID = fixture.parent.ID - task.ChildSessionID = fixture.child.ID - stub := newAgentTaskControllerStub(map[string]*database.AgentTaskEntity{task.Task.ID: &task}, nil) - - app := newRenderTestApp(t) - app.runtime = assistant.NewRuntimeForTest(func(options *assistant.RuntimeTestOptions) { - options.Sessions = fixture.sessions - }) - app.runtime.SetAgentTaskController(stub) - app.sessionID = fixture.parent.ID canceled := false - app.activePrompt = newTestActivePrompt(func() { canceled = true }) - app.activePrompt.SessionID = fixture.parent.ID - app.working = true + fixture, _, app := newActivePromptInspectionTestApp( + t, + database.TaskRunning, + func() { canceled = true }, + ) require.NoError(t, app.inspectAgentTask(t.Context(), behaviorTaskID)) defer app.stopAgentTaskWatches() @@ -1144,21 +1104,7 @@ func TestAltEscapeLeavesInspectionWithoutCancelingParentPrompt(t *testing.T) { func TestActivePromptInspectionBlocksGlobalAndExtensionShortcuts(t *testing.T) { t.Parallel() - fixture := newAgentTaskSessionPair(t) - task := behaviorAgentTask(behaviorTaskID, database.TaskRunning) - task.Task.OwnerSessionID = fixture.parent.ID - task.ChildSessionID = fixture.child.ID - stub := newAgentTaskControllerStub(map[string]*database.AgentTaskEntity{task.Task.ID: &task}, nil) - - app := newRenderTestApp(t) - app.runtime = assistant.NewRuntimeForTest(func(options *assistant.RuntimeTestOptions) { - options.Sessions = fixture.sessions - }) - app.runtime.SetAgentTaskController(stub) - app.sessionID = fixture.parent.ID - app.activePrompt = newTestActivePrompt(nil) - app.activePrompt.SessionID = fixture.parent.ID - app.working = true + _, _, app := newActivePromptInspectionTestApp(t, database.TaskRunning, nil) require.NoError(t, app.inspectAgentTask(t.Context(), behaviorTaskID)) defer app.stopAgentTaskWatches() diff --git a/internal/terminal/input.go b/internal/terminal/input.go index 3b556fa..7a4825e 100644 --- a/internal/terminal/input.go +++ b/internal/terminal/input.go @@ -10,6 +10,8 @@ import ( "github.com/omarluq/librecode/internal/tui" ) +const readOnlyAgentInspectionStatus = "agent task inspection is read-only while the parent response runs" + func (app *App) handleEvent(ctx context.Context, event tcell.Event) (bool, error) { switch typedEvent := event.(type) { case *tcell.EventResize: @@ -100,9 +102,7 @@ func (app *App) handleModalPriorityKey(ctx context.Context, event *tcell.EventKe return app.handlePanelPriorityKey(ctx, event) } - app.setStatus("agent task inspection is read-only while the parent response runs") - - return keyHandlingResult{err: nil, shouldQuit: false, handled: true} + return app.readOnlyInspectionResult() } func (app *App) handleInterruptPriorityKey(ctx context.Context, event *tcell.EventKey) keyHandlingResult { @@ -133,7 +133,11 @@ func (app *App) handleReadOnlyInspectionPriorityKey( return keyHandlingResult{err: nil, shouldQuit: false, handled: true} } - app.setStatus("agent task inspection is read-only while the parent response runs") + return app.readOnlyInspectionResult() +} + +func (app *App) readOnlyInspectionResult() keyHandlingResult { + app.setStatus(readOnlyAgentInspectionStatus) return keyHandlingResult{err: nil, shouldQuit: false, handled: true} } @@ -257,7 +261,7 @@ func (app *App) handleFocusedAutocompleteKey(event *tcell.EventKey) bool { func (app *App) handleInputKey(ctx context.Context, event *tcell.EventKey) (bool, error) { if app.inspectingWhilePromptRuns() { - app.setStatus("agent task inspection is read-only while the parent response runs") + app.setStatus(readOnlyAgentInspectionStatus) return false, nil } From 69dfafea7ac5a8fe2b0f10cba7ec72d70091305d Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Tue, 28 Jul 2026 23:13:03 -0500 Subject: [PATCH 4/4] fix(terminal): harden active prompt agent inspection --- internal/terminal/agent_tasks.go | 59 +++++++++++++-- .../agent_tasks_behavior_internal_test.go | 19 +++++ internal/terminal/async_events.go | 6 +- .../terminal/bench_terminal_internal_test.go | 22 +++++- internal/terminal/input.go | 10 --- internal/terminal/session_view.go | 74 +++++++++++++------ .../terminal/session_view_internal_test.go | 23 +++++- 7 files changed, 170 insertions(+), 43 deletions(-) diff --git a/internal/terminal/agent_tasks.go b/internal/terminal/agent_tasks.go index 29cdd0f..b7a3ee4 100644 --- a/internal/terminal/agent_tasks.go +++ b/internal/terminal/agent_tasks.go @@ -958,9 +958,11 @@ func (app *App) deliverAgentTaskCompletion(ctx context.Context, task *database.A return } - app.withSessionView(task.Task.OwnerSessionID, func() { + if !app.withSessionView(task.Task.OwnerSessionID, func() { app.deliverAgentTaskCompletionText(ctx, task.Task.ID, completion) - }) + }) { + app.setStatus("agent result owner view is unavailable") + } } func (app *App) deliverAgentTaskCompletionEvent(ctx context.Context, taskID, completion string) { @@ -976,9 +978,11 @@ func (app *App) deliverAgentTaskCompletionEvent(ctx context.Context, taskID, com ownerSessionID = task.Task.OwnerSessionID } - app.withSessionView(ownerSessionID, func() { + if !app.withSessionView(ownerSessionID, func() { app.deliverAgentTaskCompletionText(ctx, taskID, completion) - }) + }) { + app.setStatus("agent result owner view is unavailable") + } } func (app *App) deliverAgentTaskCompletionText(ctx context.Context, taskID, completion string) { @@ -1650,7 +1654,9 @@ func (app *App) leaveAgentTaskSession(ctx context.Context) error { app.saveSessionView() app.agentTaskSessionStack = app.agentTaskSessionStack[:last] - if !app.restoreSessionView(parentSessionID) { + if app.restoreSessionView(parentSessionID) { + app.appendMissingSessionMessages(messages) + } else { app.sessionID = parentSessionID app.pendingParentID = nil app.resetMessages() @@ -1674,6 +1680,49 @@ func (app *App) leaveAgentTaskSession(ctx context.Context) error { return nil } +func (app *App) appendMissingSessionMessages(messages []database.SessionMessageEntity) { + appended := false + + for index := range messages { + message := &messages[index] + role := transcript.FromDatabaseRole(message.Role) + found := false + + for historyIndex := range app.transcript.History { + history := &app.transcript.History[historyIndex] + if history.CreatedAt.Equal(message.CreatedAt) && + history.Role == role && history.Content == message.Content { + found = true + + break + } + } + + if found { + continue + } + + app.appendMessage(chatMessage{ + CreatedAt: message.CreatedAt, + Role: role, + Content: message.Content, + }) + + appended = true + + if message.Role == database.RoleUser { + app.recordPromptHistory(message.Content) + } + } + + if appended { + slices.SortStableFunc(app.transcript.History, func(left, right chatMessage) int { + return left.CreatedAt.Compare(right.CreatedAt) + }) + app.transcript.LineCache.reset() + } +} + func (app *App) resumeInspectedAgentTask(ctx context.Context, childSessionID string) { ownerSessionID := app.agentTaskSessionStack[len(app.agentTaskSessionStack)-1] diff --git a/internal/terminal/agent_tasks_behavior_internal_test.go b/internal/terminal/agent_tasks_behavior_internal_test.go index b1b3645..4db7910 100644 --- a/internal/terminal/agent_tasks_behavior_internal_test.go +++ b/internal/terminal/agent_tasks_behavior_internal_test.go @@ -706,6 +706,25 @@ func TestInspectAndLeaveAgentTaskSession(t *testing.T) { assert.Contains(t, err.Error(), "outside the current inspection path") } +func TestLeaveAgentTaskSessionRefreshesDurableParentTranscript(t *testing.T) { + t.Parallel() + + fixture, _, app := newAgentTaskSessionTestApp(t, database.TaskSucceeded) + + require.NoError(t, app.inspectAgentTask(t.Context(), behaviorTaskID)) + _, err := fixture.sessions.AppendMessage(t.Context(), fixture.parent.ID, nil, &database.MessageEntity{ + Timestamp: time.Now().UTC(), + Role: database.RoleAssistant, + Content: "new durable parent message", + Provider: "", + Model: "", + }) + require.NoError(t, err) + + require.NoError(t, app.leaveAgentTaskSession(t.Context())) + assert.Contains(t, app.transcript.History[0].Content, "new durable parent message") +} + func TestRevisitAgentTaskSessionRefreshesDurableTranscript(t *testing.T) { t.Parallel() diff --git a/internal/terminal/async_events.go b/internal/terminal/async_events.go index 65a3e82..8b32eb0 100644 --- a/internal/terminal/async_events.go +++ b/internal/terminal/async_events.go @@ -264,13 +264,15 @@ func (app *App) handlePromptAsyncEvent(ctx context.Context, payload *asyncEvent) ownerSessionID = app.activePrompt.SessionID } - app.withSessionView(ownerSessionID, func() { + if !app.withSessionView(ownerSessionID, func() { if app.handlePromptLifecycleEvent(ctx, payload) { return } app.handlePromptStreamEvent(ctx, payload) - }) + }) { + app.setStatus("prompt event owner view is unavailable") + } } func (app *App) ignorePromptEvent(payload *asyncEvent) bool { diff --git a/internal/terminal/bench_terminal_internal_test.go b/internal/terminal/bench_terminal_internal_test.go index 45672e7..565fd18 100644 --- a/internal/terminal/bench_terminal_internal_test.go +++ b/internal/terminal/bench_terminal_internal_test.go @@ -2,15 +2,35 @@ package terminal import ( "fmt" - "github.com/omarluq/librecode/internal/tui" "strings" "testing" "github.com/gdamore/tcell/v3" "github.com/omarluq/librecode/internal/transcript" + "github.com/omarluq/librecode/internal/tui" ) +const benchmarkDisplayedSession = "displayed-session" + +func BenchmarkWithSessionViewMissing(b *testing.B) { + app := newApp(nil, &RunOptions{ + Extensions: nil, Resources: nil, Runtime: nil, Workflows: nil, Settings: nil, + Models: nil, Auth: nil, Config: nil, CWD: "", SessionID: "", + }) + app.sessionID = benchmarkDisplayedSession + app.composerBuffer.Metadata = map[string]any{"source": "benchmark"} + app.composerBuffer.Chars = []string{"a", "b", "c"} + app.scopedEnabled = map[string]bool{"benchmark-tool": true} + app.scopedOrder = []string{"benchmark-tool"} + + b.ReportAllocs() + + for b.Loop() { + app.withSessionView("missing-session", func() {}) + } +} + func BenchmarkDrawMessagesSameWidth(b *testing.B) { app := newApp(nil, &RunOptions{ Extensions: nil, diff --git a/internal/terminal/input.go b/internal/terminal/input.go index 7a4825e..15d2409 100644 --- a/internal/terminal/input.go +++ b/internal/terminal/input.go @@ -146,10 +146,6 @@ func (app *App) handleInlineListsAndExtensionKey( ctx context.Context, event *tcell.EventKey, ) keyHandlingResult { - if app.inspectingWhilePromptRuns() { - return app.handleReadOnlyInspectionPriorityKey(ctx, event) - } - if handled, err := app.handleAgentTaskSummaryPriorityKey(ctx, event); handled || err != nil { return keyHandlingResult{err: err, shouldQuit: false, handled: true} } @@ -260,12 +256,6 @@ func (app *App) handleFocusedAutocompleteKey(event *tcell.EventKey) bool { } func (app *App) handleInputKey(ctx context.Context, event *tcell.EventKey) (bool, error) { - if app.inspectingWhilePromptRuns() { - app.setStatus(readOnlyAgentInspectionStatus) - - return false, nil - } - if app.keys.matches(event, actionInputClear) && !app.composerBuffer.Empty() { app.composerBuffer.Clear() app.resetPromptHistoryNavigation() diff --git a/internal/terminal/session_view.go b/internal/terminal/session_view.go index c19a8ac..5c5c2b5 100644 --- a/internal/terminal/session_view.go +++ b/internal/terminal/session_view.go @@ -49,8 +49,12 @@ func (app *App) saveSessionView() { app.sessionViews = make(map[string]sessionViewState) } - app.sessionViews[app.sessionID] = sessionViewState{ - pendingParentID: cloneStringPtr(app.pendingParentID), + app.sessionViews[app.sessionID] = app.captureSessionView(true) +} + +func (app *App) captureSessionView(clone bool) sessionViewState { + view := sessionViewState{ + pendingParentID: app.pendingParentID, transcript: app.transcript, runningToolBlocks: app.runningToolBlocks, liveAgentCompletions: app.liveAgentCompletions, @@ -58,14 +62,14 @@ func (app *App) saveSessionView() { hiddenQueuedMessages: app.hiddenQueuedMessages, promptHistory: app.promptHistory, promptHistoryDraft: app.promptHistoryDraft, - tokenUsage: cloneTerminalUsage(app.tokenUsage), - composerBuffer: cloneComposerBuffer(app.composerBuffer), + tokenUsage: app.tokenUsage, + composerBuffer: app.composerBuffer, selection: app.selection, transcriptList: app.transcriptList, streamingText: app.streamingText, streamingThinkingText: app.streamingThinkingText, - scopedEnabled: maps.Clone(app.scopedEnabled), - scopedOrder: slices.Clone(app.scopedOrder), + scopedEnabled: app.scopedEnabled, + scopedOrder: app.scopedOrder, settings: app.currentSessionSettings(), streamedToolEvents: app.streamedToolEvents, promptHistoryIndex: app.promptHistoryIndex, @@ -76,6 +80,15 @@ func (app *App) saveSessionView() { escapePresses: app.escapePresses, lastEscape: app.lastEscape, } + if clone { + view.pendingParentID = cloneStringPtr(view.pendingParentID) + view.tokenUsage = cloneTerminalUsage(view.tokenUsage) + view.composerBuffer = cloneComposerBuffer(view.composerBuffer) + view.scopedEnabled = maps.Clone(view.scopedEnabled) + view.scopedOrder = slices.Clone(view.scopedOrder) + } + + return view } func (app *App) restoreSessionView(sessionID string) bool { @@ -84,8 +97,14 @@ func (app *App) restoreSessionView(sessionID string) bool { return false } + app.applySessionView(sessionID, &view, true) + + return true +} + +func (app *App) applySessionView(sessionID string, view *sessionViewState, clone bool) { app.sessionID = sessionID - app.pendingParentID = cloneStringPtr(view.pendingParentID) + app.pendingParentID = view.pendingParentID app.transcript = view.transcript app.runningToolBlocks = view.runningToolBlocks app.liveAgentCompletions = view.liveAgentCompletions @@ -93,14 +112,14 @@ func (app *App) restoreSessionView(sessionID string) bool { app.hiddenQueuedMessages = view.hiddenQueuedMessages app.promptHistory = view.promptHistory app.promptHistoryDraft = view.promptHistoryDraft - app.tokenUsage = cloneTerminalUsage(view.tokenUsage) - app.composerBuffer = cloneComposerBuffer(view.composerBuffer) + app.tokenUsage = view.tokenUsage + app.composerBuffer = view.composerBuffer app.selection = view.selection app.transcriptList = view.transcriptList app.streamingText = view.streamingText app.streamingThinkingText = view.streamingThinkingText - app.scopedEnabled = maps.Clone(view.scopedEnabled) - app.scopedOrder = slices.Clone(view.scopedOrder) + app.scopedEnabled = view.scopedEnabled + app.scopedOrder = view.scopedOrder app.streamedToolEvents = view.streamedToolEvents app.promptHistoryIndex = view.promptHistoryIndex app.scrollOffset = view.scrollOffset @@ -111,16 +130,23 @@ func (app *App) restoreSessionView(sessionID string) bool { app.lastEscape = view.lastEscape app.applySessionSettings(&view.settings) - return true + if clone { + app.pendingParentID = cloneStringPtr(app.pendingParentID) + app.tokenUsage = cloneTerminalUsage(app.tokenUsage) + app.composerBuffer = cloneComposerBuffer(app.composerBuffer) + app.scopedEnabled = maps.Clone(app.scopedEnabled) + app.scopedOrder = slices.Clone(app.scopedOrder) + } } -// withSessionView routes an event to its owning session without changing the -// session the user is viewing. The terminal event loop serializes these state -// transitions, so callbacks cannot observe a partially switched view. func (app *App) inspectingWhilePromptRuns() bool { return app.activePrompt != nil && app.activePrompt.SessionID != "" && app.activePrompt.SessionID != app.sessionID } +// withSessionView routes an event to its owning session without changing the +// session the user is viewing. The terminal event loop serializes these state +// transitions, so callbacks cannot observe a partially switched view. State is +// transferred rather than cloned because neither view is used concurrently. func (app *App) withSessionView(sessionID string, apply func()) bool { if sessionID == "" || sessionID == app.sessionID { apply() @@ -128,18 +154,20 @@ func (app *App) withSessionView(sessionID string, apply func()) bool { return true } - displayedSessionID := app.sessionID - app.saveSessionView() - - if !app.restoreSessionView(sessionID) { - app.restoreSessionView(displayedSessionID) - + targetView, found := app.sessionViews[sessionID] + if !found { return false } + displayedSessionID := app.sessionID + displayedView := app.captureSessionView(false) + app.sessionViews[displayedSessionID] = displayedView + app.applySessionView(sessionID, &targetView, false) + apply() - app.saveSessionView() - app.restoreSessionView(displayedSessionID) + + app.sessionViews[sessionID] = app.captureSessionView(false) + app.applySessionView(displayedSessionID, &displayedView, false) return true } diff --git a/internal/terminal/session_view_internal_test.go b/internal/terminal/session_view_internal_test.go index 89573d9..7554952 100644 --- a/internal/terminal/session_view_internal_test.go +++ b/internal/terminal/session_view_internal_test.go @@ -40,7 +40,7 @@ func TestWithSessionViewRejectsEventWhenOwnerViewIsMissing(t *testing.T) { t.Parallel() app := newRenderTestApp(t) - app.sessionID = "displayed-session" + app.sessionID = benchmarkDisplayedSession called := false applied := app.withSessionView("missing-session", func() { @@ -51,6 +51,25 @@ func TestWithSessionViewRejectsEventWhenOwnerViewIsMissing(t *testing.T) { assert.False(t, applied) assert.False(t, called) - assert.Equal(t, "displayed-session", app.sessionID) + assert.Equal(t, benchmarkDisplayedSession, app.sessionID) assert.Empty(t, app.transcript.History) } + +func TestPromptEventReportsMissingOwnerView(t *testing.T) { + t.Parallel() + + app := newRenderTestApp(t) + app.sessionID = benchmarkDisplayedSession + app.activePrompt = newTestActivePrompt(nil) + app.activePrompt.SessionID = "missing-session" + + app.handlePromptAsyncEvent(t.Context(), asyncTestEvent( + asyncEventPromptDelta, + "dropped delta", + "", + app.activePrompt.ID, + )) + + assert.Equal(t, "prompt event owner view is unavailable", app.statusMessage) + assert.Empty(t, app.transcript.Streaming.Blocks) +}