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
40 changes: 40 additions & 0 deletions src/renderer/src/hooks/use-message-reconciliation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,27 @@ describe('getActiveMessageReconciliationRequest', () => {
}
})).toBeNull()
})

it('schedules reconciliation after a human message send timestamp changes', () => {
const reconciler = { schedule: vi.fn() }

hooks.scheduleHumanMessageSentReconciliation({
lastHumanMessageSentAt: 0,
reconciler
})
hooks.scheduleHumanMessageSentReconciliation({
lastHumanMessageSentAt: 1_717_000_000_000,
reconciler
})

expect(reconciler.schedule).toHaveBeenCalledTimes(1)
expect(reconciler.schedule).toHaveBeenCalledWith('human-message-sent')
})
})

describe('createMessageReconciler', () => {
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
agentStore.useAgentStore.getState().clearAll()
})
Expand Down Expand Up @@ -234,4 +251,27 @@ describe('createMessageReconciler', () => {
store.reconcileMessages([messagesAfterUpdate[0]])
expect(agentStore.useAgentStore.getState().messages).toBe(messagesAfterUpdate)
})

it('tracks optimistic human sends for reconciliation triggers', () => {
const now = 1_717_000_123_000
vi.spyOn(Date, 'now').mockReturnValue(now)

agentStore.useAgentStore.getState().addHumanMessage('#general', 'hello from human', 'project-1')

const state = agentStore.useAgentStore.getState()
expect(state.lastHumanMessageSentAt).toBe(now)
expect(state.messages[0]).toMatchObject({
from: 'human',
to: '#general',
body: 'hello from human',
projectId: 'project-1'
})
})

it('wires human message sends into the reconciliation hook', () => {
const source = hooks.useMessageReconciliation.toString()
expect(source).toContain('s.lastHumanMessageSentAt')
expect(source).toMatch(/scheduleHumanMessageSentReconciliation\([\s\S]*lastHumanMessageSentAt[\s\S]*reconciler/)
expect(source).toMatch(/\[lastHumanMessageSentAt,\s*reconciler\]/)
})
Comment on lines +271 to +276

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Asserting on the stringified source code of a React hook (hooks.useMessageReconciliation.toString()) is highly brittle and considered an anti-pattern. This test will easily break if:

  1. The code is minified or bundled (where variable names like s are mangled).
  2. Code formatting changes (e.g., spacing, line breaks, or running Prettier).
  3. Internal variables or selectors are refactored (e.g., renaming s to state).

Instead, you should test the hook's behavior. If @testing-library/react is available, you can use renderHook to verify that updating lastHumanMessageSentAt in the store triggers the reconciler's schedule function.

})
14 changes: 14 additions & 0 deletions src/renderer/src/hooks/use-message-reconciliation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ export interface MessageReconciler {
dispose: () => void
}

export function scheduleHumanMessageSentReconciliation(input: {
lastHumanMessageSentAt: number
reconciler: Pick<MessageReconciler, 'schedule'>
}): void {
if (input.lastHumanMessageSentAt > 0) {
input.reconciler.schedule('human-message-sent')
}
}

function normalizeChannelName(value: string | undefined): string | null {
const normalized = value?.trim().replace(/^#/, '')
return normalized || null
Expand Down Expand Up @@ -206,6 +215,7 @@ export function useMessageReconciliation(): void {
const activeTabId = useUIStore((s) => s.activeTabId)
const tabs = useUIStore((s) => s.tabs)
const brokerStatus = useAgentStore((s) => s.brokerStatus)
const lastHumanMessageSentAt = useAgentStore((s) => s.lastHumanMessageSentAt)
const activeTab = getActiveTab(tabs, activeTabId)
const activeRoomKey = activeTab?.kind === 'channel'
? `channel:${activeTab.projectId || activeProjectId || ''}:${normalizeChannelName(activeTab.channelName) || ''}`
Expand Down Expand Up @@ -245,6 +255,10 @@ export function useMessageReconciliation(): void {
}
}, [brokerStatus, reconciler])

useEffect(() => {
scheduleHumanMessageSentReconciliation({ lastHumanMessageSentAt, reconciler })
}, [lastHumanMessageSentAt, reconciler])

useEffect(() => {
const scheduleAfterRefresh = (reason: string): void => {
refreshEventStream(reason)
Expand Down
8 changes: 6 additions & 2 deletions src/renderer/src/stores/agent-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,7 @@ interface AgentState {
brokerError: string | null
brokerErrors: BrokerErrorEntry[]
brokerEvents: BrokerEventRecord[]
lastHumanMessageSentAt: number

setActiveAgentKey: (key: string | null) => void
markAgentActive: (projectId: string | undefined, name: string) => void
Expand Down Expand Up @@ -460,6 +461,7 @@ export const useAgentStore = create<AgentState>()(subscribeWithSelector((set, ge
brokerError: null,
brokerErrors: [],
brokerEvents: [],
lastHumanMessageSentAt: 0,

setActiveAgentKey: (key) => set({ activeAgentKey: key }),

Expand Down Expand Up @@ -1002,7 +1004,8 @@ export const useAgentStore = create<AgentState>()(subscribeWithSelector((set, ge
set((state) => ({
messages: isDuplicateHumanEcho(state.messages, msg)
? state.messages
: capByCount([...state.messages, msg], MAX_CHAT_MESSAGES)
: capByCount([...state.messages, msg], MAX_CHAT_MESSAGES),
lastHumanMessageSentAt: timestamp
}))
},

Expand Down Expand Up @@ -1147,7 +1150,8 @@ export const useAgentStore = create<AgentState>()(subscribeWithSelector((set, ge
brokerStatus: 'disconnected',
brokerError: null,
brokerErrors: [],
brokerEvents: []
brokerEvents: [],
lastHumanMessageSentAt: 0
}),

getAgentBuffer: (projectId, name) => getPtyChunks(getAgentKey(projectId, name))
Expand Down
Loading