feat(core): finalize session event lifecycle (#35272)

This commit is contained in:
Kit Langton 2026-07-06 18:14:06 -04:00 committed by GitHub
commit bf01264661
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
82 changed files with 5200 additions and 3711 deletions

View file

@ -30,6 +30,8 @@ type OpenApiDocument = {
const document = (await Bun.file("./openapi.json").json()) as OpenApiDocument
const v2Document = (await Bun.file("./openapi-v2.json").json()) as OpenApiDocument
normalizeComponentNames(v2Document)
deduplicateEquivalentComponent(v2Document, "Shell", "Shell1")
renameCollidingComponents(document, v2Document)
document.paths = { ...document.paths, ...v2Document.paths }
document.components = {
@ -60,7 +62,7 @@ if (schemas) {
visit({ ...document, components: { ...document.components, schemas: undefined } })
for (const name of Object.keys(schemas)) {
if (
/^(SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionSettled|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetried|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)1$/.test(
/^(SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionStarted|SessionExecutionSucceeded|SessionExecutionFailed|SessionExecutionInterrupted|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetryScheduled|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)\d+$/.test(
name,
) &&
!reachable.has(name)
@ -100,17 +102,28 @@ await createClient({
const generatedTypesPath = "./src/v2/gen/types.gen.ts"
const generatedTypes = await Bun.file(generatedTypesPath).text()
if (
/export type (SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionSettled|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetried|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)1 =/.test(
/export type (SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionStarted|SessionExecutionSucceeded|SessionExecutionFailed|SessionExecutionInterrupted|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetryScheduled|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)\d+ =/.test(
generatedTypes,
)
) {
throw new Error("Session history generated duplicate Session event variants")
}
const logTypesPatched = generatedTypes.replace(
const sessionErrorTypesPatched = deduplicateEquivalentGeneratedTypes(
generatedTypes,
"SessionStructuredError",
/^SessionStructuredError\d+$/,
)
const obsoleteSessionNext = [...sessionErrorTypesPatched.matchAll(/export type (SessionNext\w*) =/g)].map(
(match) => match[1],
)
if (obsoleteSessionNext.length > 0) {
throw new Error(`Obsolete SessionNext generated type noise reintroduced: ${obsoleteSessionNext.join(", ")}`)
}
const logTypesPatched = sessionErrorTypesPatched.replace(
/(export type V2SessionLogData = \{[\s\S]*?query\?: \{\s*after\?: )string/,
"$1number",
)
if (logTypesPatched === generatedTypes) {
if (logTypesPatched === sessionErrorTypesPatched) {
throw new Error("Session log numeric query patch did not apply")
}
const sessionListTypesPatched = logTypesPatched.replace(
@ -128,12 +141,21 @@ if (sessionMessagesTypesPatched === sessionListTypesPatched) {
throw new Error("Session messages numeric query patch did not apply")
}
const eventSubscribeTypesPatched = sessionMessagesTypesPatched.replace(
/(export type V2EventSubscribeResponses = \{\s*\/\*\*[\s\S]*?\*\/\s*200: )\{\s*id: string \| null;?\s*event: string;?\s*data: V2EventStreamV2;?\s*\};?/,
/(export type V2EventSubscribeResponses = \{\s*\/\*\*[\s\S]*?\*\/\s*200: )\{\s*id: string \| null;?\s*event: string;?\s*data: V2EventStream(?:V2)?;?\s*\};?/,
"$1V2Event",
)
if (eventSubscribeTypesPatched === sessionMessagesTypesPatched) {
throw new Error("Event subscribe response patch did not apply")
}
if (/SessionStructuredError\d/.test(eventSubscribeTypesPatched)) {
throw new Error("Session structured error generated a name-mangled duplicate")
}
if (/\bSessionNext\w*\b/.test(eventSubscribeTypesPatched)) {
throw new Error("Obsolete SessionNext generated type noise reintroduced")
}
if (/export type Shell\d+V2 =/.test(eventSubscribeTypesPatched)) {
throw new Error("Shell generated a name-mangled duplicate")
}
await Bun.write(generatedTypesPath, eventSubscribeTypesPatched)
const querySerializerPath = "./src/v2/gen/client/utils.gen.ts"
@ -206,6 +228,10 @@ function renameCollidingComponents(target: OpenApiDocument, source: OpenApiDocum
const renames = new Map<string, string>()
for (const name of Object.keys(sourceSchemas)) {
if (!Object.hasOwn(targetSchemas, name)) continue
if (JSON.stringify(normalizeSchema(sourceSchemas[name])) === JSON.stringify(normalizeSchema(targetSchemas[name]))) {
delete sourceSchemas[name]
continue
}
let renamed = `${name}V2`
let index = 2
while (Object.hasOwn(targetSchemas, renamed) || Object.hasOwn(sourceSchemas, renamed)) {
@ -225,6 +251,136 @@ function renameCollidingComponents(target: OpenApiDocument, source: OpenApiDocum
source.paths = rewriteRefs(source.paths, renames) as Record<string, unknown> | undefined
}
function normalizeComponentNames(document: OpenApiDocument) {
const schemas = document.components?.schemas
if (!schemas) return
const canonical = new Map(Object.entries(schemas))
const renames = new Map<string, string>()
for (const name of Object.keys(schemas)) {
const next = componentTypeName(name)
if (next === name) continue
const existing = canonical.get(next)
if (existing !== undefined) {
if (JSON.stringify(normalizeSchema(schemas[name])) !== JSON.stringify(normalizeSchema(existing))) continue
renames.set(name, next)
continue
}
renames.set(name, next)
canonical.set(next, schemas[name])
}
if (renames.size === 0) return
const renamed = new Set<string>()
document.components = {
...document.components,
schemas: Object.fromEntries(
[
...Object.entries(schemas).filter(([name]) => !renames.has(name)),
...Object.entries(schemas).flatMap(([name, schema]) => {
const next = renames.get(name)
if (!next || Object.hasOwn(schemas, next) || renamed.has(next)) return []
renamed.add(next)
return [[next, schema] as const]
}),
].map(([name, schema]) => [name, rewriteRefs(schema, renames)]),
),
}
document.paths = rewriteRefs(document.paths, renames) as Record<string, unknown> | undefined
}
function componentTypeName(name: string) {
if (!name.includes(".")) return name
return name
.split(".")
.filter((part) => !/^\d+$/.test(part))
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
.join("")
}
function deduplicateEquivalentComponent(document: OpenApiDocument, canonical: string, duplicate: string) {
const schemas = document.components?.schemas
if (!schemas?.[canonical] || !schemas[duplicate]) return
if (JSON.stringify(normalizeSchema(schemas[canonical])) !== JSON.stringify(normalizeSchema(schemas[duplicate]))) {
throw new Error(`${duplicate} no longer has the same wire shape as ${canonical}`)
}
const renames = new Map([[duplicate, canonical]])
const rewritten = rewriteRefs(schemas, renames) as Record<string, unknown>
delete rewritten[duplicate]
document.components = { ...document.components, schemas: rewritten }
document.paths = rewriteRefs(document.paths, renames) as Record<string, unknown> | undefined
}
function deduplicateEquivalentGeneratedTypes(source: string, canonical: string, duplicates: RegExp) {
const canonicalType = generatedType(source, canonical)
if (!canonicalType) throw new Error(`Generated canonical type missing: ${canonical}`)
const names = [...source.matchAll(/export type (\w+) =/g)]
.map((match) => match[1])
.filter((name): name is string => name !== undefined && duplicates.test(name))
return names.reduce((patched, name) => {
const duplicate = generatedType(patched, name)
const currentCanonical = generatedType(patched, canonical)
if (!duplicate || !currentCanonical) throw new Error(`Generated type declaration missing while comparing ${name}`)
if (normalizeGeneratedType(currentCanonical.shape) !== normalizeGeneratedType(duplicate.shape)) {
throw new Error(`${name} no longer has the same generated type shape as ${canonical}`)
}
return (patched.slice(0, duplicate.start) + patched.slice(duplicate.end)).replaceAll(name, canonical)
}, source)
}
function generatedType(source: string, name: string) {
const start = source.indexOf(`export type ${name} =`)
if (start === -1) return undefined
const next = source.indexOf("\n\nexport type ", start + 1)
const shapeEnd = next === -1 ? source.length : next
return {
start,
end: next === -1 ? source.length : next + 2,
shape: source.slice(source.indexOf("=", start) + 1, shapeEnd),
}
}
function normalizeGeneratedType(shape: string) {
return shape.replaceAll(/\s/g, "")
}
function normalizeSchema(value: unknown, key?: string): unknown {
if (Array.isArray(value)) {
const flattened =
key === "anyOf"
? value.flatMap((item) =>
typeof item === "object" && item !== null && Object.keys(item).length === 1 && "anyOf" in item
? Array.isArray(item.anyOf)
? item.anyOf
: [item]
: [item],
)
: value
const expanded =
key === "anyOf"
? flattened.flatMap((item) => {
if (typeof item !== "object" || item === null || !("type" in item) || !("enum" in item)) return [item]
if (Object.keys(item).some((property) => property !== "type" && property !== "enum")) return [item]
if (!Array.isArray(item.enum)) return [item]
return item.enum.map((member) => ({ type: item.type, enum: [member] }))
})
: flattened
const normalized = expanded.map((item) => normalizeSchema(item))
if (key !== "anyOf" && key !== "required" && key !== "enum") return normalized
return [...new Map(normalized.map((item) => [JSON.stringify(item), item])).values()].sort((a, b) =>
JSON.stringify(a).localeCompare(JSON.stringify(b)),
)
}
if (typeof value !== "object" || value === null) return value
return Object.fromEntries(
Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.map(([property, child]) => [property, normalizeSchema(child, property)]),
)
}
function rewriteRefs(value: unknown, renames: Map<string, string>): unknown {
if (Array.isArray(value)) return value.map((item) => rewriteRefs(item, renames))
if (typeof value !== "object" || value === null) return value

View file

@ -76,8 +76,8 @@ import type {
FindTextResponses,
FormatterStatusErrors,
FormatterStatusResponses,
FormCreatePayload2,
FormReply2,
FormCreatePayloadV2,
FormReply,
GlobalConfigGetErrors,
GlobalConfigGetResponses,
GlobalConfigUpdateErrors,
@ -92,8 +92,8 @@ import type {
GlobalUpgradeResponses,
InstanceDisposeErrors,
InstanceDisposeResponses,
InstructionEntryKey2,
LocationRef2,
InstructionEntryKeyV2,
LocationRefV2,
LspStatusErrors,
LspStatusResponses,
McpAddErrors,
@ -114,7 +114,7 @@ import type {
McpRemoteConfig,
McpStatusErrors,
McpStatusResponses,
ModelRef2,
ModelRef,
MoveSessionDestination,
OutputFormat,
Part as Part2,
@ -131,8 +131,8 @@ import type {
PermissionRespondErrors,
PermissionRespondResponses,
PermissionRuleset,
PermissionV2Reply2,
PermissionV2Source2,
PermissionV2Reply,
PermissionV2SourceV2,
ProjectCommands,
ProjectCurrentErrors,
ProjectCurrentResponses,
@ -145,9 +145,9 @@ import type {
ProjectListResponses,
ProjectUpdateErrors,
ProjectUpdateResponses,
PromptAgentAttachment2,
PromptInputFileAttachment2,
PromptInputV2,
PromptAgentAttachment,
PromptInput,
PromptInputFileAttachment,
ProviderAuthErrors,
ProviderAuthResponses,
ProviderListErrors,
@ -179,7 +179,7 @@ import type {
QuestionRejectResponses,
QuestionReplyErrors,
QuestionReplyResponses,
QuestionV2Reply2,
QuestionV2Reply,
SessionAbortErrors,
SessionAbortResponses,
SessionChildrenErrors,
@ -460,7 +460,7 @@ import type {
VcsDiffResponses,
VcsGetErrors,
VcsGetResponses,
VcsMode2,
VcsMode,
VcsStatusErrors,
VcsStatusResponses,
WorktreeCreateErrors,
@ -5292,7 +5292,7 @@ export class Entry extends HeyApiClient {
public remove<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
key: InstructionEntryKey2
key: InstructionEntryKeyV2
},
options?: Options<never, ThrowOnError>,
) {
@ -5326,7 +5326,7 @@ export class Entry extends HeyApiClient {
public put<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
key: InstructionEntryKey2
key: InstructionEntryKeyV2
value?: unknown
},
options?: Options<never, ThrowOnError>,
@ -5395,7 +5395,7 @@ export class Form extends HeyApiClient {
public create<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
formCreatePayload: FormCreatePayload2
formCreatePayloadV2: FormCreatePayloadV2
},
options?: Options<never, ThrowOnError>,
) {
@ -5405,7 +5405,7 @@ export class Form extends HeyApiClient {
{
args: [
{ in: "path", key: "sessionID" },
{ key: "formCreatePayload", map: "body" },
{ key: "formCreatePayloadV2", map: "body" },
],
},
],
@ -5493,7 +5493,7 @@ export class Form extends HeyApiClient {
parameters: {
sessionID: string
formID: string
formReply: FormReply2
formReply: FormReply
},
options?: Options<never, ThrowOnError>,
) {
@ -5593,7 +5593,7 @@ export class Permission2 extends HeyApiClient {
metadata?: {
[key: string]: unknown
}
source?: PermissionV2Source2
source?: PermissionV2SourceV2
agent?: string | null
},
options?: Options<never, ThrowOnError>,
@ -5674,7 +5674,7 @@ export class Permission2 extends HeyApiClient {
parameters: {
sessionID: string
requestID: string
reply?: PermissionV2Reply2
reply?: PermissionV2Reply
message?: string | null
},
options?: Options<never, ThrowOnError>,
@ -5742,7 +5742,7 @@ export class Question2 extends HeyApiClient {
parameters: {
sessionID: string
requestID: string
questionV2Reply: QuestionV2Reply2
questionV2Reply: QuestionV2Reply
},
options?: Options<never, ThrowOnError>,
) {
@ -5863,8 +5863,8 @@ export class Session3 extends HeyApiClient {
parameters?: {
id?: string | null
agent?: string | null
model?: ModelRef2 | null
location?: LocationRef2 | null
model?: ModelRef | null
location?: LocationRefV2 | null
},
options?: Options<never, ThrowOnError>,
) {
@ -6006,7 +6006,7 @@ export class Session3 extends HeyApiClient {
public switchModel<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
model?: ModelRef2
model?: ModelRef
},
options?: Options<never, ThrowOnError>,
) {
@ -6081,7 +6081,7 @@ export class Session3 extends HeyApiClient {
parameters: {
sessionID: string
id?: string | null
prompt?: PromptInputV2
prompt?: PromptInput
delivery?: "steer" | "queue" | null
resume?: boolean | null
},
@ -6125,9 +6125,9 @@ export class Session3 extends HeyApiClient {
command?: string
arguments?: string | null
agent?: string | null
model?: ModelRef2 | null
files?: Array<PromptInputFileAttachment2>
agents?: Array<PromptAgentAttachment2>
model?: ModelRef | null
files?: Array<PromptInputFileAttachment>
agents?: Array<PromptAgentAttachment>
delivery?: "steer" | "queue" | null
resume?: boolean | null
},
@ -6559,7 +6559,7 @@ export class Generate extends HeyApiClient {
workspace?: string | null
} | null
prompt?: string
model?: ModelRef2 | null
model?: ModelRef | null
},
options?: Options<never, ThrowOnError>,
) {
@ -8013,7 +8013,7 @@ export class Vcs2 extends HeyApiClient {
directory?: string | null
workspace?: string | null
} | null
mode: VcsMode2
mode: VcsMode
context?: string | null
},
options?: Options<never, ThrowOnError>,

File diff suppressed because it is too large Load diff