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
6 changes: 3 additions & 3 deletions api/src/models/mock-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,13 +201,13 @@ function processMockPrompt (lastMessage: string, prompt: string | Array<any>): M
const last = Array.isArray(prompt) ? prompt[prompt.length - 1] : undefined
const waited = last?.content?.some?.((c: any) => c.type === 'tool-result' && c.toolName === 'wait_for_user_action')
if (waited) return { type: 'text', text: `You did: ${toolResult}` }
return { type: 'tool-call', toolName: 'wait_for_user_action', toolArgs: JSON.stringify({ expecting: 'you to click Create' }) }
return { type: 'tool-call', toolName: 'wait_for_user_action', toolArgs: JSON.stringify({ message: 'Ready: click Create.', expecting: 'you to click Create' }) }
}
if (endsWithCommand(lastMessage, 'wait for me')) {
return { type: 'tool-call', toolName: 'wait_for_user_action', toolArgs: JSON.stringify({ expecting: 'you to click Create' }) }
return { type: 'tool-call', toolName: 'wait_for_user_action', toolArgs: JSON.stringify({ message: 'Ready: click Create.', expecting: 'you to click Create' }) }
}
if (endsWithCommand(lastMessage, 'wait briefly')) {
return { type: 'tool-call', toolName: 'wait_for_user_action', toolArgs: JSON.stringify({ expecting: 'you to click Create', timeoutSeconds: 1 }) }
return { type: 'tool-call', toolName: 'wait_for_user_action', toolArgs: JSON.stringify({ message: 'Ready: click Create.', expecting: 'you to click Create', timeoutSeconds: 1 }) }
}

// If the most recent message in the prompt is a tool result, we already called a tool
Expand Down
5 changes: 5 additions & 0 deletions docs/architecture/host-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,11 @@ a new one. An aborted turn never reaches `result.response`, which is where a tur
normally enter history, so the turn's finished steps are tracked as they complete and committed
(`commitRunningTurn`) before the new turn pushes its user message; the step still open — the one
holding the wait — is recorded with a result saying it was interrupted (`interrupted-turn.ts`).
The new turn's hidden context then ends with a reminder, just before the person's message: what the
wait was for, and to declare it again after answering if that action is still to come. The same
instruction used to live in the interrupted wait's tool result, where it sat in history ahead of the new
question; judged runs answered the question and never waited again, so the action they had handed
over went unseen.
Stop commits the same way, with a result saying the reply was stopped. The open step is reset on
the `finish-step` part the loop reads, not in `onStepFinish`: the SDK runs that callback on its
side of the stream, possibly before the loop has read the step's parts, and any call already in
Expand Down
2 changes: 1 addition & 1 deletion lib-sim/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@data-fair/lib-agents-sim",
"version": "0.8.0",
"version": "0.9.0",
"description": "Primitives for judged browser simulations of the data-fair agents chat, plus a Claude Code bridge exposing the Agent SDK as an OpenAI-compatible provider.",
"main": "index.js",
"type": "module",
Expand Down
33 changes: 32 additions & 1 deletion lib-sim/page-perception.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,29 @@ export const SNAPSHOT_CAP = 4000
// instead of an unrecorded hang.
export const ACTION_TIMEOUT_MS = 15000

/** How long a look after an action waits, at most, for the page to stop changing. */
export const SETTLE_MAX_MS = 3000
export const SETTLE_INTERVAL_MS = 250

/**
* Read until two consecutive reads agree, or the budget runs out. A look taken right
* after clicking a chat link read the page before the route changed: the persona saw
* the old page, said the link did nothing, and the assistant apologised for a link
* the application had just reported as followed.
*/
export async function settledRead (read: () => Promise<string>, opts: { intervalMs?: number, maxMs?: number } = {}): Promise<string> {
const interval = opts.intervalMs ?? SETTLE_INTERVAL_MS
const deadline = Date.now() + (opts.maxMs ?? SETTLE_MAX_MS)
let previous = await read()
while (Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, interval))
const current = await read()
if (current === previous) return current
previous = current
}
return previous
}

