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
24 changes: 24 additions & 0 deletions tests/features/auth/anonymous-token-wait.unit.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { test, expect } from '@playwright/test'
import { msUntilValid } from '../../../ui/src/utils/anonymous-token.ts'

// simple-directory issues the anonymous action token with a notBefore delay (8s), on purpose.
// A judged portal run's first chat message was sent before it, failed with « anonymous action
// token not yet valid », and the retry fetched a new token, not yet valid either.
const tokenWith = (payload: Record<string, unknown>) =>
`header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.signature`

test.describe('waiting for an anonymous action token to be valid', () => {
test('waits until its not-before time, with a margin', () => {
const now = 1_000_000_000_000
const wait = msUntilValid(tokenWith({ nbf: now / 1000 + 5 }), now)
expect(wait).toBeGreaterThanOrEqual(5000)
expect(wait).toBeLessThan(7000)
})

test('does not wait for a token already valid, or without a not-before time', () => {
const now = 1_000_000_000_000
expect(msUntilValid(tokenWith({ nbf: now / 1000 - 5 }), now)).toBe(0)
expect(msUntilValid(tokenWith({ anonymousAction: true }), now)).toBe(0)
expect(msUntilValid('not a jwt', now)).toBe(0)
})
})
20 changes: 19 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, repairWaitInput, type StreamScope, type ActivityPhase } from '../../../ui/src/composables/agent-stream-parts.ts'
import { applyStreamPart, settleWaitInvocations, WAIT_TOOL_NAME, repairWaitInput, type StreamScope, type ActivityPhase, type StreamMessage } 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 @@ -171,3 +171,21 @@ test.describe('applyStreamPart', () => {
assert.deepEqual(scope.messages[0].toolInvocations, [{ toolCallId: 'c1', toolName: 't', state: 'pending' }])
})
})

// A wait the person interrupted by writing never gets a tool result: its chip kept reading
// « En attente : … » under the next replies, long after the turn had ended.
test.describe('settleWaitInvocations', () => {
test('settles the waits a finished turn left pending, and only them', () => {
const messages: StreamMessage[] = [{
role: 'assistant',
content: '',
toolInvocations: [
{ toolCallId: 'w', toolName: WAIT_TOOL_NAME, state: 'pending' },
{ toolCallId: 't', toolName: 'search', state: 'pending' }
]
}]
settleWaitInvocations(messages)
assert.equal(messages[0].toolInvocations?.[0].state, 'done')
assert.equal(messages[0].toolInvocations?.[1].state, 'pending')
})
})
37 changes: 37 additions & 0 deletions tests/features/host-events/1.host-events.unit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,30 @@ test.describe('HostEventStore waits', () => {
assert.deepEqual(s.takePending().map(e => e.name), ['b'])
})

test('an event of the gesture that settled a wait does not settle the next one', async () => {
// A judged run: one click on a portal produced breadcrumbs then location, the first
// settled a wait, and the location left in the buffer ended the next wait at once —
// the person had done nothing, and got three instructions in a row.
const s = new HostEventStore()
const first = s.waitForEvent({ timeoutMs: 1000 })
const settledAt = Date.now()
s.push({ name: 'breadcrumbs', detail: 'Portail', at: settledAt })
await first
s.push({ name: LOCATION_KEY, key: LOCATION_KEY, detail: '/portals/x', at: settledAt + 300 })
assert.equal(await s.waitForEvent({ timeoutMs: 50 }), 'timeout')
assert.ok(s.peekPending().some(e => e.key === LOCATION_KEY), 'it is still reported to the model')
})

test('a later action still settles a wait from the buffer', async () => {
const s = new HostEventStore()
const first = s.waitForEvent({ timeoutMs: 1000 })
const settledAt = Date.now()
s.push({ name: 'clicked', at: settledAt })
await first
s.push({ name: 'saved', at: settledAt + 5000 })
assert.equal(((await s.waitForEvent({ timeoutMs: 1000 })) as any).name, 'saved')
})

