Skip to content
Open
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
4 changes: 2 additions & 2 deletions docs/api/client-contract/pagination.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ Lifecycle:
6. **`messages-requeued {localIds}`** (SSE): an explicit Retry restored normal queue delivery; clear `deliveryState`.
7. **`message-cancelled {messageId, localId?}`** (SSE): remove the row (match either id).

**Queued semantics**: a user message is "queued" iff `invokedAt === null` **strictly**, `deliveryState !== 'indeterminate'`, and `status !== 'failed'`. An indeterminate row remains visible in the unresolved-delivery bar but is not eligible for automatic delivery. `undefined` means already-invoked (rows from pre-V8 hubs omit the field) — only rows explicitly carrying `null` belong in the queued bar. Server-side, rows sent without a `localId` are stamped invoked at insert and can never be queued.
**Queued semantics**: a user message is "queued" iff `invokedAt === null` **strictly**, `deliveryState !== 'indeterminate'`, and `status !== 'failed'`. An indeterminate row remains visible in the unresolved-delivery bar but is not eligible for automatic delivery. `undefined` means already-invoked (rows from pre-V8 hubs omit the field) — only rows explicitly carrying `null` belong in the queued bar. Server-side, a send without a `localId` gets a hub-minted one, so every REST send follows the queue/ack path; only agent-originated rows without a `localId` are stamped invoked at insert.

### Queued-state recovery

Expand All @@ -170,7 +170,7 @@ After a reconnect whose handshake said `resume: 'gap'` (an `ok` resume replayed
| Field | Type | Rules |
|---|---|---|
| `text` | string | Required (route also accepts empty text when `attachments` is non-empty). |
| `localId` | string | Optional but **required for `scheduledAt`**, and required in practice: without it the row is stamped invoked at insert (no queue/ack/cancel path). |
| `localId` | string | Optional but **required for `scheduledAt`**. When omitted the hub mints one; send your own to reconcile an optimistic row with its stored copy. |
| `attachments` | `AttachmentMetadata[]` | Optional. Not allowed with `scheduledAt`. |
| `scheduledAt` | epoch ms | Optional. Must be ≤ now + **7 days**; requires `localId`; no attachments; never `steer`. |
| `deliveryMode` | `'queue'` \| `'steer'` | Optional, default `queue`. `steer` is honored only for **Pi-flavor** sessions and never for scheduled sends — the hub silently normalizes everything else to `queue`, and deferred/replayed delivery (reconnect backfill, retries, scheduled release) always degrades `steer` to `queue`. |
Expand Down
2 changes: 1 addition & 1 deletion docs/api/native-companion-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ request scheduling, answer APIs or notification withdrawal.
| Allow | `POST /api/sessions/:id/permissions/:requestId/approve` |
| Deny | `POST /api/sessions/:id/permissions/:requestId/deny` |

`localId` is optional in the send-message body - an opaque client-generated id for reconciling the locally shown message with the server-echoed one.
`localId` is optional in the send-message body - an opaque client-generated id for reconciling the locally shown message with the server-echoed one. When omitted, the hub mints one.

The REST send endpoint stamps `sentFrom: 'webapp'` server-side for native
and web sends alike; clients do not send a `sentFrom` field. See
Expand Down
32 changes: 32 additions & 0 deletions hub/src/sync/messageService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1080,6 +1080,38 @@ describe('MessageService.sendMessage with scheduledAt', () => {
expect(msgs[0].scheduledAt).toBeNull()
})

it('mints a localId for senders that omit one so the agent echo dedupes', async () => {
const store = makeStore()
const session = makeSession(store, 'no-local-id')
const publisher = makePublisher()

const cliEmitted: Array<{ body: { message: { localId: string | null } } }> = []
const io = {
of: (ns: string) => ({
to: (_room: string) => ({
emit: (_event: string, data: unknown) => {
if (ns === '/cli') cliEmitted.push(data as typeof cliEmitted[number])
},
timeout: (_ms: number) => ({ emit: () => {} })
}),
adapter: { rooms: { get: () => undefined } }
})
} as unknown as Server

const service = new MessageService(store, io, publisher as any)
await service.sendMessage(session.id, { text: 'peer ping' })
await service.sendMessage(session.id, { text: 'peer ping' })

const [first, second] = cliEmitted.map((update) => update.body.message.localId)
expect(first).toEqual(expect.any(String))
expect(second).toEqual(expect.any(String))
expect(second).not.toBe(first)

// Shared Codex echoes the user turn back under the id it received.
store.messages.addMessage(session.id, { role: 'user', content: { type: 'text', text: 'peer ping' }, meta: { sentFrom: 'cli' } }, first!)
expect(store.messages.getMessages(session.id)).toHaveLength(2)
})

it('past scheduledAt (already mature): emits to /cli immediately', async () => {
const store = makeStore()
const session = makeSession(store, 'sched-past')
Expand Down
5 changes: 4 additions & 1 deletion hub/src/sync/messageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -881,10 +881,13 @@ export class MessageService {
}
}

// Every user message needs an identity: agents echo the turn back under
// it (Shared Codex clientUserMessageId), and (session, localId) is the
// only dedupe key. A sender that omits one gets a hub-minted id.
const inserted = this.store.addMessageForCurrentSession(
sessionId,
content,
payload.localId ?? undefined,
payload.localId ?? randomUUID(),
payload.scheduledAt ?? null
)
const actualSessionId = inserted.sessionId
Expand Down
Loading