@mirrowel/opencode-openshare 0.1.1 → 0.2.0-dev.2

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.
package/src/v2-server.ts CHANGED
@@ -74,114 +74,10 @@ type V2Context = {
74
74
  signal?: AbortSignal
75
75
  }
76
76
 
77
- // --- v2 message -> v1 items ---------------------------------------------------
78
-
79
- function toolContentText(content: unknown): string {
80
- if (typeof content === "string") return content
81
- if (Array.isArray(content)) {
82
- return content
83
- .map((item) => {
84
- if (typeof item === "string") return item
85
- if (item && typeof item === "object") {
86
- const record = item as Record<string, unknown>
87
- if (typeof record.text === "string") return record.text
88
- }
89
- return ""
90
- })
91
- .join("")
92
- }
93
- return ""
94
- }
95
-
96
- function convertToolState(state: unknown): Record<string, unknown> {
97
- if (!state || typeof state !== "object") return { status: "pending" }
98
- const record = state as Record<string, unknown>
99
- const status = String(record.status ?? "pending")
100
- const mapped: Record<string, unknown> = {}
101
- if (status === "streaming" || status === "running") mapped.status = "running"
102
- else if (status === "completed") mapped.status = "completed"
103
- else if (status === "failed" || status === "error") mapped.status = "error"
104
- else mapped.status = "pending"
105
- if (record.input !== undefined) mapped.input = record.input
106
- if (record.output !== undefined) mapped.output = toolContentText(record.output)
107
- if (record.error !== undefined) mapped.error = record.error
108
- if (record.time !== undefined) mapped.time = record.time
109
- return mapped
110
- }
111
-
112
- /** v2 flat message -> { info, parts } in the v1 shapes the viewer speaks. */
113
- export function convertV2Message(message: V2Message, sessionID: string): { info: MessageInfo; parts: PartInfo[] } | undefined {
114
- if (!message || typeof message.id !== "string") return undefined
115
- const base = {
116
- id: message.id,
117
- sessionID,
118
- }
119
- if (message.type === "user") {
120
- return {
121
- info: {
122
- ...base,
123
- role: "user",
124
- time: { created: message.time?.created ?? 0 },
125
- text: message.text ?? "",
126
- },
127
- parts: [],
128
- }
129
- }
130
- if (message.type === "assistant") {
131
- const parts: PartInfo[] = []
132
- const content = Array.isArray(message.content) ? message.content : []
133
- content.forEach((block, index) => {
134
- const record = block as Record<string, unknown>
135
- const kind = String(record.type ?? "")
136
- if (kind === "text") {
137
- parts.push({
138
- id: `${message.id}_t${index}`,
139
- messageID: message.id,
140
- sessionID,
141
- type: "text",
142
- text: String(record.text ?? ""),
143
- synthetic: false,
144
- time: { start: message.time?.created ?? 0, end: message.time?.completed ?? 0 },
145
- })
146
- } else if (kind === "reasoning") {
147
- parts.push({
148
- id: `${message.id}_r${index}`,
149
- messageID: message.id,
150
- sessionID,
151
- type: "reasoning",
152
- text: String(record.text ?? ""),
153
- synthetic: false,
154
- time: { start: message.time?.created ?? 0, end: message.time?.completed ?? 0 },
155
- })
156
- } else if (kind === "tool") {
157
- parts.push({
158
- id: String(record.id ?? `${message.id}_tool${index}`),
159
- messageID: message.id,
160
- sessionID,
161
- type: "tool",
162
- tool: String(record.name ?? "unknown"),
163
- state: convertToolState(record.state),
164
- synthetic: false,
165
- })
166
- }
167
- })
168
- return {
169
- info: {
170
- ...base,
171
- role: "assistant",
172
- agent: message.agent,
173
- model: message.model
174
- ? { providerID: message.model.providerID, modelID: message.model.modelID ?? message.model.id }
175
- : undefined,
176
- time: { created: message.time?.created ?? 0, completed: message.time?.completed ?? 0 },
177
- },
178
- parts,
179
- }
180
- }
181
- return undefined
182
- }
183
-
184
77
  // --- v2 client -> engine ClientApi ---------------------------------------------
78
+ // Contract v2: messages pass through VERBATIM as v2 session messages (the
79
+ // engine's ingestion detects v2-native shapes and enqueues them directly —
80
+ // no v1 conversion, no dropped compaction, no dropped user text).
185
81
 