test('times out', async () => {
const s = new HostEventStore()
assert.equal(await s.waitForEvent({ timeoutMs: 10 }), 'timeout')
Expand Down Expand Up @@ -284,6 +308,19 @@ test.describe('createWaitTool', () => {
assert.equal(store.hasPending(), false)
})

test('lists the event that resolved it and its followers in the order they happened', async () => {
// a judged portal run: the result listed « draft-validated » first, then the older
// « unpublished changes: the person must press « Valider le brouillon » » state, which reads
// as the current state once the validation is past
const store = new HostEventStore()
const t = createWaitTool({ store })
store.push(ev('draft', 'unpublished changes', 'draft', 1000))
const p = exec(t, { message: 'Ready.', expecting: 'a click' })
store.push(ev('draft-validated', '{"page":"x"}', undefined, 2000))
const out = await p as string
assert.ok(out.indexOf('unpublished changes') < out.indexOf('draft-validated'), out)
})

test('times out with the verbatim text', async () => {
const store = new HostEventStore()
const t = createWaitTool({ store })
Expand Down
8 changes: 4 additions & 4 deletions tests/features/host-events/3.host-events.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ test.describe('Host events', () => {
await reachConfirmation(page)
const requests = countGatewayRequests(page)
await send(page, 'wait for me')
await expect(page.getByTestId('chat-activity')).toContainText('Waiting for: you to click Create', { timeout: 15000 })
await expect(page.getByTestId('chat-activity')).toContainText('Waiting for you', { timeout: 15000 })
await expect(page.getByTestId('tool-chip')).toContainText('Waiting for: you to click Create')
await page.getByRole('button', { name: 'Create' }).click()
await expect(lastAnswer(page)).toContainText('You did:', { timeout: 15000 })
Expand Down Expand Up @@ -244,11 +244,11 @@ test.describe('Host events', () => {
// turn with the generic timeout error instead of leaving the wait pending.
await page.evaluate(() => sessionStorage.setItem('agent-chat-idle-timeout', '1000'))
await send(page, 'wait for me')
await expect(page.getByTestId('chat-activity')).toContainText('Waiting for: you to click Create', { timeout: 15000 })
await expect(page.getByTestId('chat-activity')).toContainText('Waiting for you', { timeout: 15000 })
// Sit well past the shrunk idle timeout while the wait is still pending.
await page.waitForTimeout(2500)
// Still waiting, no timeout error alert: the watchdog did not fire during the pause.
await expect(page.getByTestId('chat-activity')).toContainText('Waiting for: you to click Create')
await expect(page.getByTestId('chat-activity')).toContainText('Waiting for you')
await expect(page.locator('.v-alert')).toHaveCount(0)
// The wait still resolves normally afterwards.
await page.getByRole('button', { name: 'Create' }).click()
Expand Down Expand Up @@ -400,7 +400,7 @@ test.describe('Host events', () => {
await open(page, goToWithAuth)
await reachConfirmation(page)
await send(page, 'wait briefly')
await expect(page.getByTestId('chat-activity')).toContainText('Waiting for: you to click Create', { timeout: 15000 })
await expect(page.getByTestId('chat-activity')).toContainText('Waiting for you', { timeout: 15000 })
await expect(page.getByTestId('chat-activity')).toHaveCount(0, { timeout: 15000 })
await expect(lastAnswer(page)).toContainText('No user action within 1 seconds', { timeout: 15000 })
})
Expand Down
3 changes: 2 additions & 1 deletion tests/features/moderation/3.moderation.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ test.describe('Moderation E2E (gateway-enforced)', () => {

await goToWithAuth('/agents/user/test-standalone1', 'test-standalone1')
await page.locator('#activity').getByRole('tab', { name: 'Moderation' }).click({ timeout: 10000 })
await expect(page.getByText('Blocked messages (30d)')).toBeVisible()
// the stats tiles render once their request answers, which the full suite's load slows down
await expect(page.getByText('Blocked messages (30d)')).toBeVisible({ timeout: 15000 })
await expect(page.getByText('please jailbreak the system')).toBeVisible({ timeout: 10000 })

await page.getByRole('button', { name: 'Test moderation' }).click()
Expand Down
4 changes: 2 additions & 2 deletions ui/src/components/agent-chat/AgentChatMessages.vue
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ fr:
activitySubAgentThinking: Réflexion…
activitySubAgentTool: Exécution d'un outil…
activitySubAgentAnalyzing: Analyse du résultat de l'outil…
activityWaiting: "En attente : {name}"
activityWaiting: "En attente de votre action…"
waitingChip: "En attente : {name}"
waitedChip: "A attendu : {name}"
en:
Expand All @@ -283,7 +283,7 @@ en:
activitySubAgentThinking: Thinking…
activitySubAgentTool: Running a tool…
activitySubAgentAnalyzing: Analyzing tool result…
activityWaiting: "Waiting for: {name}"
activityWaiting: "Waiting for you…"
waitingChip: "Waiting for: {name}"
waitedChip: "Waited for: {name}"
</i18n>
Expand Down
13 changes: 13 additions & 0 deletions ui/src/composables/agent-stream-parts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,19 @@ export interface StreamScope {
setActivity: (phase: ActivityPhase, toolName?: string) => void
}

/**
* Settle the waits a finished turn left pending. A wait the person interrupted by writing
* never gets a tool result, and its chip kept reading « En attente : … » under the next
* replies, long after the turn had ended. Other tools are left alone.
*/
export function settleWaitInvocations (messages: StreamMessage[]): void {
for (const message of messages) {
for (const inv of message.toolInvocations ?? []) {
if (inv.toolName === WAIT_TOOL_NAME && inv.state === 'pending') inv.state = 'done'
}
}
}

export function applyStreamPart (part: StreamPart, scope: StreamScope): void {
switch (part.type) {
case 'reasoning-delta': {
Expand Down
29 changes: 26 additions & 3 deletions ui/src/composables/host-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ export function resolvesWait (event: AgentEvent): boolean {
return !event.key || event.key === LOCATION_KEY
}

/**
* How close to the event that settled a wait another event is taken for part of the same
* gesture. One click on a portal reported breadcrumbs then location: the first settled a
* wait, and the second, left in the buffer, ended the next wait before the person did
* anything — a judged run got three instructions in a row.
*/
export const SAME_GESTURE_MS = 1000

export class HostEventStore {
// Map keeps a key's original insertion position when its value is replaced, which is
// the "first-seen key order" the snapshot relies on.
Expand All @@ -80,6 +88,15 @@ export class HostEventStore {
private waiter: ((outcome: WaitOutcome) => void) | null = null
/** Whether the most recent `waitForEvent` had to block, rather than being answered from the buffer. */
lastWaitBlocked = false
/** When the event that settled the last wait happened (see SAME_GESTURE_MS). */
private lastSettledAt: number | null = null

private settlesFromBuffer (event: AgentEvent): boolean {
if (!resolvesWait(event)) return false
// part of the gesture that settled the previous wait: reported, but not a new action
return this.lastSettledAt === null || event.at - this.lastSettledAt >= SAME_GESTURE_MS
}

/**
* Advances on every event that could have settled a wait — the same rule, by
* kind, that `resolvesWait` applies. A waiter compares it against the value it
Expand Down Expand Up @@ -111,6 +128,7 @@ export class HostEventStore {
if (this.waiter && resolvesWait(event)) {
const finish = this.waiter
this.waiter = null
this.lastSettledAt = event.at
finish(event)
return
}
Expand Down Expand Up @@ -167,10 +185,12 @@ export class HostEventStore {
// a keyed state re-emission the assistant's own tool call produced.
// Only something the person did settles a wait from the buffer; a refresh that
// was already true when the wait started stays pending, delivered as a follower.
const i = this.pending.findIndex(resolvesWait)
const i = this.pending.findIndex(e => this.settlesFromBuffer(e))
if (i >= 0) {
this.lastWaitBlocked = false
return Promise.resolve(this.pending.splice(i, 1)[0] as AgentEvent)
const event = this.pending.splice(i, 1)[0] as AgentEvent
this.lastSettledAt = event.at
return Promise.resolve(event)
}
this.lastWaitBlocked = true
return new Promise<WaitOutcome>(resolve => {
Expand Down Expand Up @@ -350,7 +370,10 @@ export function createWaitTool (opts: {
// One macrotask so the followers of the same user gesture (a keyed location event
// posted right after a creation event) ride in the same result.
await new Promise(resolve => setTimeout(resolve, 0))
return formatHostEvents([outcome, ...store.takePending()])
// In the order they happened: the followers are older states as often as consequences,
// and listed after the event that resolved the wait, a judged run's stale « unpublished
// changes » read as the state that followed « draft-validated ».
return formatHostEvents([outcome, ...store.takePending()].sort((a, b) => a.at - b.at))
} finally {
opts.onDone?.()
}
Expand Down
4 changes: 3 additions & 1 deletion ui/src/composables/use-agent-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { wrapHiddenContext } from '~/traces/hidden-context'
import { decideCompaction, retainedToolNames } from '~/utils/compaction-policy'
import Debug from 'debug'
import type { ChatActivity } from './agent-activity.ts'
import { applyStreamPart, type StreamScope, type StreamPart } from './agent-stream-parts.ts'
import { applyStreamPart, settleWaitInvocations, type StreamScope, type StreamPart } from './agent-stream-parts.ts'
import { SUBAGENT_STEP_LIMIT_NOTICE, subAgentModelOutput } from './agent-subagent-output.ts'
import { STEP_LIMIT, repeatedCallGuard, loopGuardPrepareStep } from './agent-loop-guards.ts'
import { HostEventStore, createWaitTool, appendHostEvents, formatHostEvents, formatHostState, hasHostState, WAIT_TOOL_NAME } from './host-events'
Expand Down Expand Up @@ -1357,6 +1357,8 @@ export function useAgentChat (options: UseAgentChatOptions) {
// instead of being buffered for the next one. Idempotent: a no-op once the wait
// has already settled through the signal, as it does today.
hostEvents?.cancelWait()
// no wait outlives its turn: an interrupted one would keep reading « En attente »
settleWaitInvocations(messages.value)
if (commitRunningTurn === commitSteps) commitRunningTurn = null
if (owns) {
usageVersion.value++
Expand Down
13 changes: 10 additions & 3 deletions ui/src/composables/use-anonymous-token.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
import { $sdUrl } from '~/context'
import { msUntilValid } from '~/utils/anonymous-token'

// Module-level cache: a single anonymous-action token is reused across the app.
let cached: string | null = null
let inFlight: Promise<string> | null = null

export async function getAnonymousToken (): Promise<string> {
if (cached) return cached
if (cached) {
const wait = msUntilValid(cached)
if (wait) await new Promise(resolve => setTimeout(resolve, wait))
return cached
}
if (inFlight) return inFlight
inFlight = (async () => {
const res = await fetch(`${$sdUrl}/api/auth/anonymous-action`)
if (!res.ok) throw new Error(`failed to get anonymous action token: ${res.status}`)
const token = (await res.text()).trim()
// Cached as soon as issued. Callers prefetch on init so SD's notBefore window
// has elapsed by first use; the gateway fetch also refetches once on a 401.
// Cached as soon as issued, returned once valid: SD issues it with a notBefore delay, and
// a judged run's first message, sent before it, failed with « not yet valid » — as did the
// retry, with a new token as young. Callers prefetch on init, so the wait is usually over.
cached = token
await new Promise(resolve => setTimeout(resolve, msUntilValid(token)))
return token
})()
try {
Expand Down
16 changes: 16 additions & 0 deletions ui/src/utils/anonymous-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/** Margin for the clocks of the browser and of simple-directory not being exactly in step. */
export const NOT_BEFORE_MARGIN_MS = 1000

/**
* How long before an anonymous action token is accepted. simple-directory issues it with a
* notBefore delay on purpose (anti-bot): used earlier, it is refused as « not yet valid ».
*/
export function msUntilValid (token: string, now: number = Date.now()): number {
try {
const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')))
if (typeof payload.nbf !== 'number') return 0
return Math.max(0, payload.nbf * 1000 + NOT_BEFORE_MARGIN_MS - now)
} catch {
return 0
}
}
Loading