@adhdev/daemon-core 0.8.64 → 0.8.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -343,20 +343,49 @@ export class ExtensionProviderInstance implements ProviderInstance {
343
343
  }
344
344
  }
345
345
 
346
+ private buildSyntheticTurnKey(message: any, occurrence: number): string {
347
+ const role = typeof message?.role === 'string' ? message.role : '';
348
+ const kind = typeof message?.kind === 'string' ? message.kind : '';
349
+ const senderName = typeof message?.senderName === 'string' ? message.senderName : '';
350
+ const content = flattenContent(message?.content)
351
+ .replace(/\s+/g, ' ')
352
+ .trim()
353
+ .slice(0, 500);
354
+ return `${role}|${kind}|${senderName}|${content}|${occurrence}`;
355
+ }
356
+
346
357
  /**
347
- * Assign stable receivedAt to extension messages.
348
- * Same pattern as IdeProviderInstance.readChat() prevByHash
349
- * preserves first-seen timestamp across polling cycles.
358
+ * Assign stable receivedAt / synthetic _turnKey to extension messages.
359
+ * Same transcript should keep the same identity across polling cycles and
360
+ * stream resets, while repeated identical text later in the transcript still
361
+ * produces a distinct completion marker via the occurrence suffix.
350
362
  */
351
363
  private assignReceivedAt(messages: any[]): any[] {
352
364
  const now = Date.now();
353
365
  const nextHashes = new Map<string, number>();
366
+ const occurrenceByBaseKey = new Map<string, number>();
354
367
 
355
368
  for (const msg of messages) {
356
- const hash = `${msg.role}:${(msg.content || '').slice(0, 100)}`;
357
- const prevTime = this.prevMessageHashes.get(hash);
369
+ const explicitTurnKey = typeof msg?._turnKey === 'string' && msg._turnKey.trim()
370
+ ? msg._turnKey.trim()
371
+ : '';
372
+ const explicitId = typeof msg?.id === 'string' && msg.id.trim()
373
+ ? `id:${msg.id.trim()}`
374
+ : '';
375
+ const explicitIndex = typeof msg?.index === 'number' && Number.isFinite(msg.index)
376
+ ? `idx:${msg.index}`
377
+ : '';
378
+ const baseKey = explicitTurnKey || explicitId || explicitIndex || `${msg?.role || ''}:${flattenContent(msg?.content || '').slice(0, 500)}`;
379
+ const occurrence = (occurrenceByBaseKey.get(baseKey) || 0) + 1;
380
+ occurrenceByBaseKey.set(baseKey, occurrence);
381
+ const syntheticTurnKey = explicitTurnKey || explicitId || explicitIndex || this.buildSyntheticTurnKey(msg, occurrence);
382
+ if (!explicitTurnKey && !explicitId && !explicitIndex) {
383
+ msg._turnKey = syntheticTurnKey;
384
+ }
385
+
386
+ const prevTime = this.prevMessageHashes.get(syntheticTurnKey);
358
387
  msg.receivedAt = prevTime || now;
359
- nextHashes.set(hash, msg.receivedAt);
388
+ nextHashes.set(syntheticTurnKey, msg.receivedAt);
360
389
  }
361
390
 
362
391
  this.prevMessageHashes = nextHashes;
@@ -18,6 +18,7 @@ import { StatusMonitor } from './status-monitor.js';
18
18
  import { ChatHistoryWriter } from '../config/chat-history.js';
19
19
  import { LOG } from '../logging/logger.js';
20
20
  import { buildPersistedProviderEffectMessage, normalizeProviderEffects } from './control-effects.js';
21
+ import { validateReadChatResultPayload } from './read-chat-contract.js';
21
22
  import type { ChatMessage } from '../types.js';
22
23
  import { formatAutoApprovalMessage, pickApprovalButton } from './approval-utils.js';
23
24
  import { mergeProviderPatchState, resolveProviderStateSurface } from './provider-patch-state.js';
@@ -311,7 +312,7 @@ export class IdeProviderInstance implements ProviderInstance {
311
312
  }
312
313
 
313
314
  if (!raw || typeof raw !== 'object') return;
314
- const chat = raw as ReadChatPayload;
315
+ const chat = validateReadChatResultPayload(raw, `${this.type} readChat`) as ReadChatPayload;
315
316
 
316
317
  // Modal filter
317
318
  let { activeModal } = chat;
@@ -0,0 +1,71 @@
1
+ import type { InputEnvelope, ProviderModule } from './contracts.js'
2
+
3
+ type InputMediaType = 'text' | 'image' | 'audio' | 'video' | 'resource'
4
+
5
+ const VALID_INPUT_MEDIA_TYPES = new Set<InputMediaType>(['text', 'image', 'audio', 'video', 'resource'])
6
+
7
+ function getProviderLabel(provider?: Pick<ProviderModule, 'name' | 'type'> | null): string {
8
+ return provider?.name || provider?.type || 'This provider'
9
+ }
10
+
11
+ function hasNonEmptyFallbackText(input: InputEnvelope): boolean {
12
+ return typeof input.textFallback === 'string' && input.textFallback.trim().length > 0
13
+ }
14
+
15
+ function getRequestedInputMediaTypes(input: InputEnvelope): InputMediaType[] {
16
+ const types = new Set<InputMediaType>()
17
+ if (hasNonEmptyFallbackText(input) && !input.parts.some((part) => part.type === 'text')) {
18
+ types.add('text')
19
+ }
20
+ for (const part of input.parts) {
21
+ if (VALID_INPUT_MEDIA_TYPES.has(part.type as InputMediaType)) {
22
+ types.add(part.type as InputMediaType)
23
+ }
24
+ }
25
+ return Array.from(types)
26
+ }
27
+
28
+ function getEffectiveSemanticPartCount(input: InputEnvelope): number {
29
+ let count = input.parts.length
30
+ if (hasNonEmptyFallbackText(input) && !input.parts.some((part) => part.type === 'text')) {
31
+ count += 1
32
+ }
33
+ return count
34
+ }
35
+
36
+ export function assertTextOnlyInput(provider: Pick<ProviderModule, 'name' | 'type'> | null | undefined, input: InputEnvelope): void {
37
+ const unsupported = getRequestedInputMediaTypes(input).filter((type) => type !== 'text')
38
+ if (unsupported.length === 0) return
39
+ const label = getProviderLabel(provider)
40
+ const suffix = unsupported.length === 1 ? '' : 's'
41
+ throw new Error(`${label} only supports text input; unsupported input type${suffix}: ${unsupported.join(', ')}`)
42
+ }
43
+
44
+ export function getDeclaredProviderInputSupport(provider?: Pick<ProviderModule, 'capabilities'> | null): {
45
+ multipart: boolean
46
+ mediaTypes: Set<InputMediaType>
47
+ } {
48
+ const rawMediaTypes = Array.isArray(provider?.capabilities?.input?.mediaTypes)
49
+ ? provider?.capabilities?.input?.mediaTypes.filter((type): type is InputMediaType => VALID_INPUT_MEDIA_TYPES.has(type as InputMediaType))
50
+ : []
51
+
52
+ return {
53
+ multipart: provider?.capabilities?.input?.multipart === true,
54
+ mediaTypes: new Set<InputMediaType>(rawMediaTypes.length > 0 ? rawMediaTypes : ['text']),
55
+ }
56
+ }
57
+
58
+ export function assertProviderSupportsDeclaredInput(provider: Pick<ProviderModule, 'name' | 'type' | 'capabilities'> | null | undefined, input: InputEnvelope): void {
59
+ const label = getProviderLabel(provider)
60
+ const support = getDeclaredProviderInputSupport(provider)
61
+ const requestedTypes = getRequestedInputMediaTypes(input)
62
+ const unsupported = requestedTypes.filter((type) => !support.mediaTypes.has(type))
63
+ if (unsupported.length > 0) {
64
+ const suffix = unsupported.length === 1 ? '' : 's'
65
+ throw new Error(`${label} does not support input type${suffix}: ${unsupported.join(', ')}`)
66
+ }
67
+
68
+ if (getEffectiveSemanticPartCount(input) > 1 && !support.multipart) {
69
+ throw new Error(`${label} does not support multipart input`)
70
+ }
71
+ }
@@ -1,5 +1,7 @@
1
1
  import type { ProviderControlDef, ProviderControlType, ProviderModule } from './contracts.js'
2
2
 
3
+ const VALID_CAPABILITY_MEDIA_TYPES = new Set(['text', 'image', 'audio', 'video', 'resource'])
4
+
3
5
  const KNOWN_PROVIDER_FIELDS = new Set<string>([
4
6
  'type',
5
7
  'name',
@@ -50,6 +52,7 @@ const KNOWN_PROVIDER_FIELDS = new Set<string>([
50
52
  'sendDelayMs',
51
53
  'sendKey',
52
54
  'submitStrategy',
55
+ 'timeouts',
53
56
  'disableUpstream',
54
57
  ])
55
58
 
@@ -88,6 +91,7 @@ export function validateProviderDefinition(raw: unknown): ProviderValidationResu
88
91
  }
89
92
 
90
93
  const category = provider.category
94
+ const controls = Array.isArray(provider.controls) ? provider.controls : []
91
95
  if ((category === 'cli' || category === 'acp')) {
92
96
  const spawn = provider.spawn
93
97
  const command = spawn && typeof spawn === 'object'
@@ -110,13 +114,68 @@ export function validateProviderDefinition(raw: unknown): ProviderValidationResu
110
114
  warnings.push('Extension providers should have extensionId')
111
115
  }
112
116
 
113
- for (const control of Array.isArray(provider.controls) ? provider.controls : []) {
117
+ validateCapabilities(provider as unknown as ProviderModule, controls, errors)
118
+
119
+ for (const control of controls) {
114
120
  validateControl(control as ProviderControlDef, errors)
115
121
  }
116
122
 
117
123
  return { errors, warnings }
118
124
  }
119
125
 
126
+ function validateCapabilities(provider: ProviderModule, controls: ProviderControlDef[], errors: string[]): void {
127
+ const capabilities = provider.capabilities
128
+ if (provider.contractVersion === 2) {
129
+ if (!capabilities || typeof capabilities !== 'object') {
130
+ errors.push('contractVersion 2 providers must declare capabilities')
131
+ return
132
+ }
133
+ }
134
+ if (!capabilities || typeof capabilities !== 'object') {
135
+ return
136
+ }
137
+
138
+ const input = capabilities.input
139
+ if (!input || typeof input !== 'object') {
140
+ errors.push('capabilities.input is required')
141
+ } else {
142
+ if (typeof input.multipart !== 'boolean') {
143
+ errors.push('capabilities.input.multipart must be boolean')
144
+ }
145
+ if (!Array.isArray(input.mediaTypes) || input.mediaTypes.length === 0) {
146
+ errors.push('capabilities.input.mediaTypes must be a non-empty array')
147
+ } else if (input.mediaTypes.some((type) => typeof type !== 'string' || !VALID_CAPABILITY_MEDIA_TYPES.has(type))) {
148
+ errors.push(`capabilities.input.mediaTypes must only include: ${Array.from(VALID_CAPABILITY_MEDIA_TYPES).join(', ')}`)
149
+ }
150
+ }
151
+
152
+ const output = capabilities.output
153
+ if (!output || typeof output !== 'object') {
154
+ errors.push('capabilities.output is required')
155
+ } else {
156
+ if (typeof output.richContent !== 'boolean') {
157
+ errors.push('capabilities.output.richContent must be boolean')
158
+ }
159
+ if (!Array.isArray(output.mediaTypes) || output.mediaTypes.length === 0) {
160
+ errors.push('capabilities.output.mediaTypes must be a non-empty array')
161
+ } else if (output.mediaTypes.some((type) => typeof type !== 'string' || !VALID_CAPABILITY_MEDIA_TYPES.has(type))) {
162
+ errors.push(`capabilities.output.mediaTypes must only include: ${Array.from(VALID_CAPABILITY_MEDIA_TYPES).join(', ')}`)
163
+ }
164
+ }
165
+
166
+ const controlCapabilities = capabilities.controls
167
+ if (!controlCapabilities || typeof controlCapabilities !== 'object') {
168
+ errors.push('capabilities.controls is required')
169
+ return
170
+ }
171
+ if (typeof controlCapabilities.typedResults !== 'boolean') {
172
+ errors.push('capabilities.controls.typedResults must be boolean')
173
+ }
174
+ if (controls.length > 0 && controlCapabilities.typedResults !== true) {
175
+ errors.push('providers declaring controls must set capabilities.controls.typedResults=true')
176
+ }
177
+ }
178
+
120
179
  function validateControl(control: ProviderControlDef, errors: string[]): void {
121
180
  if (!control || typeof control !== 'object') {
122
181
  errors.push('controls: each control must be an object')
@@ -0,0 +1,136 @@
1
+ import type { MessagePart, ModalInfo, ReadChatResult } from './contracts.js'
2
+ import { normalizeMessageParts } from './contracts.js'
3
+ import type { ChatMessage } from '../types.js'
4
+
5
+ const VALID_STATUSES = ['idle', 'generating', 'waiting_approval', 'error', 'panel_hidden', 'streaming', 'long_generating'] as const
6
+ const VALID_ROLES = ['user', 'assistant', 'system', 'human'] as const
7
+
8
+ type ValidStatus = typeof VALID_STATUSES[number]
9
+ type ValidRole = typeof VALID_ROLES[number]
10
+
11
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
12
+ return !!value && typeof value === 'object' && !Array.isArray(value)
13
+ }
14
+
15
+ function isFiniteNumber(value: unknown): value is number {
16
+ return typeof value === 'number' && Number.isFinite(value)
17
+ }
18
+
19
+ function validateStatus(status: unknown, source: string): ValidStatus {
20
+ if (typeof status !== 'string' || !VALID_STATUSES.includes(status as ValidStatus)) {
21
+ throw new Error(`${source}: status must be one of ${VALID_STATUSES.join(', ')}`)
22
+ }
23
+ return status as ValidStatus
24
+ }
25
+
26
+ function validateRole(role: unknown, source: string, index: number): ValidRole {
27
+ if (typeof role !== 'string' || !VALID_ROLES.includes(role as ValidRole)) {
28
+ throw new Error(`${source}: messages[${index}].role must be one of ${VALID_ROLES.join(', ')}`)
29
+ }
30
+ return role as ValidRole
31
+ }
32
+
33
+ function validateMessageContent(content: unknown, source: string, index: number): string | MessagePart[] {
34
+ if (typeof content === 'string') return content
35
+ if (Array.isArray(content)) return normalizeMessageParts(content as any)
36
+ throw new Error(`${source}: messages[${index}].content must be a string or structured content array`)
37
+ }
38
+
39
+ function validateMessage(message: unknown, source: string, index: number): ChatMessage {
40
+ if (!isPlainObject(message)) {
41
+ throw new Error(`${source}: messages[${index}] must be an object`)
42
+ }
43
+
44
+ const normalized: ChatMessage = {
45
+ role: validateRole(message.role, source, index),
46
+ content: validateMessageContent(message.content, source, index),
47
+ }
48
+
49
+ if (typeof message.kind === 'string') normalized.kind = message.kind as any
50
+ if (typeof message.id === 'string') normalized.id = message.id
51
+ if (isFiniteNumber(message.index)) normalized.index = message.index
52
+ if (isFiniteNumber(message.timestamp)) normalized.timestamp = message.timestamp
53
+ if (isFiniteNumber(message.receivedAt)) normalized.receivedAt = message.receivedAt
54
+ if (Array.isArray(message.toolCalls)) normalized.toolCalls = message.toolCalls as any
55
+ if (isPlainObject(message.meta)) normalized.meta = message.meta as any
56
+ if (typeof message.senderName === 'string') normalized.senderName = message.senderName
57
+ if (typeof (message as any)._type === 'string') normalized._type = (message as any)._type
58
+ if (typeof (message as any)._sub === 'string') normalized._sub = (message as any)._sub
59
+
60
+ return normalized
61
+ }
62
+
63
+ function validateModal(activeModal: unknown, status: ValidStatus, source: string): ModalInfo | null | undefined {
64
+ if (activeModal == null) {
65
+ if (status === 'waiting_approval') {
66
+ throw new Error(`${source}: waiting_approval status requires activeModal with buttons`)
67
+ }
68
+ return activeModal === null ? null : undefined
69
+ }
70
+ if (!isPlainObject(activeModal)) {
71
+ throw new Error(`${source}: activeModal must be an object when provided`)
72
+ }
73
+ if (typeof activeModal.message !== 'string') {
74
+ throw new Error(`${source}: activeModal.message must be a string`)
75
+ }
76
+ if (!Array.isArray(activeModal.buttons) || activeModal.buttons.some((button) => typeof button !== 'string' || !button.trim())) {
77
+ throw new Error(`${source}: activeModal.buttons must be a non-empty string array`)
78
+ }
79
+ const normalized: ModalInfo = {
80
+ message: activeModal.message,
81
+ buttons: activeModal.buttons.map((button) => button.trim()),
82
+ }
83
+ if (isFiniteNumber(activeModal.width)) normalized.width = activeModal.width
84
+ if (isFiniteNumber(activeModal.height)) normalized.height = activeModal.height
85
+ return normalized
86
+ }
87
+
88
+ function validateControlValues(controlValues: unknown, source: string): Record<string, string | number | boolean> | undefined {
89
+ if (controlValues === undefined) return undefined
90
+ if (!isPlainObject(controlValues)) {
91
+ throw new Error(`${source}: controlValues must be an object when provided`)
92
+ }
93
+ const normalized: Record<string, string | number | boolean> = {}
94
+ for (const [key, value] of Object.entries(controlValues)) {
95
+ if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
96
+ throw new Error(`${source}: controlValues.${key} must be string, number, or boolean`)
97
+ }
98
+ normalized[key] = value
99
+ }
100
+ return normalized
101
+ }
102
+
103
+ export function validateReadChatResultPayload(raw: unknown, source = 'read_chat'): ReadChatResult & Record<string, unknown> {
104
+ if (!isPlainObject(raw)) {
105
+ throw new Error(`${source}: payload must be an object`)
106
+ }
107
+
108
+ const status = validateStatus(raw.status, source)
109
+ if (!Array.isArray(raw.messages)) {
110
+ throw new Error(`${source}: messages must be an array`)
111
+ }
112
+ const messages = raw.messages.map((message, index) => validateMessage(message, source, index))
113
+ const activeModal = validateModal(raw.activeModal, status, source)
114
+ const controlValues = validateControlValues(raw.controlValues, source)
115
+
116
+ const normalized: ReadChatResult & Record<string, unknown> = {
117
+ status: status as any,
118
+ messages,
119
+ }
120
+
121
+ if (activeModal !== undefined) normalized.activeModal = activeModal
122
+ if (typeof raw.id === 'string') normalized.id = raw.id
123
+ if (typeof raw.title === 'string') normalized.title = raw.title
124
+ if (typeof raw.agentType === 'string') normalized.agentType = raw.agentType
125
+ if (typeof raw.agentName === 'string') normalized.agentName = raw.agentName
126
+ if (typeof raw.extensionId === 'string') normalized.extensionId = raw.extensionId
127
+ if (typeof raw.inputContent === 'string') normalized.inputContent = raw.inputContent
128
+ if (typeof raw.isVisible === 'boolean') normalized.isVisible = raw.isVisible
129
+ if (typeof raw.isWelcomeScreen === 'boolean') normalized.isWelcomeScreen = raw.isWelcomeScreen
130
+ if (controlValues) normalized.controlValues = controlValues
131
+ if (raw.summaryMetadata !== undefined) normalized.summaryMetadata = raw.summaryMetadata as any
132
+ if (Array.isArray(raw.effects)) normalized.effects = raw.effects as any
133
+ if (typeof raw.providerSessionId === 'string') normalized.providerSessionId = raw.providerSessionId
134
+
135
+ return normalized
136
+ }