mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
fix(native-chat): record the account each chat runtime launched on
record.accountHome names only the founding account, so after a switch nothing said which account a usage limit hit. Owner proof now records the account each runtime fence launched under, every host cut carries its turn's fence, and the hold reads the cut's account from that pair. This replaces the sweep's saved copy, which a fast child exit could miss. Spending the new account showed before a switch now holds the chat until its own reset; only a reading taken after the switch releases it.
This commit is contained in:
+1
@@ -102,6 +102,7 @@ export async function acquireOwner(
|
||||
sessionId: record.sessionId,
|
||||
fence,
|
||||
...(acquired.link ? { link: acquired.link } : {}),
|
||||
...(acquired.accountHome ? { launchedAccountHome: acquired.accountHome } : {}),
|
||||
...(acquired.accountRecipe
|
||||
? {
|
||||
accountAcquisition: {
|
||||
|
||||
+24
-5
@@ -75,12 +75,13 @@ async function heldChat() {
|
||||
launchUnder: (next: UsageLimitAccountScope) => {
|
||||
attributed = next
|
||||
},
|
||||
/** The cut ran on account A; a start of this chat would now launch on `path`. */
|
||||
/** The cut's runtime launched on account A; a start of this chat would now launch on `path`. */
|
||||
switchAccount: async (path: string) => {
|
||||
await current.store.recordUsageLimitCutAccount(SESSION, {
|
||||
turnItemId: cut.turnItemId,
|
||||
accountHome: { variable: 'CODEX_HOME', path: '/homes/a' }
|
||||
})
|
||||
const fence = journal.itemFence(cut.turnItemId)!
|
||||
await current.store.transitionHandoff(SESSION, (record) => ({
|
||||
...record,
|
||||
launchedAccount: { fence, accountHome: { variable: 'CODEX_HOME', path: '/homes/a' } }
|
||||
}))
|
||||
current.host.deps.launchAccountHome = async () => ({ variable: 'CODEX_HOME', path })
|
||||
},
|
||||
setEnabled: (next: boolean) => {
|
||||
@@ -151,6 +152,24 @@ it('continues before the cut resets once the chat would start on another account
|
||||
await settleOne(await chat.pass(start + 20_000, (now) => weekly(0, null, now)))
|
||||
})
|
||||
|
||||
it('keeps the new account spent from a reading before the switch until a later one shows room', async () => {
|
||||
const chat = await heldChat()
|
||||
const start = chat.cut.resetsAt - 60_000
|
||||
const newAccountReset = start + 2 * HOUR
|
||||
await chat.switchAccount('/homes/b')
|
||||
expect(await chat.pass(start + 5_000, () => weekly(100, newAccountReset, start + 1_000))).toEqual(
|
||||
[]
|
||||
)
|
||||
expect(chat.state()).toMatchObject({ continueAt: newAccountReset })
|
||||
expect(await chat.pass(start + 10 * 60_000, () => weekly(100, newAccountReset, start))).toEqual(
|
||||
[]
|
||||
)
|
||||
const room = start + 20 * 60_000
|
||||
expect(await chat.pass(room, (now) => weekly(0, null, now))).toEqual([])
|
||||
expect(chat.state()).toMatchObject({ continueAt: room })
|
||||
await settleOne(await chat.pass(room + 15_000, (now) => weekly(0, null, now)))
|
||||
})
|
||||
|
||||
it('retires a hold when the launch moves to billing the reading does not describe', async () => {
|
||||
const chat = await heldChat()
|
||||
const weeklyReset = chat.cut.resetsAt + 3 * HOUR
|
||||
|
||||
-1
@@ -119,7 +119,6 @@ it('continues every chat whose limit reset in the same pass', async () => {
|
||||
sessions: new Map(ids.map((id) => [id, {}])),
|
||||
readSavedCut: () => null,
|
||||
readLoadedCut: cut,
|
||||
cutAccountHome: async () => null,
|
||||
serialize: (_id, run) => run(),
|
||||
// Each send is answered as superseded, which spends no budget; only who was sent matters here.
|
||||
send: async (params) => {
|
||||
|
||||
+2
-1
@@ -125,7 +125,8 @@ export class StructuredAgentSessionJournalProjections {
|
||||
contextId,
|
||||
usageLimitCut: hostUsageLimitCut(
|
||||
snapshot,
|
||||
latestNativeChatUsageLimitCut(snapshot.items, snapshot.submissions, journal.itemBody)
|
||||
latestNativeChatUsageLimitCut(snapshot.items, snapshot.submissions, journal.itemBody),
|
||||
journal.itemFence
|
||||
),
|
||||
firstInputSubmissionKey,
|
||||
submissionCount: journal.inputOrder.inputCount(),
|
||||
|
||||
-12
@@ -14,18 +14,6 @@ export function start(
|
||||
sessions: context.sessions,
|
||||
readLoadedCut: delivery.readUsageLimitCut,
|
||||
readSavedCut: (id) => readSavedUsageLimitCut(context.deps.journalDatabase.db, id),
|
||||
cutAccountHome: async (id, turnItemId) => {
|
||||
// Only the child that ran the cut's turn knows its account, so save it while that child is live.
|
||||
const session = context.sessions.get(id)
|
||||
const child = session?.child
|
||||
if (child?.accountHome && session?.journal.itemFence(turnItemId) === child.fence) {
|
||||
const accountHome = child.accountHome
|
||||
await context.deps.store.recordUsageLimitCutAccount(id, { turnItemId, accountHome })
|
||||
return accountHome
|
||||
}
|
||||
const saved = context.deps.store.getRecord(id)?.usageLimitCutAccount
|
||||
return saved?.turnItemId === turnItemId ? saved.accountHome : null
|
||||
},
|
||||
serialize: context.serialize,
|
||||
send: (params) => host.send({ callerKey: 'trusted-local:usage-limit-continue' }, params),
|
||||
publish: delivery.publishStatus,
|
||||
|
||||
+8
-6
@@ -195,10 +195,8 @@ export class UsageLimitAccountHolds {
|
||||
): UsageLimitAccountVerdict {
|
||||
// The account the chat moved to never hit this cut: from the move on, only its reading holds.
|
||||
const moved = this.movedSince.get(id)
|
||||
const cut =
|
||||
moved?.cut === identity(limited)
|
||||
? { ...limited, resetsAt: Math.min(limited.resetsAt, moved.at) }
|
||||
: limited
|
||||
const movedCut = moved?.cut === identity(limited)
|
||||
const cut = movedCut ? { ...limited, resetsAt: Math.min(limited.resetsAt, moved.at) } : limited
|
||||
const scope = this.scopes.get(id)
|
||||
let hold = this.holds.get(id)
|
||||
// Nothing attributed and nothing held: the cut decides, and nothing is kept for it.
|
||||
@@ -232,10 +230,11 @@ export class UsageLimitAccountHolds {
|
||||
(hold.unknownReset && hold.spentSeenAt) || cut.resetsAt,
|
||||
typeof covering === 'number' ? covering : cut.resetsAt
|
||||
)
|
||||
// Spending the new account showed before the move still holds; only a later reading releases it.
|
||||
if (
|
||||
account &&
|
||||
reading &&
|
||||
fresh(cut.resetsAt) &&
|
||||
fresh(movedCut ? Number.NEGATIVE_INFINITY : cut.resetsAt) &&
|
||||
now - anchor <= NATIVE_CHAT_USAGE_LIMIT_MAX_OVERDUE_MS
|
||||
) {
|
||||
if (typeof covering === 'number') {
|
||||
@@ -250,7 +249,10 @@ export class UsageLimitAccountHolds {
|
||||
hold.heldUntil = Math.max(hold.heldUntil ?? spentUntil, spentUntil)
|
||||
}
|
||||
hold.spentSeenAt = Math.max(hold.spentSeenAt ?? reading.updatedAt, reading.updatedAt)
|
||||
} else if (reading.updatedAt > (hold.spentSeenAt ?? Number.NEGATIVE_INFINITY)) {
|
||||
} else if (
|
||||
fresh(cut.resetsAt) &&
|
||||
reading.updatedAt > (hold.spentSeenAt ?? Number.NEGATIVE_INFINITY)
|
||||
) {
|
||||
// Room newer than any spending seen releases the hold at that reading, so it counts from now.
|
||||
if (hold.unknownReset || (hold.heldUntil ?? Number.NEGATIVE_INFINITY) > reading.updatedAt) {
|
||||
hold.heldUntil = reading.updatedAt
|
||||
|
||||
+21
-14
@@ -11,6 +11,7 @@ import { readAgentJournalTurn } from '../../../../shared/agent-session/lifecycle
|
||||
import { restoreJournal } from '../../agent-session-journal/replay/journal-open'
|
||||
import { renderJournalStatus } from '../../agent-session-journal/projection/journal-public-read'
|
||||
import { journalInputOrder } from '../../agent-session-journal/input-order/journal-input-order'
|
||||
import { journalItemExecutionFence } from '../../agent-session-journal/projection/journal-item-provenance'
|
||||
import type Database from '../../../sqlite/sync-database'
|
||||
import {
|
||||
latestNativeChatUsageLimitCut,
|
||||
@@ -168,18 +169,25 @@ export function usageLimitContinueChain(
|
||||
return chain
|
||||
}
|
||||
|
||||
/** A cut as the host's sweep reads it: with the chain that led to it. */
|
||||
export type HostUsageLimitCut = NativeChatUsageLimitCut & { chain: number }
|
||||
/** A cut as the host's sweep reads it: with the chain that led to it and the runtime fence that
|
||||
* ran its turn, which names the account it hit. */
|
||||
export type HostUsageLimitCut = NativeChatUsageLimitCut & { chain: number; fence?: number }
|
||||
|
||||
export function hostUsageLimitCut(
|
||||
snapshot: {
|
||||
items: readonly AgentJournalRenderItem[]
|
||||
submissions: readonly ChainSubmission[]
|
||||
},
|
||||
cut: NativeChatUsageLimitCut | null
|
||||
cut: NativeChatUsageLimitCut | null,
|
||||
fenceOf: (itemId: string) => number | undefined
|
||||
): HostUsageLimitCut | null {
|
||||
const fence = cut ? fenceOf(cut.turnItemId) : undefined
|
||||
return cut
|
||||
? { ...cut, chain: usageLimitContinueChain(snapshot.items, snapshot.submissions, cut) }
|
||||
? {
|
||||
...cut,
|
||||
chain: usageLimitContinueChain(snapshot.items, snapshot.submissions, cut),
|
||||
...(fence === undefined ? {} : { fence })
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -189,14 +197,13 @@ export function readSavedUsageLimitCut(
|
||||
id: string
|
||||
): HostUsageLimitCut | null {
|
||||
const load = restoreJournal(db, id)
|
||||
const snapshot =
|
||||
load && !load.newer && !load.damage
|
||||
? renderJournalStatus(load.state, journalInputOrder(load.state))
|
||||
: null
|
||||
return snapshot
|
||||
? hostUsageLimitCut(
|
||||
snapshot,
|
||||
latestNativeChatUsageLimitCut(snapshot.items, snapshot.submissions)
|
||||
)
|
||||
: null
|
||||
if (!load || load.newer || load.damage) {
|
||||
return null
|
||||
}
|
||||
const snapshot = renderJournalStatus(load.state, journalInputOrder(load.state))
|
||||
return hostUsageLimitCut(
|
||||
snapshot,
|
||||
latestNativeChatUsageLimitCut(snapshot.items, snapshot.submissions),
|
||||
(itemId) => journalItemExecutionFence(load.state, itemId)
|
||||
)
|
||||
}
|
||||
|
||||
+5
-6
@@ -16,7 +16,6 @@ import type { sendStructuredAgentSessionTurn } from '../operations/structured-ag
|
||||
import { RestartContinuationSupersededError } from './structured-agent-session-restart-continuation'
|
||||
import { UsageLimitAccountHolds } from './structured-agent-session-usage-limit-account-hold'
|
||||
import { UsageLimitColdCuts } from './structured-agent-session-usage-limit-cold-cuts'
|
||||
import type { AgentSessionAccountHome } from '../../../../shared/agent-session/lifecycle/agent-session-account-home'
|
||||
|
||||
type Cut = HostUsageLimitCut & { resetsAt: number; turnEndedAt: number }
|
||||
type Attempt = { identity: string; count: number; at: number; unconfirmed?: boolean }
|
||||
@@ -39,8 +38,6 @@ type Deps = {
|
||||
params: Parameters<typeof sendStructuredAgentSessionTurn>[2]
|
||||
) => ReturnType<typeof sendStructuredAgentSessionTurn>
|
||||
readLoadedCut: (id: string) => HostUsageLimitCut | null
|
||||
/** The account the cut's turn ran on, when known. */
|
||||
cutAccountHome: (id: string, turnItemId: string) => Promise<AgentSessionAccountHome | null>
|
||||
publish: (id: string) => void
|
||||
now: () => number
|
||||
}
|
||||
@@ -225,9 +222,11 @@ export class StructuredAgentSessionUsageLimitContinue {
|
||||
}
|
||||
}
|
||||
|
||||
private async attribute(id: string, cut: HostUsageLimitCut): Promise<void> {
|
||||
const home = await this.deps.cutAccountHome(id, cut.turnItemId).catch(() => null)
|
||||
await this.holds.attribute(id, this.deps.host, { cut: identity(cut), home }, this.deps.now())
|
||||
/** The account a cut hit is the one its turn's runtime launched under, when the record names it. */
|
||||
private attribute(id: string, cut: HostUsageLimitCut): Promise<void> {
|
||||
const launched = this.deps.host.store.getRecord(id)?.launchedAccount
|
||||
const home = launched && launched.fence === cut.fence ? launched.accountHome : null
|
||||
return this.holds.attribute(id, this.deps.host, { cut: identity(cut), home }, this.deps.now())
|
||||
}
|
||||
|
||||
private deferUnchangedCut(id: string, cut: Cut, at: number): void {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentSessionRecord } from '../../../../shared/agent-session/lifecycle/agent-session-record'
|
||||
import type { AgentSessionProviderHandleLink } from '../../../../shared/agent-session/lifecycle/agent-session-provider-handle'
|
||||
import type { AgentSessionAccountHome } from '../../../../shared/agent-session/lifecycle/agent-session-account-home'
|
||||
import {
|
||||
withAcquiredAgentSessionAccount,
|
||||
type ClaudeAccountRecipe
|
||||
@@ -21,9 +22,11 @@ export function createAgentSessionOwnerProof(
|
||||
now: number
|
||||
leaseTtlMs?: number
|
||||
accountAcquisition?: { generation: string; recipe: ClaudeAccountRecipe }
|
||||
/** The account this runtime launched under, when the provider reports one. */
|
||||
launchedAccountHome?: AgentSessionAccountHome
|
||||
}): Promise<AgentSessionRecord> =>
|
||||
mutate(args.sessionId, (record) =>
|
||||
withAcquiredAgentSessionAccount(
|
||||
mutate(args.sessionId, (record) => {
|
||||
const { launchedAccount: _previous, ...proved } = withAcquiredAgentSessionAccount(
|
||||
proveAgentSessionOwner({
|
||||
record,
|
||||
fence: args.fence,
|
||||
@@ -33,5 +36,12 @@ export function createAgentSessionOwnerProof(
|
||||
}),
|
||||
args.accountAcquisition
|
||||
)
|
||||
)
|
||||
// A runtime that reports no account leaves no stale claim about where this fence ran.
|
||||
return args.launchedAccountHome
|
||||
? {
|
||||
...proved,
|
||||
launchedAccount: { fence: args.fence, accountHome: args.launchedAccountHome }
|
||||
}
|
||||
: proved
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { agentSessionRecordPredecessorIds } from './agent-session-record-lineage'
|
||||
import { clearSuccessor } from './agent-session-sender-identity-revision'
|
||||
import * as usageLimit from '../../../../shared/agent-session/lifecycle/agent-session-usage-limit-continue'
|
||||
import * as usageLimitContinue from '../../../../shared/agent-session/lifecycle/agent-session-usage-limit-continue'
|
||||
import { createAgentSessionOwnerProof } from '../ownership/agent-session-owner-proof'
|
||||
import { backfillAgentSessionPermissionModes } from '../ownership/agent-session-permission-backfill'
|
||||
import { createAgentSessionConversationReceipts } from '../agent-session-conversation-receipts'
|
||||
@@ -256,11 +256,10 @@ export class AgentSessionRecordStore extends AgentSessionRecordTabs {
|
||||
return this.mutate(args.sessionId, (record) => evictAgentSessionOwner({ ...args, record }))
|
||||
}
|
||||
|
||||
recordUsageLimitCutAccount = (id: string, cut: usageLimit.UsageLimitCutAccount) =>
|
||||
this.mutate(id, (record) => usageLimit.recordAgentSessionUsageLimitCutAccount(record, cut))
|
||||
|
||||
cancelUsageLimitContinue = (id: string, cut: usageLimit.UsageLimitContinueIdentity) =>
|
||||
this.mutate(id, (record) => usageLimit.cancelAgentSessionUsageLimitContinue(record, cut))
|
||||
cancelUsageLimitContinue = (id: string, cut: usageLimitContinue.UsageLimitContinueIdentity) =>
|
||||
this.mutate(id, (record) =>
|
||||
usageLimitContinue.cancelAgentSessionUsageLimitContinue(record, cut)
|
||||
)
|
||||
|
||||
transitionHandoff = (
|
||||
sessionId: string,
|
||||
@@ -312,7 +311,7 @@ export class AgentSessionRecordStore extends AgentSessionRecordTabs {
|
||||
/** The journal commit revealing a stopped cut co-writes its withdrawal; a saved one needs none. */
|
||||
usageLimitContinueCancelReceipt = (
|
||||
id: string,
|
||||
cut: usageLimit.UsageLimitContinueIdentity
|
||||
cut: usageLimitContinue.UsageLimitContinueIdentity
|
||||
): JournalOperationReceipt | undefined => {
|
||||
const saved = this.getRecord(id)?.usageLimitContinueCancel
|
||||
return saved?.turnItemId === cut.turnItemId && saved.resetsAt === cut.resetsAt
|
||||
@@ -320,7 +319,10 @@ export class AgentSessionRecordStore extends AgentSessionRecordTabs {
|
||||
: this.transactions.receipt((draft) => {
|
||||
const record = draft.records.get(id)
|
||||
if (record) {
|
||||
draft.records.set(id, usageLimit.cancelAgentSessionUsageLimitContinue(record, cut))
|
||||
draft.records.set(
|
||||
id,
|
||||
usageLimitContinue.cancelAgentSessionUsageLimitContinue(record, cut)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -86,6 +86,26 @@ export function agentPinsAccountHome(
|
||||
: agent.accountLocatorKind === home.kind
|
||||
}
|
||||
|
||||
/** The account a chat's runtime at `fence` launched under; a chat that follows the selection
|
||||
* moves on, so its founding `accountHome` no longer says where it ran. */
|
||||
export type AgentSessionLaunchedAccount = { fence: number; accountHome: AgentSessionAccountHome }
|
||||
|
||||
export function isOptionalAgentSessionLaunchedAccount(
|
||||
value: unknown
|
||||
): value is AgentSessionLaunchedAccount | undefined {
|
||||
return (
|
||||
value === undefined ||
|
||||
(typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'fence' in value &&
|
||||
typeof value.fence === 'number' &&
|
||||
Number.isSafeInteger(value.fence) &&
|
||||
value.fence > 0 &&
|
||||
'accountHome' in value &&
|
||||
isAgentSessionAccountHome(value.accountHome))
|
||||
)
|
||||
}
|
||||
|
||||
function isBoundedAccountString(value: unknown, max: number): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { hasValidUsageLimitRecordFields } from './agent-session-usage-limit-continue'
|
||||
import { isOptionalUsageLimitContinueCancel } from './agent-session-usage-limit-continue'
|
||||
import type { AgentSessionDeathEvidence } from './agent-session-death-evidence'
|
||||
import { isAgentSessionRewindRecord, type AgentSessionRewindRecord } from './agent-session-rewind'
|
||||
import { isAgentSessionLaunchArgs } from './agent-session-launch-args'
|
||||
@@ -30,8 +30,10 @@ import { isStructuredAgentId } from './agent-session-provider-handle-encoding'
|
||||
import { isNonNegativeSafeInteger } from '../../ssh-retained-payload-limits'
|
||||
import {
|
||||
isAgentSessionAccountHome,
|
||||
isOptionalAgentSessionLaunchedAccount,
|
||||
MAX_PATH_LENGTH,
|
||||
type AgentSessionAccountHome
|
||||
type AgentSessionAccountHome,
|
||||
type AgentSessionLaunchedAccount
|
||||
} from './agent-session-account-home'
|
||||
|
||||
export const AGENT_SESSION_RECORD_SCHEMA_VERSION = 2 as const
|
||||
@@ -119,6 +121,8 @@ export type AgentSessionRecord = {
|
||||
providerHandleChain: AgentSessionProviderHandleLink[]
|
||||
providerContextBoundary?: AgentSessionProviderContextBoundary
|
||||
accountHome: AgentSessionAccountHome
|
||||
/** Where the latest proven runtime actually launched; absent before any or from older hosts. */
|
||||
launchedAccount?: AgentSessionLaunchedAccount
|
||||
/** Optional host-written construction observation; checked independently of record readability. */
|
||||
acquiredAccount?: unknown
|
||||
/** Initial launch directory in host path syntax. Floating sessions resume here; worktree/folder IDs resolve their durable location. */
|
||||
@@ -128,8 +132,6 @@ export type AgentSessionRecord = {
|
||||
/** The permission mode the user chose; Orca's, so a provider's options answer never replaces it. */
|
||||
permissionMode?: string
|
||||
usageLimitContinueCancel?: { turnItemId: string; resetsAt: number }
|
||||
/** The account the latest usage-limit cut hit, saved while the child that ran it was live. */
|
||||
usageLimitCutAccount?: { turnItemId: string; accountHome: AgentSessionAccountHome }
|
||||
rewind?: AgentSessionRewindRecord
|
||||
conversationCommand?: AgentSessionConversationCommandRecord
|
||||
/** The conversation's one name: Orca's automatic name or the user's rename. Session History,
|
||||
@@ -315,12 +317,13 @@ export function isPersistedAgentSessionObservationRecord(
|
||||
isAgentSessionExecutionLocation(record.location) &&
|
||||
isStructuredAgentId(record.provider) &&
|
||||
isAgentSessionAccountHome(record.accountHome) &&
|
||||
isOptionalAgentSessionLaunchedAccount(record.launchedAccount) &&
|
||||
(record.launchDirectory === undefined ||
|
||||
isBoundedString(record.launchDirectory, MAX_PATH_LENGTH)) &&
|
||||
(record.options === undefined || isAgentSessionOptions(record.options)) &&
|
||||
(record.permissionMode === undefined ||
|
||||
isBoundedString(record.permissionMode, MAX_ID_LENGTH)) &&
|
||||
hasValidUsageLimitRecordFields(record) &&
|
||||
isOptionalUsageLimitContinueCancel(record.usageLimitContinueCancel) &&
|
||||
(record.rewind === undefined || isAgentSessionRewindRecord(record.rewind)) &&
|
||||
(record.providerContextBoundary === undefined ||
|
||||
isAgentSessionProviderContextBoundary(record.providerContextBoundary)) &&
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import type { AgentSessionRecord } from './agent-session-record'
|
||||
import {
|
||||
agentSessionAccountHomesEqual,
|
||||
isAgentSessionAccountHome,
|
||||
type AgentSessionAccountHome
|
||||
} from './agent-session-account-home'
|
||||
|
||||
export type UsageLimitContinueIdentity = { turnItemId: string; resetsAt: number }
|
||||
|
||||
@@ -23,45 +18,6 @@ export function isOptionalUsageLimitContinueCancel(
|
||||
)
|
||||
}
|
||||
|
||||
/** The account whose usage limit ended a turn; that account's reset is the one that holds it. */
|
||||
export type UsageLimitCutAccount = { turnItemId: string; accountHome: AgentSessionAccountHome }
|
||||
|
||||
export function isOptionalUsageLimitCutAccount(
|
||||
value: unknown
|
||||
): value is UsageLimitCutAccount | undefined {
|
||||
return (
|
||||
value === undefined ||
|
||||
(typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'turnItemId' in value &&
|
||||
typeof value.turnItemId === 'string' &&
|
||||
value.turnItemId.length > 0 &&
|
||||
'accountHome' in value &&
|
||||
isAgentSessionAccountHome(value.accountHome))
|
||||
)
|
||||
}
|
||||
|
||||
export function hasValidUsageLimitRecordFields(record: {
|
||||
usageLimitContinueCancel?: unknown
|
||||
usageLimitCutAccount?: unknown
|
||||
}): boolean {
|
||||
return (
|
||||
isOptionalUsageLimitContinueCancel(record.usageLimitContinueCancel) &&
|
||||
isOptionalUsageLimitCutAccount(record.usageLimitCutAccount)
|
||||
)
|
||||
}
|
||||
|
||||
export function recordAgentSessionUsageLimitCutAccount(
|
||||
record: AgentSessionRecord,
|
||||
cut: UsageLimitCutAccount
|
||||
): AgentSessionRecord {
|
||||
const saved = record.usageLimitCutAccount
|
||||
return saved?.turnItemId === cut.turnItemId &&
|
||||
agentSessionAccountHomesEqual(saved.accountHome, cut.accountHome)
|
||||
? record
|
||||
: { ...record, usageLimitCutAccount: cut, updatedAt: Date.now() }
|
||||
}
|
||||
|
||||
export function cancelAgentSessionUsageLimitContinue(
|
||||
record: AgentSessionRecord,
|
||||
cut: UsageLimitContinueIdentity
|
||||
|
||||
Reference in New Issue
Block a user