186
82
  function adaptSessionClient(session: V2SessionClient | undefined, message: V2MessageClient | undefined, directory: string | undefined): ClientApi | undefined {
187
83
  if (!session) return undefined
@@ -203,7 +99,7 @@ function adaptSessionClient(session: V2SessionClient | undefined, message: V2Mes
203
99
 
204
100
  const listMessages = async (sessionID: string) => {
205
101
  if (!message) return []
206
- const out: Array<{ info: MessageInfo; parts: PartInfo[] }> = []
102
+ const out: Array<{ info: unknown; parts: unknown[] }> = []
207
103
  let cursor: string | undefined
208
104
  for (let page = 0; page < 50; page++) {
209
105
  const res = (await message.list({ sessionID, limit: 100, cursor })) as
@@ -212,8 +108,9 @@ function adaptSessionClient(session: V2SessionClient | undefined, message: V2Mes
212
108
  const data = res?.data
213
109
  if (!Array.isArray(data)) break
214
110
  for (const raw of data) {
215
- const converted = convertV2Message(raw, sessionID)
216
- if (converted) out.push(converted)
111
+ if (raw && typeof raw === "object" && typeof (raw as { id?: unknown }).id === "string") {
112
+ out.push({ info: raw, parts: [] })
113
+ }
217
114
  }
218
115
  const next = res?.cursor?.next ?? undefined
219
116
  if (!next || data.length === 0) break
@@ -332,7 +229,20 @@ export function createV2ServerSetup() {
332
229
  // the client adapter and feed them into the engine as the v1 events it
333
230
  // understands (session.updated / message.updated / message.part.updated).
334
231
  // The engine's keyed queue makes repeated re-syncs idempotent.
232
+ // NOTE: no content-hash dedupe here — a refresh that runs while the
233
+ // session is still dormant (pre-flag) would poison the cache without
234
+ // emitting anything, and the flag-time refresh would then SKIP the
235
+ // unchanged messages (live-caught in the v2 mock suite: the share's
236
+ // first assistant content arrived only as the next change). The dedupe
237
+ // belongs engine-side, where dormancy is known, if it's ever needed.
335
238
  const pendingRefresh = new Map<string, ReturnType<typeof setTimeout>>()
239
+ // queued inbox inputs per session: v2 persists the prompt in the inbox
240
+ // (delivery "queue") while the current turn still runs — the message
241
+ // row can exist before the model ever sees it, and a mid-turn refresh
242
+ // would surface it early. Held here until session.inbox.delivered
243
+ // fires (the turn actually starts); "steer" inputs inject into the
244
+ // live run, so they pass straight through.
245
+ const queuedInbox = new Map<string, Set<string>>()
336
246
  const refresh = (sessionID: string) => {
337
247
  let timer = pendingRefresh.get(sessionID)
338
248
  if (timer) clearTimeout(timer)
@@ -346,7 +256,9 @@ export function createV2ServerSetup() {
346
256
  })
347
257
  const messages = await (client as NonNullable<typeof client>).session.messages?.({ path: { id: sessionID } })
348
258
  const list = ((messages as { data?: unknown })?.data ?? []) as Array<{ info: MessageInfo; parts: PartInfo[] }>
259
+ const held = queuedInbox.get(sessionID)
349
260
  for (const message of list) {
261
+ if (held?.has(message.info.id)) continue // still queued — not sent to the model yet
350
262
  await engine.hooks.event({
351
263
  event: { type: "message.updated", properties: { sessionID, info: message.info } },
352
264
  })
@@ -380,6 +292,41 @@ export function createV2ServerSetup() {
380
292
  await engine.hooks.event({ event: { type, properties: { sessionID: sid } } })
381
293
  continue
382
294
  }
295
+ if (type.startsWith("session.inbox.")) {
296
+ // queued-prompt lifecycle: hold delivery="queue" inputs off
297
+ // the share until they are actually delivered to the model
298
+ // (cancelled ones never appear). inboxID shares the message
299
+ // id space (the inbox item promotes into that message row).
300
+ const data = (event.data ?? {}) as { inboxID?: string; item?: { delivery?: string } }
301
+ const inboxID = data.inboxID
302
+ if (inboxID) {
303
+ let set = queuedInbox.get(sid)
304
+ if (!set) {
305
+ set = new Set()
306
+ queuedInbox.set(sid, set)
307
+ }
308
+ if (type === "session.inbox.enqueued" && data.item?.delivery === "queue") {
309
+ set.add(inboxID)
310
+ } else if (type === "session.inbox.delivered") {
311
+ set.delete(inboxID)
312
+ refresh(sid) // the held prompt's turn is starting now
313
+ } else if (type === "session.inbox.cancelled") {
314
+ // cancelled-before-run: the prompt never reaches the
315
+ // model — keep it held forever so no later refresh
316
+ // surfaces the orphaned row
317
+ set.add(inboxID)
318
+ } else if (type === "session.inbox.delivery.changed") {
319
+ // steer inputs inject into the live run — the model
320
+ // sees them; only "queue" holds
321
+ if (data.item?.delivery === "queue") set.add(inboxID)
322
+ else {
323
+ set.delete(inboxID)
324
+ refresh(sid)
325
+ }
326
+ }
327
+ }
328
+ continue
329
+ }
383
330
  if (type === "message.removed" || type === "message.part.removed") {
384
331
  // undo cleanup: forwarded verbatim — the engine emits the
385
332
  // tombstones that purge the reverted items from the share