/** `cap` overrides SNAPSHOT_CAP for this root: a dense page can deserve more than a chat panel. */
export type PerceptionRoot = { label: string, root: ChatRoot, cap?: number }
export type Observation = { turn: number, tool: string, args: unknown, result: string }
Expand Down Expand Up @@ -203,11 +226,17 @@ export function createPagePerception (roots: PerceptionRoot[], opts: { offLimits
const isOffLimits = (name: string) => offLimits.has(name.trim().toLowerCase())
const OFF_LIMITS_RESULT = 'the composer is not yours to operate — reply with your message and the runner will send it for you'

// Set by click and type: the next look waits for what the action set off to settle.
let actedSinceLook = false

const look = async () => {
const settle = actedSinceLook
actedSinceLook = false
const parts: string[] = []
for (const { label, root, cap } of roots) {
let snap = ''
try { snap = await root.locator('body').ariaSnapshot({ timeout: ACTION_TIMEOUT_MS }) } catch (err) {
const read = () => root.locator('body').ariaSnapshot({ timeout: ACTION_TIMEOUT_MS })
try { snap = settle ? await settledRead(read) : await read() } catch (err) {
snap = `(could not read: ${err instanceof Error ? err.message : String(err)})`
}
// Capped per root, not on the joined result: otherwise a large first
Expand Down Expand Up @@ -251,6 +280,7 @@ export function createPagePerception (roots: PerceptionRoot[], opts: { offLimits
if (match) {
try {
await match.loc.click({ timeout: ACTION_TIMEOUT_MS })
actedSinceLook = true
// Playwright clicks whatever is visible, so the text fallback succeeds
// on a paragraph as readily as on a button. Saying which one it was is
// the difference between a person learning nothing happened and a
Expand All @@ -277,6 +307,7 @@ export function createPagePerception (roots: PerceptionRoot[], opts: { offLimits
if (loc) {
try {
await loc.fill(text, { timeout: ACTION_TIMEOUT_MS })
actedSinceLook = true
return `typed into "${name}"`
} catch (err) {
return `could not type into "${name}": ${err instanceof Error ? err.message : String(err)}`
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 5 additions & 4 deletions simulations/simulate.sim.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,11 @@ for (const simCase of selected) {
const changed = resumed.length !== conversation.length
conversation.length = 0
conversation.push(...resumed)
// A person who acted and stopped has not seen the reply their action caused:
// a judged run ended on the click, and whether the assistant noticed the
// creation was never on record. Let them read it before they decide.
if (changed && isDone(message)) continue
// Whatever the person wrote in that pass, they wrote it before the reply their
// action caused: a judged run ended on the click, others sent "what was just
// created?" under the very message that said so, interrupting the next wait.
// Drop it and let them read the reply first — stopping included.
if (changed) continue
}
if (isDone(message)) break
if (message === '') {
Expand Down
9 changes: 7 additions & 2 deletions tests/features/chat-send/1.interrupted-turn.unit.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { test } from 'playwright/test'
import assert from 'node:assert/strict'
import { interruptedStepMessages, interruptedWaitResult, toolResultOutput, INTERRUPTED_RESULTS } from '../../../ui/src/composables/interrupted-turn.ts'
import { interruptedStepMessages, interruptedWaitResult, interruptedWaitReminder, toolResultOutput, INTERRUPTED_RESULTS } from '../../../ui/src/composables/interrupted-turn.ts'

const waitCall = { toolCallId: 'w', toolName: 'wait_for_user_action', input: { message: 'Appuyez sur Enregistrer.', expecting: 'Clic sur Enregistrer' } }

Expand All @@ -18,7 +18,12 @@ test.describe('interruptedStepMessages', () => {
assert.deepEqual(assistant.content.map((p: any) => p.type), ['text', 'tool-call'])
assert.equal(tool.content[0].toolCallId, 'w')
assert.equal(tool.content[0].output.value, interruptedWaitResult('Clic sur Enregistrer'))
assert.match(tool.content[0].output.value, /\(Clic sur Enregistrer\).*declare wait_for_user_action again/)
assert.match(tool.content[0].output.value, /\(Clic sur Enregistrer\)/)
})

test('the turn the person starts carries the reminder to wait again', () => {
assert.match(interruptedWaitReminder('Clic sur Enregistrer'), /\(Clic sur Enregistrer\).*declare wait_for_user_action again/)
assert.match(interruptedWaitReminder(undefined), /\(an action on the page\)/)
})

test('any other pending call only says the person spoke', () => {
Expand Down
19 changes: 18 additions & 1 deletion tests/features/chat-subagent/stream-parts.unit.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { test } from 'playwright/test'
import assert from 'node:assert/strict'
import { applyStreamPart, WAIT_TOOL_NAME, type StreamScope, type ActivityPhase } from '../../../ui/src/composables/agent-stream-parts.ts'
import { applyStreamPart, WAIT_TOOL_NAME, repairWaitInput, type StreamScope, type ActivityPhase } from '../../../ui/src/composables/agent-stream-parts.ts'
import { WAIT_TOOL_NAME as HOST_WAIT_TOOL_NAME } from '../../../ui/src/composables/host-events.ts'

function makeScope () {
Expand Down Expand Up @@ -59,6 +59,23 @@ test.describe('applyStreamPart', () => {
assert.equal(scope.messages[0].content, 'Voilà.\n\nAppuyez sur Enregistrer.')
})

test('repairs the other arguments swallowed into the wait message', () => {
// The exact arguments a Haiku run sent (JSON-decoded once, as the tool receives them).
const raw = JSON.parse(String.raw`{"message":"Parfait ! J'ai créé les 11 champs :\\n\\n**Contact :** Email\\n\\nCliquez sur « Enregistrer ».\",\"expecting\":\"Enregistrement de la structure\",\"timeoutSeconds\":300"}`)
const repaired = repairWaitInput(raw)
assert.equal(repaired.message, "Parfait ! J'ai créé les 11 champs :\n\n**Contact :** Email\n\nCliquez sur « Enregistrer ».")
assert.equal(repaired.expecting, 'Enregistrement de la structure')
assert.equal(repaired.timeoutSeconds, 300)
const { scope } = makeScope()
applyStreamPart({ type: 'tool-call', toolCallId: 'w', toolName: WAIT_TOOL_NAME, input: raw } as any, scope)
assert.ok(!scope.messages[0].content.includes('expecting'), scope.messages[0].content)
})

test('leaves well-formed wait arguments alone', () => {
const input = { message: 'Appuyez sur "Enregistrer".', expecting: 'Clic', timeoutSeconds: 120 }
assert.deepEqual(repairWaitInput(input), input)
})

test('final tool-result settles the matching invocation; preliminary does not', () => {
const { scope } = makeScope()
applyStreamPart({ type: 'tool-call', toolCallId: 'c1', toolName: 't' }, scope)
Expand Down
Loading
Loading