Skip to content

Commit bc61860

Browse files
Merge pull request #7 from OneStepAt4time/fix/issue-2
Arch: Protocol violation due to internal polling in comet_ask
2 parents e090e5b + df85817 commit bc61860

2 files changed

Lines changed: 8 additions & 307 deletions

File tree

src/server.ts

Lines changed: 3 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ export const toolDefinitions: ToolDef[] = [
158158
{
159159
name: 'comet_ask',
160160
description:
161-
'Send a prompt to Perplexity Comet and poll until the agent responds or times out. Supports newChat to start fresh.',
161+
'Send a prompt to Perplexity Comet and return immediately. Supports newChat to start fresh. Use comet_poll or comet_wait to get the response.',
162162
inputSchema: buildInputSchema(askShape),
163163
},
164164
{
@@ -355,7 +355,7 @@ export async function startServer(): Promise<void> {
355355
// 2. comet_ask
356356
server.tool(
357357
'comet_ask',
358-
'Send a prompt to Perplexity Comet and poll until the agent responds or times out. Supports newChat to start fresh.',
358+
'Send a prompt to Perplexity Comet and return immediately. Supports newChat to start fresh. Use comet_poll or comet_wait to get the response.',
359359
askShape,
360360
async ({ prompt, newChat, timeout }) => {
361361
try {
@@ -398,92 +398,7 @@ export async function startServer(): Promise<void> {
398398
const submitResult = await client.safeEvaluate(buildSubmitPromptScript())
399399
logger.debug('Submit result:', extractValue(submitResult))
400400

401-
// POLLING LOOP
402-
const startTime = Date.now()
403-
let sawNewResponse = false
404-
let timedOut = false
405-
const collectedSteps: string[] = []
406-
let lastResponse = ''
407-
let stallCount = 0
408-
const MAX_STALL_POLLS = 10
409-
410-
while (!timedOut && Date.now() - startTime < effectiveTimeout) {
411-
await sleep(config.pollInterval)
412-
413-
const statusRaw = await client.safeEvaluate(buildGetAgentStatusScript(activeSelectors))
414-
const status = parseAgentStatus(extractValue(statusRaw))
415-
416-
// Collect new steps
417-
for (const step of status.steps) {
418-
if (!collectedSteps.includes(step)) {
419-
collectedSteps.push(step)
420-
}
421-
}
422-
423-
// Check for new response — proseCount is the primary signal
424-
const proseIncreased = (status.proseCount ?? 0) > preSendState.proseCount
425-
// Only consider response "changed" if:
426-
// 1. proseCount increased (new prose element added), OR
427-
// 2. Fresh page had no prose before, and now there's a substantial response
428-
const responseChanged =
429-
proseIncreased || (!preSendState.lastProseText && hasSubstantialResponse(status))
430-
431-
if (responseChanged && status.response) {
432-
// Track response growth for auto-extend
433-
if (status.response.length > lastResponse.length) {
434-
stallCount = 0
435-
} else if (sawNewResponse) {
436-
stallCount++
437-
}
438-
sawNewResponse = true
439-
lastResponse = status.response
440-
}
441-
442-
// Stall detection — if response stopped growing, give up after MAX_STALL_POLLS
443-
if (stallCount >= MAX_STALL_POLLS) break
444-
445-
if ((status.status === 'completed' || status.status === 'idle') && sawNewResponse) {
446-
// Wait for response to stabilize — poll until length stops growing
447-
let settledResponse = lastResponse
448-
for (let settle = 0; settle < 5; settle++) {
449-
await sleep(1000)
450-
const settledRaw = await client.safeEvaluate(
451-
buildGetAgentStatusScript(activeSelectors),
452-
)
453-
const settledStatus = parseAgentStatus(extractValue(settledRaw))
454-
const candidate = settledStatus.response || settledResponse
455-
if (candidate.length <= settledResponse.length) break
456-
settledResponse = candidate
457-
}
458-
459-
const parts: string[] = []
460-
if (settledResponse) parts.push(settledResponse)
461-
if (collectedSteps.length > 0) {
462-
parts.push(`\n\nSteps:\n${collectedSteps.map((s) => ` - ${s}`).join('\n')}`)
463-
}
464-
return textResult(parts.join('') || 'Agent completed with no visible response.')
465-
}
466-
467-
if (
468-
status.status === 'idle' &&
469-
!sawNewResponse &&
470-
status.response &&
471-
!preSendState.lastProseText
472-
) {
473-
return textResult(status.response)
474-
}
475-
}
476-
477-
// Mark timed out to prevent any further polling
478-
timedOut = true
479-
480-
// Timeout
481-
const timeoutParts: string[] = ['Agent is still working. Use comet_poll to check status.']
482-
if (collectedSteps.length > 0) {
483-
timeoutParts.push(`\nSteps so far:\n${collectedSteps.map((s) => ` - ${s}`).join('\n')}`)
484-
}
485-
if (lastResponse) timeoutParts.push(`\nPartial response:\n${lastResponse}`)
486-
return textResult(timeoutParts.join('\n'))
401+
return textResult('Prompt submitted successfully. Use comet_poll to track status or comet_wait to block until completion.')
487402
} catch (err) {
488403
return toMcpError(err)
489404
}

tests/integration/tools/core-tools.test.ts

Lines changed: 5 additions & 219 deletions
Original file line numberDiff line numberDiff line change
@@ -138,241 +138,27 @@ describe('Core tool handlers', () => {
138138
// ---------------------------------------------------------------------------
139139

140140
describe('comet_ask', () => {
141-
it('quick response — returns agent response', async () => {
141+
it('returns immediate submission message without polling', async () => {
142142
let callCount = 0
143-
const responseText =
144-
'The answer is 42, which is the meaning of life according to Douglas Adams'
145-
146143
mocks.safeEvaluate.mockImplementation(async () => {
147144
callCount++
148-
// First call: pre-send state
149-
if (callCount === 1) {
150-
return { result: { value: '{"proseCount":0,"lastProseText":""}' } }
151-
}
152-
// Second call: type prompt
153-
if (callCount === 2) {
154-
return { result: { value: 'typed' } }
155-
}
156-
// Third call: submit
157-
if (callCount === 3) {
158-
return { result: { value: 'submitted' } }
159-
}
160-
// Fourth+ calls: status polling (completed)
161-
return {
162-
result: {
163-
value: JSON.stringify({
164-
status: 'completed',
165-
steps: ['Searching web'],
166-
currentStep: 'Searching web',
167-
response: responseText,
168-
hasStopButton: false,
169-
}),
170-
},
171-
}
172-
})
173-
174-
const handler = getHandler('comet_ask')
175-
const result = await handler({ prompt: 'What is 42?' })
176-
177-
expect(result.content[0].text).toContain(responseText)
178-
})
179-
180-
it('timeout — returns still working message', async () => {
181-
mocks.safeEvaluate.mockResolvedValue({
182-
result: {
183-
value: JSON.stringify({
184-
status: 'working',
185-
steps: [],
186-
currentStep: '',
187-
response: '',
188-
hasStopButton: true,
189-
}),
190-
},
145+
return { result: { value: '{"proseCount":0,"lastProseText":""}' } }
191146
})
192147

193148
const handler = getHandler('comet_ask')
194-
const result = await handler({ prompt: 'test', timeout: 300 })
149+
const result = await handler({ prompt: 'test' })
195150

196-
expect(result.content[0].text).toContain('Agent is still working')
151+
expect(result.content[0].text).toContain('Prompt submitted successfully')
152+
expect(result.content[0].text).toContain('comet_poll')
197153
})
198154

199155
it('error handling — returns MCP error when safeEvaluate throws', async () => {
200156
mocks.safeEvaluate.mockRejectedValue(new Error('Script error'))
201-
202157
const handler = getHandler('comet_ask')
203158
const result = await handler({ prompt: 'test' })
204-
205159
expect(result.isError).toBe(true)
206160
expect(result.content[0].text).toContain('Error')
207161
})
208-
209-
it('sequential queries — returns new response when proseCount increases', async () => {
210-
// Simulates BUG-2: second query should detect new response via proseCount
211-
// even when old response text is still on the page
212-
let callCount = 0
213-
const oldResponse = 'This is the old response from the first query that is still on the page.'
214-
const newResponse = 'This is the new response from the second query with different content.'
215-
216-
mocks.safeEvaluate.mockImplementation(async () => {
217-
callCount++
218-
// First call: pre-send state (old response still on page, proseCount=1)
219-
if (callCount === 1) {
220-
return {
221-
result: { value: JSON.stringify({ proseCount: 1, lastProseText: oldResponse }) },
222-
}
223-
}
224-
// Second call: type prompt
225-
if (callCount === 2) return { result: { value: 'typed' } }
226-
// Third call: submit
227-
if (callCount === 3) return { result: { value: 'submitted' } }
228-
// Fourth+ calls: status polling — proseCount now 2 (new prose added)
229-
return {
230-
result: {
231-
value: JSON.stringify({
232-
status: 'completed',
233-
steps: ['Searching web'],
234-
currentStep: 'Searching web',
235-
response: newResponse,
236-
hasStopButton: false,
237-
proseCount: 2,
238-
}),
239-
},
240-
}
241-
})
242-
243-
const handler = getHandler('comet_ask')
244-
const result = await handler({ prompt: 'What is 3+3?' })
245-
246-
expect(result.content[0].text).toContain(newResponse)
247-
expect(result.content[0].text).not.toContain(oldResponse)
248-
})
249-
250-
it('comet_ask stops polling after timeout — no runaway polling', async () => {
251-
let evalCalls = 0
252-
mocks.safeEvaluate.mockImplementation(async () => {
253-
evalCalls++
254-
if (evalCalls === 1) return { result: { value: '{"proseCount":0,"lastProseText":""}' } }
255-
if (evalCalls === 2) return { result: { value: 'typed' } }
256-
if (evalCalls === 3) return { result: { value: 'submitted' } }
257-
return {
258-
result: {
259-
value: JSON.stringify({
260-
status: 'working',
261-
steps: [],
262-
currentStep: '',
263-
response: '',
264-
hasStopButton: true,
265-
}),
266-
},
267-
}
268-
})
269-
270-
const handler = getHandler('comet_ask')
271-
const result = await handler({ prompt: 'test', timeout: 300 })
272-
expect(result.content[0].text).toContain('Agent is still working')
273-
274-
// Verify no runaway polling after timeout
275-
const callsAfterTimeout = evalCalls
276-
await new Promise((r) => setTimeout(r, 500))
277-
expect(evalCalls).toBe(callsAfterTimeout)
278-
})
279-
280-
it('smart polling — auto-extends when response is growing', async () => {
281-
let callCount = 0
282-
const growingResponses = ['A'.repeat(60), 'A'.repeat(120), 'A'.repeat(200)]
283-
mocks.safeEvaluate.mockImplementation(async () => {
284-
callCount++
285-
if (callCount === 1) return { result: { value: '{"proseCount":0,"lastProseText":""}' } }
286-
if (callCount === 2) return { result: { value: 'typed' } }
287-
if (callCount === 3) return { result: { value: 'submitted' } }
288-
const responseIdx = Math.min(callCount - 4, growingResponses.length - 1)
289-
return {
290-
result: {
291-
value: JSON.stringify({
292-
status: callCount > 6 ? 'completed' : 'working',
293-
steps: [],
294-
currentStep: '',
295-
response: growingResponses[responseIdx],
296-
hasStopButton: callCount <= 6,
297-
proseCount: 1,
298-
}),
299-
},
300-
}
301-
})
302-
303-
const handler = getHandler('comet_ask')
304-
// 300ms timeout would normally be too short, but growing response should keep it alive
305-
const result = await handler({ prompt: 'test', timeout: 300 })
306-
// Should have gotten the full response since it was growing
307-
expect(result.content[0].text).toContain('A'.repeat(200))
308-
})
309-
310-
it('smart polling — gives up after stall', async () => {
311-
let callCount = 0
312-
const stalledResponse = 'B'.repeat(60)
313-
mocks.safeEvaluate.mockImplementation(async () => {
314-
callCount++
315-
if (callCount === 1) return { result: { value: '{"proseCount":0,"lastProseText":""}' } }
316-
if (callCount === 2) return { result: { value: 'typed' } }
317-
if (callCount === 3) return { result: { value: 'submitted' } }
318-
return {
319-
result: {
320-
value: JSON.stringify({
321-
status: 'working',
322-
steps: [],
323-
currentStep: '',
324-
response: stalledResponse,
325-
hasStopButton: true,
326-
proseCount: 1,
327-
}),
328-
},
329-
}
330-
})
331-
332-
const handler = getHandler('comet_ask')
333-
const result = await handler({ prompt: 'test', timeout: 30000 })
334-
// Should time out because response stopped growing (stall detection)
335-
expect(result.content[0].text).toContain('still working')
336-
})
337-
338-
it('ignores old substantial response — no false positive from hasSubstantialResponse', async () => {
339-
// Regression: hasSubstantialResponse was OR'd into responseChanged,
340-
// causing old responses to be treated as new when proseCount didn't increase.
341-
const oldResponse =
342-
'This is an old response from a previous query that is still on the page and is quite long.'
343-
let callCount = 0
344-
mocks.safeEvaluate.mockImplementation(async () => {
345-
callCount++
346-
// pre-send state: old response still on page
347-
if (callCount === 1) {
348-
return {
349-
result: { value: JSON.stringify({ proseCount: 1, lastProseText: oldResponse }) },
350-
}
351-
}
352-
if (callCount === 2) return { result: { value: 'typed' } }
353-
if (callCount === 3) return { result: { value: 'submitted' } }
354-
// Polling: agent hasn't started yet, old response still visible
355-
return {
356-
result: {
357-
value: JSON.stringify({
358-
status: 'working',
359-
steps: [],
360-
currentStep: '',
361-
response: oldResponse,
362-
hasStopButton: true,
363-
proseCount: 1,
364-
}),
365-
},
366-
}
367-
})
368-
369-
const handler = getHandler('comet_ask')
370-
const result = await handler({ prompt: 'New question?', timeout: 1500 })
371-
372-
// Should NOT return the old response as if it were the new answer
373-
// Instead should timeout since no new response detected
374-
expect(result.content[0].text).toContain('still working')
375-
})
376162
})
377163

378164
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)