Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 41 additions & 12 deletions products/posthog_ai/frontend/components/ThreadItems.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IconCheck, IconCollapse, IconWarning, IconX } from '@posthog/icons'
import { IconCheck, IconCircleDashed, IconCollapse, IconWarning, IconX } from '@posthog/icons'
import { Spinner } from '@posthog/lemon-ui'

import { humanFriendlyNumber } from 'lib/utils/numbers'
Expand All @@ -7,23 +7,52 @@ import type { ThreadItem } from '../types/streamTypes'
import { Activity } from './ActivityPrimitives'
import type { ActivityStatus } from './ActivityPrimitives'

/** Inline `_posthog/status` item — a spinner while compacting, a generic status line otherwise. */
export function StatusItem({ item }: { item: ThreadItem }): JSX.Element {
const isCompacting = item.status === 'compacting' && !item.isComplete
/** Statuses that run for a while and get a spinner with their own label, keyed by wire status. */
const IN_PROGRESS_STATUS_LABELS: Record<string, string> = {
compacting: 'Compacting conversation history…',
clearing: 'Clearing conversation…',
}

function StatusLine({ icon, children }: { icon?: JSX.Element; children: React.ReactNode }): JSX.Element {
return (
<div className="flex items-center justify-center gap-2 py-1 text-xs text-muted">
{isCompacting ? (
<>
<Spinner className="size-3" />
<span>Compacting conversation history…</span>
</>
) : (
<span>Status: {item.status}</span>
)}
{icon}
<span>{children}</span>
</div>
)
}

/** Inline `_posthog/status` item — a spinner while an operation runs, a status line otherwise. */
export function StatusItem({ item }: { item: ThreadItem }): JSX.Element {
const inProgressLabel = item.isComplete ? undefined : IN_PROGRESS_STATUS_LABELS[item.status ?? '']
if (inProgressLabel) {
return <StatusLine icon={<Spinner className="size-3" />}>{inProgressLabel}</StatusLine>
}
// A failed clear leaves the agent session closed, so the way forward is a new run, not a retry.
if (item.status === 'clearing_failed') {
const reason = item.errorMessage
? `Couldn't clear the conversation: ${item.errorMessage}`
: "Couldn't clear the conversation"
return <StatusLine icon={<IconX className="size-3" />}>{reason}. Start a new run to keep going.</StatusLine>
}
return <StatusLine>Status: {item.status}</StatusLine>
}

/** Inline `_posthog/conversation_cleared` item — the `/clear` boundary card. */
export function ConversationClearedItem({ item }: { item: ThreadItem }): JSX.Element {
return (
<Activity
id={item.id}
title="Conversation cleared"
subtitle="Earlier messages are no longer in the agent's context"
status="completed"
icon={<IconCircleDashed className="size-4" />}
animate={false}
showCompletionIcon={false}
/>
)
}

/** Inline `_posthog/compact_boundary` item — the post-compaction card. */
export function CompactBoundaryItem({ item }: { item: ThreadItem }): JSX.Element {
const parts = [
Expand Down
5 changes: 4 additions & 1 deletion products/posthog_ai/frontend/components/ThreadRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import type { ProgressStep, ThreadItem } from '../types/streamTypes'
import { resolveToolCall } from '../utils/toolResolver'
import { RunActivity } from './RunActivity'
import { RunAlertActivity } from './RunAlertActivity'
import { CompactBoundaryItem, StatusItem, TaskNotificationItem } from './ThreadItems'
import { CompactBoundaryItem, ConversationClearedItem, StatusItem, TaskNotificationItem } from './ThreadItems'
import { ToolCallCard } from './tool/ToolCallCard'

type ToolInvocations = typeof runStreamLogic.values.toolInvocations
Expand Down Expand Up @@ -161,6 +161,9 @@ export const ThreadRow = memo(function ThreadRow({
if (item.type === 'compact_boundary') {
return <CompactBoundaryItem item={item} />
}
if (item.type === 'conversation_cleared') {
return <ConversationClearedItem item={item} />
}
if (item.type === 'task_notification') {
return <TaskNotificationItem item={item} />
}
Expand Down
1 change: 1 addition & 0 deletions products/posthog_ai/frontend/components/ThreadView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const THREAD_ITEM_HEIGHT_ESTIMATES: Partial<Record<ThreadItem['type'], number>>
error: 42,
status: 42,
compact_boundary: 42,
conversation_cleared: 42,
task_notification: 26,
progress: 42,
debug: 30,
Expand Down
75 changes: 74 additions & 1 deletion products/posthog_ai/frontend/logics/runInteractionLogic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import { aiConsentLogic } from 'scenes/settings/organization/aiConsentLogic'

import { initKeaTests } from '~/test/init'

import { tasksRunCreate, tasksRunsCommandCreate } from 'products/tasks/frontend/generated/api'
import {
tasksRunCreate,
tasksRunsClearConversationCreate,
tasksRunsCommandCreate,
} from 'products/tasks/frontend/generated/api'

import { contextItemLine } from '../utils/posthogContextBlock'
import { attachedContextLogic } from './attachedContextLogic'
Expand All @@ -25,12 +29,14 @@ jest.mock('./runStreamLogic', () => {
key((p: { streamKey: string }) => p.streamKey),
actions({
pushHumanMessage: (content: string) => ({ content }),
pushConversationCleared: true,
respondToPermission: (payload: unknown) => ({ payload }),
cancelRun: (run?: unknown) => ({ run }),
markTurnComplete: true,
setCurrentMode: (mode: string) => ({ mode }),
setStubStatus: (status: string | null) => ({ status }),
setStubThinking: (thinking: boolean) => ({ thinking }),
setStubClearSupported: (supported: boolean) => ({ supported }),
}),
reducers({
currentRunStatus: [
Expand All @@ -46,6 +52,12 @@ jest.mock('./runStreamLogic', () => {
},
],
pendingPermissionRequest: [null, {}],
conversationClearSupported: [
true,
{
setStubClearSupported: (_: boolean, { supported }: { supported: boolean }) => supported,
},
],
respondingToPermission: [false, {}],
currentMode: [
null,
Expand Down Expand Up @@ -82,6 +94,7 @@ jest.mock('scenes/projectLogic', () => {
jest.mock('products/tasks/frontend/generated/api', () => ({
tasksRunsCommandCreate: jest.fn(),
tasksRunCreate: jest.fn(),
tasksRunsClearConversationCreate: jest.fn(),
}))

jest.mock('lib/lemon-ui/LemonToast', () => ({
Expand Down Expand Up @@ -122,6 +135,7 @@ describe('runInteractionLogic', () => {
jest.clearAllMocks()
;(tasksRunsCommandCreate as jest.Mock).mockResolvedValue({})
;(tasksRunCreate as jest.Mock).mockResolvedValue({ latest_run: { id: 'run-2' } })
;(tasksRunsClearConversationCreate as jest.Mock).mockResolvedValue({})
initKeaTests()
project = projectLogic()
project.mount()
Expand Down Expand Up @@ -365,6 +379,40 @@ describe('runInteractionLogic', () => {
expect(logic.values.composerForm.draft).toBe('')
})

it('records the boundary instead of starting a run when /clear is sent to a terminal run', async () => {
setStatus('completed')
logic.actions.setComposerFormValues({ draft: '/clear' })

await expectLogic(logic, () => {
logic.actions.submitComposerForm()
}).toFinishAllListeners()

// Booting a sandbox would clear a conversation the next run rebuilds from the log anyway.
expect(tasksRunCreate).not.toHaveBeenCalled()
expect(tasksRunsClearConversationCreate).toHaveBeenCalledWith('997', TASK_ID, RUN_ID)
expect(logic.values.composerForm.draft).toBe('')
// Nothing streams back on a finished run, so the boundary is echoed from here.
await expectLogic(stream).toDispatchActions([
(action) => action.type === stream.actionTypes.pushHumanMessage && action.payload.content === '/clear',
(action) => action.type === stream.actionTypes.pushConversationCleared,
])
})

it('falls back to a new run when the chain agent cannot honour the clear boundary', async () => {
// An older agent ignores the marker and resumes the conversation it was meant to retire,
// so a divider here would claim a clear that never happens.
;(stream.actions as unknown as { setStubClearSupported: (s: boolean) => void }).setStubClearSupported(false)
setStatus('completed')
logic.actions.setComposerFormValues({ draft: '/clear' })

await expectLogic(logic, () => {
logic.actions.submitComposerForm()
}).toFinishAllListeners()

expect(tasksRunsClearConversationCreate).not.toHaveBeenCalled()
expect(tasksRunCreate).toHaveBeenCalled()
})

it('keeps the draft and toasts when starting a new run fails', async () => {
;(tasksRunCreate as jest.Mock).mockRejectedValue(new Error('boom'))
setStatus('completed')
Expand Down Expand Up @@ -439,6 +487,31 @@ describe('runInteractionLogic', () => {
expect(send.params.content).toBe('follow up')
})

it('sends /clear unwrapped so the agent still sees the command at the front, and keeps the context pending', async () => {
const item = { type: 'insight', key: 'sig', label: 'Signups' }
attachedContextLogic().actions.registerContext('scene', [item])
setThinking(false)

logic.actions.setComposerFormValues({ draft: '/clear' })
await expectLogic(logic, () => {
logic.actions.submitComposerForm()
}).toFinishAllListeners()

const send = (tasksRunsCommandCreate as jest.Mock).mock.calls[0][3] as { params: { content: string } }
expect(send.params.content).toBe('/clear')

// The agent drops the message rather than reading it, so the ref was never really delivered:
// the next real send must still carry it.
;(tasksRunsCommandCreate as jest.Mock).mockClear()
logic.actions.setComposerFormValues({ draft: 'why the drop?' })
await expectLogic(logic, () => {
logic.actions.submitComposerForm()
}).toFinishAllListeners()

const next = (tasksRunsCommandCreate as jest.Mock).mock.calls[0][3] as { params: { content: string } }
expect(next.params.content).toContain('- insight sig ("Signups")')
})

it('keeps pruning context sent by a terminal-run send after re-pointing to the fresh run', async () => {
attachedContextLogic().actions.registerContext('scene', [{ type: 'insight', key: 'sig', label: 'Signups' }])

Expand Down
Loading
Loading