Skip to content
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
5f2f58c
feat(harness-node): complete reactive turn-step wake
ytallo May 18, 2026
becd57a
feat(turn-orchestrator): add policy_function_id to config
ytallo May 18, 2026
be518c0
refactor(turn-orchestrator): keep policy_function_id at top level, ma…
ytallo May 18, 2026
77e5696
refactor(turn-orchestrator): thread policy_function_id to dispatchWit…
ytallo May 18, 2026
e4cd599
refactor(turn-orchestrator): make consultBefore policy_function_id re…
ytallo May 18, 2026
26e05a7
refactor(turn-orchestrator): call policy::check_permissions directly …
ytallo May 18, 2026
b005d6c
refactor(turn-orchestrator): align hook with approval-gate types, mat…
ytallo May 18, 2026
41af78e
refactor(approval-gate): wake turn::step via direct trigger, drop dur…
ytallo May 18, 2026
bff14b0
chore(approval-gate): refresh stale wake-path descriptions
ytallo May 18, 2026
595a3ab
refactor(approval-gate): delete gate-subscriber and before-hook topic…
ytallo May 18, 2026
311ac70
chore(approval-gate): update worker manifest description
ytallo May 18, 2026
bf2829a
chore(approval-gate): drop orphan policy_function_id from approval-ga…
ytallo May 18, 2026
ae77d5f
docs(approval-gate): describe direct policy consult + state-trigger wake
ytallo May 18, 2026
43684c7
docs(approval-gate): drop stale event row and deleted-type listing
ytallo May 18, 2026
e13d1e8
fix(turn-orchestrator): re-emit approval_requested when parking on ap…
ytallo May 19, 2026
859550e
feat(turn-orchestrator): emit turn_state_changed on every turn_state …
ytallo May 19, 2026
4c05c20
refactor(turn-orchestrator): align on-turn-state-changed error handli…
ytallo May 19, 2026
0b9437d
feat(console/web): add pending-approvals diff helper
ytallo May 19, 2026
87c004c
feat(console/web): add turn-state mirror reducer
ytallo May 19, 2026
06ebe02
feat(console/web): derive pending approvals from turn_state with relo…
ytallo May 19, 2026
14f8874
refactor(console/web): tighten turn_state_changed integration per review
ytallo May 19, 2026
d71166d
refactor: drop approval_requested signal event in favor of turn_state…
ytallo May 19, 2026
d1d59ce
refactor: drop approval_resolved signal event; denial flows through f…
ytallo May 19, 2026
7e701ed
docs(approval-gate): describe state-derived pending-approval signalling
ytallo May 19, 2026
3a30d5a
docs(turn-orchestrator): distinguish on-record-written from on-turn-s…
ytallo May 19, 2026
bc59d1b
chore: drop dead consultPolicy export, consolidate STEP_FN_ID to one …
ytallo May 19, 2026
1c315dd
fix(turn-orchestrator): close permissions bypass and reject same-stat…
ytallo May 19, 2026
e32b74d
refactor(turn-orchestrator): drop legacy approval_required parameter
ytallo May 19, 2026
949c4f2
fix: address Codex re-review P2 findings
ytallo May 19, 2026
18a1f5f
refactor(turn-orchestrator): remove outdated comments for clarity
ytallo May 19, 2026
adfd072
fix(turn-orchestrator): restore durable retry on direct turn::step fa…
ytallo May 19, 2026
a1a0aa0
fix(turn-orchestrator): close pre_approved trigger throw + cap after-…
ytallo May 19, 2026
8292ad0
feat(turn-orchestrator): add turn::get_state, remove direct state::ge…
ytallo May 19, 2026
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
2 changes: 0 additions & 2 deletions console/web/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,6 @@ export function ChatView({
let thoughtId: string | null = null
let thoughtBuffer = ''
let fcallId: string | null = null
// PR #150: same iii function_call_id can fire fcall-start twice
// (once for function_execution_start, once for approval_requested).
// Map UI message id → iii function_call_id so dedupe is reliable.
const fcallMap = new Map<string, string>()
let assistantId: string | null = null
Expand Down
63 changes: 63 additions & 0 deletions console/web/src/lib/backend/pending-approvals-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import {
diffPending,
type PendingApproval,
} from './pending-approvals-store'

describe('diffPending', () => {
it('returns all entries as added when previous list is empty', () => {
const next: PendingApproval[] = [
{ function_call_id: 'fc-1', function_id: 'shell::fs::write', args: { path: '/tmp/x' } },
]
expect(diffPending([], next)).toEqual({
added: next,
removed: [],
})
})

it('returns all entries as removed when next list is empty', () => {
const prev: PendingApproval[] = [
{ function_call_id: 'fc-1', function_id: 'shell::fs::write', args: { path: '/tmp/x' } },
]
expect(diffPending(prev, [])).toEqual({
added: [],
removed: prev,
})
})

it('detects a single addition', () => {
const prev: PendingApproval[] = [
{ function_call_id: 'fc-1', function_id: 'shell::fs::write', args: {} },
]
const next: PendingApproval[] = [
...prev,
{ function_call_id: 'fc-2', function_id: 'shell::shell', args: {} },
]
expect(diffPending(prev, next)).toEqual({
added: [next[1]],
removed: [],
})
})

it('detects a single removal', () => {
const prev: PendingApproval[] = [
{ function_call_id: 'fc-1', function_id: 'shell::fs::write', args: {} },
{ function_call_id: 'fc-2', function_id: 'shell::shell', args: {} },
]
const next: PendingApproval[] = [prev[0]!]
expect(diffPending(prev, next)).toEqual({
added: [],
removed: [prev[1]],
})
})

it('is keyed by function_call_id (treats objects with same id as same entry)', () => {
const prev: PendingApproval[] = [
{ function_call_id: 'fc-1', function_id: 'shell::shell', args: { command: 'ls' } },
]
const next: PendingApproval[] = [
{ function_call_id: 'fc-1', function_id: 'shell::shell', args: { command: 'rm' } },
]
expect(diffPending(prev, next)).toEqual({ added: [], removed: [] })
})
})
33 changes: 33 additions & 0 deletions console/web/src/lib/backend/pending-approvals-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Pure helpers for diffing two pending-approval lists by function_call_id.
* Used by the translate layer to derive synthetic `fcall-start` /
* `fcall-end` events from successive `turn_state_changed` updates.
*
* No state lives here — the per-session list lives in the closure of
* `realStream` (one chat = one mirror) and is passed in/out as plain
* arrays. Keeping this file framework-free makes it unit-testable in
* isolation.
*/

export interface PendingApproval {
function_call_id: string
function_id: string
args: unknown
}

export interface PendingDiff {
added: PendingApproval[]
removed: PendingApproval[]
}

export function diffPending(
prev: PendingApproval[],
next: PendingApproval[],
): PendingDiff {
const prevIds = new Set(prev.map((e) => e.function_call_id))
const nextIds = new Set(next.map((e) => e.function_call_id))
return {
added: next.filter((e) => !prevIds.has(e.function_call_id)),
removed: prev.filter((e) => !nextIds.has(e.function_call_id)),
}
}
43 changes: 41 additions & 2 deletions console/web/src/lib/backend/real.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import { getIiiClient } from '@/lib/iii-client'
import { newMessageId } from '@/lib/session-id'
import type { Mode, ModelId } from '@/types/chat'
import type { AgentEvent, SessionEventEnvelope } from '@/types/iii-agent-event'
import { translateAgentEvent } from './translate'
import { createTurnStateTranslator, translateAgentEvent } from './translate'
import type { ChatBackend, ChatStreamOptions, StreamEvent } from './types'

interface RunParams {
Expand Down Expand Up @@ -112,6 +112,41 @@ async function* realStream(
})
subscribed = true

const turnStateTranslator = createTurnStateTranslator()

// Recover any pending approvals after a reload — the orchestrator only
// re-broadcasts turn_state on the next write, so without this we'd never
// surface a modal for an approval that was already parked when the page
// loaded.
client
.call<Record<string, unknown> | null>('state::get', {
scope: 'agent',
key: `session/${sessionId}/turn_state`,
})
.then((record) => {
if (!record) return
// Push (not unshift): the persisted state is always older than the
// freshly-subscribed live stream. If live turn_state events already
// arrived, the recovery should be processed after them so the
// translator's mirror reflects the freshest server state. If no live
// events have arrived yet, the recovery is the first event in the
// queue and is processed in natural order.
queue.push({
type: 'turn_state_changed',
event_type: 'state:created',
new_value: record,
})
wake()
})
.catch((err) => {
if (import.meta.env.DEV) {
console.warn(
'[real-backend] state::get turn_state recovery failed',
err,
)
}
})
Comment on lines +117 to +134

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.

i think we shouldn't call state directly from the frontend, it should be abstracted on the harness logic

@ytallo ytallo May 19, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good call. Added turn::get_state (harness-node/src/turn-orchestrator/get-state.ts) that wraps persistence.loadRecord, and the console now calls it instead of state::get for reload recovery. Schema and key layout stay inside the orchestrator. Pushed in 8292ad0.


const { provider, model: modelId } = resolveRunParams(model)

// Route the turn-orchestrator kick-off through `harness::call` so the
Expand Down Expand Up @@ -178,7 +213,11 @@ async function* realStream(
if (kickoffError) continue
const event = queue.shift()
if (!event) continue
for (const streamEvent of translateAgentEvent(event, sessionId)) {
const streamEvents =
event.type === 'turn_state_changed'
? turnStateTranslator(event, sessionId)
: translateAgentEvent(event, sessionId)
for (const streamEvent of streamEvents) {
yield streamEvent
}
if (event.type === 'agent_end') return
Expand Down
119 changes: 119 additions & 0 deletions console/web/src/lib/backend/translate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest'
import type { AgentEvent } from '@/types/iii-agent-event'
import { createTurnStateTranslator } from './translate'

describe('createTurnStateTranslator', () => {
it('emits fcall-start { pendingApproval: true } when a new entry appears', () => {
const translate = createTurnStateTranslator()
const event: AgentEvent = {
type: 'turn_state_changed',
event_type: 'state:updated',
new_value: {
state: 'function_awaiting_approval',
awaiting_approval: [
{
function_call_id: 'fc-1',
function_id: 'shell::fs::write',
args: { path: '/tmp/x' },
},
],
},
old_value: {
state: 'function_execute',
awaiting_approval: [],
},
}
expect(translate(event, 'sess-a')).toEqual([
{
kind: 'fcall-start',
functionId: 'shell::fs::write',
input: { path: '/tmp/x' },
pendingApproval: true,
functionCallId: 'fc-1',
sessionId: 'sess-a',
},
])
})

it('emits nothing when the awaiting_approval list is unchanged', () => {
const translate = createTurnStateTranslator()
const same = {
state: 'function_awaiting_approval',
awaiting_approval: [
{ function_call_id: 'fc-1', function_id: 'shell::shell', args: {} },
],
}
translate(
{
type: 'turn_state_changed',
event_type: 'state:created',
new_value: same,
},
'sess-a',
)
expect(
translate(
{
type: 'turn_state_changed',
event_type: 'state:updated',
new_value: same,
old_value: same,
},
'sess-a',
),
).toEqual([])
})

it('emits nothing when state leaves function_awaiting_approval (the orchestrator emits the matching function_execution_end)', () => {
const translate = createTurnStateTranslator()
translate(
{
type: 'turn_state_changed',
event_type: 'state:created',
new_value: {
state: 'function_awaiting_approval',
awaiting_approval: [
{ function_call_id: 'fc-1', function_id: 'shell::shell', args: {} },
],
},
},
'sess-a',
)
expect(
translate(
{
type: 'turn_state_changed',
event_type: 'state:updated',
new_value: { state: 'function_execute', awaiting_approval: [] },
old_value: {
state: 'function_awaiting_approval',
awaiting_approval: [
{ function_call_id: 'fc-1', function_id: 'shell::shell', args: {} },
],
},
},
'sess-a',
),
).toEqual([])
})

it('partitions mirrors by sessionId so two chats do not interfere', () => {
const translate = createTurnStateTranslator()
const pending = {
state: 'function_awaiting_approval',
awaiting_approval: [
{ function_call_id: 'fc-1', function_id: 'shell::shell', args: {} },
],
}
translate(
{ type: 'turn_state_changed', event_type: 'state:created', new_value: pending },
'sess-a',
)
expect(
translate(
{ type: 'turn_state_changed', event_type: 'state:created', new_value: pending },
'sess-b',
),
).toHaveLength(1)
})
})
Loading
Loading