Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
type AgentHarnessStreamEvent,
attachToHarnessTurn,
Expand All @@ -16,8 +16,13 @@ import type {
} from '@/lib/agent-conversations/types'
import { useInvalidateAgentOutputs } from '@/lib/agent-files'
import type { ServerAttachmentPayload } from '@/lib/attachments'
import { sentry } from '@/lib/sentry/sentry'
import { consumeSSEStream } from '@/lib/sse'
import { buildToolLabel } from '@/lib/tool-labels'
import {
createWorkflowUsageRecord,
recordWorkflowUsage,
} from '@/lib/workflow-usage/storage'
import { mapAgentHarnessToolStatus } from './agent-stream-events'

export interface SendInput {
Expand Down Expand Up @@ -68,6 +73,8 @@ export function useAgentConversation(
const streamAbortRef = useRef<AbortController | null>(null)
const onCompleteRef = useRef(options.onComplete)
const onSessionKeyChangeRef = useRef(options.onSessionKeyChange)
const workflowToolNamesRef = useRef<string[]>([])
const workflowToolIdsRef = useRef(new Set<string>())
// Per-turn resume bookkeeping. `turnId` is captured from the response
// header; `lastSeq` advances with every SSE event so a reconnect can
// resume via Last-Event-ID.
Expand Down Expand Up @@ -112,6 +119,35 @@ export function useAgentConversation(
})
}

const resetWorkflowUsageCapture = useCallback(() => {
workflowToolNamesRef.current = []
workflowToolIdsRef.current = new Set()
}, [])

const persistWorkflowUsageCapture = useCallback(
(turnId?: string | null) => {
const toolNames = workflowToolNamesRef.current
if (toolNames.length === 0) return

void recordWorkflowUsage(
createWorkflowUsageRecord({
id: `agent-harness-turn:${turnId ?? crypto.randomUUID()}`,
source: 'agent-harness-chat',
toolNames,
}),
).catch((error) => {
sentry.captureException(error, {
extra: {
message: 'Failed to persist agent workflow usage pattern',
agentId,
turnId,
},
})
})
},
[agentId],
)

const appendTextDelta = (delta: string) => {
textAccRef.current += delta
const text = textAccRef.current
Expand Down Expand Up @@ -174,6 +210,11 @@ export function useAgentConversation(
const upsertAgentHarnessTool = (event: AgentHarnessStreamEvent) => {
if (event.type !== 'tool_call') return
const rawName = event.title || event.rawType || 'tool call'
const toolId = event.id ?? rawName
if (!workflowToolIdsRef.current.has(toolId)) {
workflowToolIdsRef.current.add(toolId)
workflowToolNamesRef.current.push(rawName)
}
const { label, subject } = buildToolLabel(
rawName,
event.text ? { description: event.text } : undefined,
Expand Down Expand Up @@ -295,6 +336,7 @@ export function useAgentConversation(
streamAbortRef.current = abortController
setStreaming(true)
weStartedStream = true
resetWorkflowUsageCapture()

const response = await attachToHarnessTurn(agentId, {
turnId: active.turnId,
Expand Down Expand Up @@ -328,6 +370,7 @@ export function useAgentConversation(
// itself, so resetting here would only cause a brief flicker.
if (!cancelled && weStartedStream) {
const finishedTurnId = turnIdRef.current
persistWorkflowUsageCapture(finishedTurnId)
turnIdRef.current = null
lastSeqRef.current = null
setStreaming(false)
Expand All @@ -344,7 +387,12 @@ export function useAgentConversation(
cancelled = true
abortController.abort()
}
}, [agentId, activeTurnIdDep])
}, [
agentId,
activeTurnIdDep,
persistWorkflowUsageCapture,
resetWorkflowUsageCapture,
])

/**
* Send the chat request and follow the 409-active-turn redirect
Expand Down Expand Up @@ -422,6 +470,7 @@ export function useAgentConversation(
}
setTurns((prev) => [...prev, turn])
setStreaming(true)
resetWorkflowUsageCapture()
textAccRef.current = ''
thinkAccRef.current = ''
const abortController = new AbortController()
Expand Down Expand Up @@ -466,6 +515,7 @@ export function useAgentConversation(
// useAgentTurnFiles consumers also flush, not just the agent-wide
// rail query.
const finishedTurnId = turnIdRef.current
persistWorkflowUsageCapture(finishedTurnId)
turnIdRef.current = null
lastSeqRef.current = null
onCompleteRef.current?.()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ import {
normalizeToolApprovalConfig,
toolApprovalConfigStorage,
} from '@/lib/tool-approvals/storage'
import {
analyzeWorkflowUsage,
detectWorkflowAdvisorCommand,
formatWorkflowAnalysisResponse,
formatWorkflowUsageClearedResponse,
formatWorkflowUsageDataResponse,
type WorkflowAdvisorCommand,
} from '@/lib/workflow-usage/advisor'
import {
clearWorkflowUsageRecords,
getWorkflowUsageRecords,
} from '@/lib/workflow-usage/storage'
import { selectedWorkspaceStorage } from '@/lib/workspace/workspace-storage'
import type { ChatMode } from './chatTypes'
import { GetConversationWithMessagesDocument } from './graphql/chatSessionDocument'
Expand Down Expand Up @@ -133,6 +145,7 @@ export interface ChatSessionOptions {
}

const NEWTAB_SYSTEM_PROMPT = `IMPORTANT: The user is chatting from the New Tab page. When performing browser actions, ALWAYS open content in a NEW TAB rather than navigating the current tab. The user's new tab page should remain accessible.`
const WORKFLOW_ADVISOR_LOCAL_ONLY = 'workflow-advisor'

const getUserSystemPrompt = (
origin: ChatOrigin | undefined,
Expand All @@ -142,6 +155,25 @@ const getUserSystemPrompt = (
? [personalization, NEWTAB_SYSTEM_PROMPT].filter(Boolean).join('\n\n')
: personalization

const createTextMessage = (
role: 'user' | 'assistant',
text: string,
options?: { localOnly?: boolean },
): UIMessage =>
({
id: crypto.randomUUID(),
role,
parts: [{ type: 'text', text }],
metadata: options?.localOnly
? { browserosLocalOnly: WORKFLOW_ADVISOR_LOCAL_ONLY }
: undefined,
}) as UIMessage

const isWorkflowAdvisorLocalOnlyMessage = (message: UIMessage): boolean => {
const metadata = (message as { metadata?: Record<string, unknown> }).metadata
return metadata?.browserosLocalOnly === WORKFLOW_ADVISOR_LOCAL_ONLY
}

const buildRequestBrowserContext = ({
activeTab,
action,
Expand Down Expand Up @@ -376,7 +408,9 @@ export const useChatSession = (options?: ChatSessionOptions) => {
Feature.PREVIOUS_CONVERSATION_ARRAY,
)

const previousMessages = messagesRef.current
const previousMessages = messagesRef.current.filter(
(message) => !isWorkflowAdvisorLocalOnlyMessage(message),
)
const history =
previousMessages.length > 0
? formatConversationHistory(previousMessages)
Expand Down Expand Up @@ -559,7 +593,9 @@ export const useChatSession = (options?: ChatSessionOptions) => {
})
}

const messagesToSave = messages.filter((m) => m.parts?.length > 0)
const messagesToSave = messages.filter(
(m) => m.parts?.length > 0 && !isWorkflowAdvisorLocalOnlyMessage(m),
)
if (messagesToSave.length === 0) return

if (isLoggedIn) {
Expand Down Expand Up @@ -645,6 +681,54 @@ export const useChatSession = (options?: ChatSessionOptions) => {
action?: ChatAction
} | null>(null)

const appendLocalWorkflowAdvisorExchange = (
userText: string,
responseText: string,
) => {
const nextMessages = [
...messagesRef.current,
createTextMessage('user', userText, { localOnly: true }),
createTextMessage('assistant', responseText, { localOnly: true }),
]
messagesRef.current = nextMessages
setMessages(nextMessages)
}

const handleWorkflowAdvisorCommand = async (
text: string,
command: WorkflowAdvisorCommand,
) => {
try {
if (command === 'clear') {
await clearWorkflowUsageRecords()
appendLocalWorkflowAdvisorExchange(
text,
formatWorkflowUsageClearedResponse(),
)
return
}

const records = await getWorkflowUsageRecords()
const response =
command === 'view'
? formatWorkflowUsageDataResponse(records)
: formatWorkflowAnalysisResponse(analyzeWorkflowUsage(records))

appendLocalWorkflowAdvisorExchange(text, response)
} catch (error) {
sentry.captureException(error, {
extra: {
message: 'Failed to run local workflow advisor command',
command,
},
})
appendLocalWorkflowAdvisorExchange(
text,
"I couldn't read the local workflow usage patterns. Nothing was sent to a model or external service.",
)
}
}

const dispatchMessage = useCallback(
(text: string) => {
startExecutionTask({
Expand Down Expand Up @@ -696,6 +780,12 @@ export const useChatSession = (options?: ChatSessionOptions) => {
selectedLlmProvider?.modelId,
})

const workflowAdvisorCommand = detectWorkflowAdvisorCommand(params.text)
if (workflowAdvisorCommand) {
void handleWorkflowAdvisorCommand(params.text, workflowAdvisorCommand)
return
}
Comment on lines +783 to +787
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 MESSAGE_SENT_EVENT analytics fires for locally-handled commands

track(MESSAGE_SENT_EVENT, ...) executes before the detectWorkflowAdvisorCommand early-return check. Workflow advisor commands — which never reach an LLM — are therefore counted as sent messages in analytics, skewing provider/model/agent event data with local-only interactions. Moving the detectWorkflowAdvisorCommand check (or the track call) before the other to ensure the event only fires when a message is genuinely dispatched would fix this.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/useChatSession.ts
Line: 783-787

Comment:
**`MESSAGE_SENT_EVENT` analytics fires for locally-handled commands**

`track(MESSAGE_SENT_EVENT, ...)` executes before the `detectWorkflowAdvisorCommand` early-return check. Workflow advisor commands — which never reach an LLM — are therefore counted as sent messages in analytics, skewing provider/model/agent event data with local-only interactions. Moving the `detectWorkflowAdvisorCommand` check (or the `track` call) before the other to ensure the event only fires when a message is genuinely dispatched would fix this.

How can I resolve this? If you propose a fix, please make it concise.


if (!isIntegrationsSyncedRef.current) {
// Queue the message — will be sent when sync completes
pendingMessageRef.current = params
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import type {
ExecutionTaskStatus,
} from '@/lib/execution-history/types'
import { sentry } from '@/lib/sentry/sentry'
import {
createWorkflowUsageRecordFromExecutionTask,
recordWorkflowUsage,
} from '@/lib/workflow-usage/storage'

interface StartExecutionTaskInput {
conversationId: string
Expand Down Expand Up @@ -145,6 +149,17 @@ export function useExecutionHistoryTracker() {
}

persistTask(nextTask)
void recordWorkflowUsage(
createWorkflowUsageRecordFromExecutionTask(nextTask),
).catch((error) => {
sentry.captureException(error, {
extra: {
message: 'Failed to persist workflow usage pattern',
conversationId: nextTask.conversationId,
taskId: nextTask.id,
},
})
})
activeTaskRef.current = null
},
[persistTask],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'bun:test'
import {
analyzeWorkflowUsage,
detectWorkflowAdvisorCommand,
formatWorkflowAnalysisResponse,
normalizeToolSequence,
} from './advisor'
import type { WorkflowUsageRecord } from './types'

describe('workflow usage advisor', () => {
it('detects explicit workflow advisor commands only', () => {
expect(detectWorkflowAdvisorCommand('analyze my workflow')).toBe('analyze')
expect(detectWorkflowAdvisorCommand('what patterns do you see?')).toBe(
'analyze',
)
expect(detectWorkflowAdvisorCommand('show workflow usage data')).toBe(
'view',
)
expect(detectWorkflowAdvisorCommand('clear skill suggestion data')).toBe(
'clear',
)
expect(detectWorkflowAdvisorCommand('summarize this page')).toBeNull()
})

it('normalizes command sequences without retaining repeated adjacent tools', () => {
expect(normalizeToolSequence([' new_page ', 'new_page', 'open'])).toEqual([
'new_page',
'open',
])
})

it('suggests repeated local tool-name patterns', () => {
const analysis = analyzeWorkflowUsage([
record('1', ['new_page', 'navigate', 'get_page_content'], 100),
record('2', ['new_page', 'navigate', 'get_page_content'], 200),
record('3', ['search', 'open'], 300),
])

expect(analysis.totalRuns).toBe(3)
expect(analysis.suggestions).toHaveLength(1)
expect(analysis.suggestions[0]).toMatchObject({
runCount: 2,
pattern: ['new_page', 'navigate', 'get_page_content'],
})
})

it('formats concrete suggestions with a privacy note', () => {
const response = formatWorkflowAnalysisResponse(
analyzeWorkflowUsage([
record('1', ['new_page', 'navigate', 'get_page_content'], 100),
record('2', ['new_page', 'navigate', 'get_page_content'], 200),
]),
)

expect(response).toContain('Pattern: Open page -> Navigate -> Read page')
expect(response).toContain('Create a "Open Page to Read Page Skill" skill')
expect(response).toContain('does not include URLs')
expect(response).toContain('tool inputs')
})
})

function record(
id: string,
toolNames: string[],
recordedAt: number,
): WorkflowUsageRecord {
return {
id,
source: 'sidepanel-chat',
recordedAt,
toolNames,
}
}
Loading
Loading