@neta-art/cohub 1.6.0 → 1.7.0

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/README.md CHANGED
@@ -120,6 +120,27 @@ space.on("message.persisted", (event) => {
120
120
  });
121
121
  ```
122
122
 
123
+ For product UIs, prefer the normalized generation stream. It folds
124
+ `session.turn.snapshot`, `session.turn.patch`, legacy `session.turn.progress`,
125
+ persisted assistant messages, finalized turns, and turn errors into stable
126
+ semantic events.
127
+
128
+ ```ts
129
+ const stop = session.subscribeGeneration({
130
+ state(event) {
131
+ console.log(event.source, event.state.contentBlocks);
132
+ },
133
+ commit(event) {
134
+ if (event.commit.kind === "final") {
135
+ console.log("assistant final", event.commit.message);
136
+ }
137
+ },
138
+ outOfSync() {
139
+ // Fetch `session.turns.streamSnapshot()` or reconcile persisted turns.
140
+ },
141
+ });
142
+ ```
143
+
123
144
  Supported business event names:
124
145
 
125
146
  - `turn.patch`
@@ -1,9 +1,8 @@
1
- import type { Fetch } from "../transport.js";
1
+ import type { HttpTransport, Fetch } from "../transport.js";
2
2
  import type { PromptTemplateCatalogResponse } from "../types.js";
3
3
  export declare class PromptsApi {
4
- private readonly fetcher;
5
- private readonly baseUrl;
6
- constructor(fetcher: Fetch, baseUrl: string);
4
+ private readonly transport;
5
+ constructor(transport: HttpTransport);
7
6
  list(options?: {
8
7
  spaceId?: string;
9
8
  }, customFetch?: Fetch): Promise<PromptTemplateCatalogResponse>;
@@ -1,22 +1,16 @@
1
1
  export class PromptsApi {
2
- fetcher;
3
- baseUrl;
4
- constructor(fetcher, baseUrl) {
5
- this.fetcher = fetcher;
6
- this.baseUrl = baseUrl;
2
+ transport;
3
+ constructor(transport) {
4
+ this.transport = transport;
7
5
  }
8
6
  async list(options, customFetch) {
9
- const fetchImpl = customFetch ?? this.fetcher;
10
7
  const params = new URLSearchParams();
11
8
  if (options?.spaceId)
12
9
  params.set("spaceId", options.spaceId);
13
10
  const query = params.toString();
14
- const base = this.baseUrl ? `${this.baseUrl}/api/prompts` : "/api/prompts";
15
- const url = query ? `${base}?${query}` : base;
16
- const response = await fetchImpl(url);
17
- if (!response.ok) {
18
- throw new Error(`Failed to fetch prompt templates: ${response.status} ${response.statusText}`);
19
- }
20
- return response.json();
11
+ const path = query ? `/api/prompts?${query}` : "/api/prompts";
12
+ return this.transport.request(path, {
13
+ fetch: customFetch,
14
+ });
21
15
  }
22
16
  }
@@ -2,13 +2,21 @@ import type { SpacePublicEndpoints } from "@neta-art/cohub-protocol/ports";
2
2
  import type { WebsocketClient, WebsocketEventPayload } from "../websocket.js";
3
3
  import type { HttpTransport, Fetch } from "../transport.js";
4
4
  import { type SessionPatchApplyResult } from "../session-patch-reducer.js";
5
- import type { CheckpointRecord, ContentBlock, SessionMessageResponse, SessionMessagesPaginatedResponse, SessionMessagesResponse, SessionTurnResponse, SessionTurnStreamSnapshotResponse, SessionTurnIndexResponse, SessionTurnWindowResponse, SessionTurnsPaginatedResponse, SessionTurnSignedUrlsResponse, SessionRecord, SpaceAccessPolicy, SpaceBootstrapSource, SpaceChannelBindingInput, SpaceCheckpointDetailResponse, SpaceCreateResponse, CreateSpacePromptInput, CreateSpacePromptResponse, SpaceEnvInput, SpaceFsCompleteUploadInput, SpaceFsCompleteUploadResponse, SpaceFsCreateUploadInput, SpaceFsCreateUploadResponse, SpaceFsFileResponse, SpaceFsMoveInput, SpaceFsTreeResponse, SpaceFsUploadResponse, SpaceUsageResponse, SpaceFsWriteFileInput, SpaceMarkKind, SpaceMarkListItem, SpaceMarkResourceType, SpaceMember, SpaceRecord, SpaceRole, SpaceSessionsResponse } from "../types.js";
5
+ import { SessionGenerationStreamClient, type GenerationStreamSubscriptionHandlers } from "../session-generation-stream.js";
6
+ import type { CheckpointRecord, ContentBlock, SessionForkRecord, SessionMessageResponse, SessionMessagesPaginatedResponse, SessionMessagesResponse, SessionTurnResponse, SessionTurnStreamSnapshotResponse, SessionTurnIndexResponse, SessionTurnWindowResponse, SessionTurnsPaginatedResponse, SessionTurnSignedUrlsResponse, SessionRecord, SpaceAccessPolicy, SpaceBootstrapSource, SpaceChannelBindingInput, SpaceCheckpointDetailResponse, SpaceCreateResponse, CreateSpacePromptInput, CreateSpacePromptResponse, SpaceEnvInput, SpaceFsCompleteUploadInput, SpaceFsCompleteUploadResponse, SpaceFsCreateUploadInput, SpaceFsCreateUploadResponse, SpaceFsFileResponse, SpaceFsMoveInput, SpaceFsTreeResponse, SpaceFsUploadResponse, SpaceUsageResponse, SpaceFsWriteFileInput, SpaceMarkKind, SpaceMarkListItem, SpaceMarkResourceType, SpaceMember, SpaceRecord, SpaceRole, SpaceSessionsResponse } from "../types.js";
6
7
  import { SpaceInvitationsApi } from "./invitations.js";
7
8
  export type SessionSubscriptionHandlers = {
8
9
  patch?: (event: WebsocketEventPayload) => void;
10
+ /**
11
+ * @deprecated Use `session.subscribeGeneration({ state })` for normalized
12
+ * snapshot/patch/progress generation state.
13
+ */
9
14
  patchState?: (result: SessionPatchApplyResult) => void;
15
+ /** @deprecated Use `session.subscribeGeneration({ state })`. */
10
16
  snapshot?: (event: WebsocketEventPayload) => void;
17
+ /** @deprecated Legacy progress events are normalized by `subscribeGeneration`. */
11
18
  progress?: (event: WebsocketEventPayload) => void;
19
+ /** @deprecated Use `session.subscribeGeneration({ commit, finalized })`. */
12
20
  final?: (event: WebsocketEventPayload) => void;
13
21
  turnUpdated?: (event: WebsocketEventPayload) => void;
14
22
  turnFinalized?: (event: WebsocketEventPayload) => void;
@@ -140,6 +148,7 @@ export declare class SessionClient {
140
148
  readonly messages: SessionMessagesClient;
141
149
  readonly turns: SessionTurnsClient;
142
150
  readonly realtime: SessionRealtimeClient;
151
+ readonly generation: SessionGenerationStreamClient;
143
152
  constructor(spaceId: string, id: string, transport: HttpTransport, websocketClient: WebsocketClient | null);
144
153
  get(customFetch?: Fetch): Promise<{
145
154
  space: SpaceRecord;
@@ -153,7 +162,16 @@ export declare class SessionClient {
153
162
  } | Fetch, customFetch?: Fetch): Promise<{
154
163
  ok: true;
155
164
  }>;
165
+ turn(turnId: string): {
166
+ fork: (input?: {
167
+ title?: string | null;
168
+ }) => Promise<{
169
+ session: SessionRecord;
170
+ fork: SessionForkRecord;
171
+ }>;
172
+ };
156
173
  subscribe(handlers: SessionSubscriptionHandlers): () => void;
174
+ subscribeGeneration(handlers: GenerationStreamSubscriptionHandlers): () => void;
157
175
  on(type: SessionEventName, handler: (event: WebsocketEventPayload) => void): () => void;
158
176
  }
159
177
  export declare class SpaceSessionsApi {
@@ -1,5 +1,6 @@
1
1
  import { ensureRealtimeConnected } from "../realtime.js";
2
2
  import { SessionPatchReducer, } from "../session-patch-reducer.js";
3
+ import { SessionGenerationStreamClient, } from "../session-generation-stream.js";
3
4
  import { SpaceInvitationsApi } from "./invitations.js";
4
5
  const DEFAULT_DEDUP_WINDOW_MS = 2000;
5
6
  const getFilenameFromContentDisposition = (value) => {
@@ -405,6 +406,7 @@ export class SessionClient {
405
406
  messages;
406
407
  turns;
407
408
  realtime;
409
+ generation;
408
410
  constructor(spaceId, id, transport, websocketClient) {
409
411
  this.spaceId = spaceId;
410
412
  this.id = id;
@@ -412,6 +414,7 @@ export class SessionClient {
412
414
  this.messages = new SessionMessagesClient(transport, id);
413
415
  this.turns = new SessionTurnsClient(transport, id);
414
416
  this.realtime = new SessionRealtimeClient(websocketClient, spaceId, id);
417
+ this.generation = new SessionGenerationStreamClient(websocketClient, spaceId, id);
415
418
  }
416
419
  get(customFetch) {
417
420
  return this.transport.request(`/api/sessions/${this.id}`, {
@@ -435,9 +438,21 @@ export class SessionClient {
435
438
  fetch,
436
439
  });
437
440
  }
441
+ turn(turnId) {
442
+ return {
443
+ fork: (input = {}) => this.transport.request(`/api/sessions/${this.id}/turns/${turnId}/fork`, {
444
+ method: "POST",
445
+ headers: { "Content-Type": "application/json" },
446
+ body: JSON.stringify(input),
447
+ }),
448
+ };
449
+ }
438
450
  subscribe(handlers) {
439
451
  return this.realtime.subscribe(handlers);
440
452
  }
453
+ subscribeGeneration(handlers) {
454
+ return this.generation.subscribe(handlers);
455
+ }
441
456
  on(type, handler) {
442
457
  return this.realtime.on(type, handler);
443
458
  }
package/dist/client.js CHANGED
@@ -39,7 +39,7 @@ export class CohubClient {
39
39
  this.channels = new ChannelsApi(this.transport);
40
40
  this.user = new UserApi(this.transport, apiBaseUrl, options.setStoredAuthToken, options.clearStoredAuthToken);
41
41
  this.models = new ModelsApi(this.transport);
42
- this.prompts = new PromptsApi(options.fetch ?? fetch, apiBaseUrl);
42
+ this.prompts = new PromptsApi(this.transport);
43
43
  this.sessionAccess = new SessionAccessApi(this.transport);
44
44
  this.tasks = new TasksApi(this.transport);
45
45
  this.cronJobs = new CronJobsApi(this.transport);
package/dist/http.js CHANGED
@@ -27,7 +27,7 @@ export class CohubHttpClient {
27
27
  this.channels = new ChannelsApi(this.transport);
28
28
  this.user = new UserApi(this.transport, apiBaseUrl, options.setStoredAuthToken, options.clearStoredAuthToken);
29
29
  this.models = new ModelsApi(this.transport);
30
- this.prompts = new PromptsApi(options.fetch ?? fetch, apiBaseUrl);
30
+ this.prompts = new PromptsApi(this.transport);
31
31
  this.sessionAccess = new SessionAccessApi(this.transport);
32
32
  this.tasks = new TasksApi(this.transport);
33
33
  this.cronJobs = new CronJobsApi(this.transport);
package/dist/index.d.ts CHANGED
@@ -2,11 +2,13 @@ export { CohubHttpClient, createHttpClient } from "./http.js";
2
2
  export { CohubClient, createCohubClient } from "./client.js";
3
3
  export { WebsocketClient, createWebsocketClient } from "./websocket.js";
4
4
  export { SessionPatchReducer, createSessionPatchReducer } from "./session-patch-reducer.js";
5
+ export { SessionGenerationStreamClient, createSessionGenerationStreamClient, parseAssistantMessageCommit, } from "./session-generation-stream.js";
5
6
  export { HttpError } from "./transport.js";
6
7
  export type { RawHttpResponse } from "./transport.js";
7
8
  export { COHUB_ENVIRONMENTS, normalizeBaseUrl, normalizeWebsocketUrl, resolveApiBaseUrl, resolveCohubEnvironment, resolveWebsocketUrl, } from "./environment.js";
8
9
  export type { CohubClientOptions, Fetch } from "./transport.js";
9
10
  export type { CohubEnvironment } from "./environment.js";
10
11
  export type { SessionPatchApplyInput, SessionPatchApplyResult, SessionPatchState, SessionPatchStatus, } from "./session-patch-reducer.js";
12
+ export type { AssistantMessageCommit, GenerationStreamCommitEvent, GenerationStreamErrorEvent, GenerationStreamEvent, GenerationStreamFinalizedEvent, GenerationStreamIntermediateMessage, GenerationStreamOutOfSyncEvent, GenerationStreamStateEvent, GenerationStreamSubscriptionHandlers, GenerationStreamTurnUpdatedEvent, } from "./session-generation-stream.js";
11
13
  export * from "./types.js";
12
14
  export type { SessionEventName, SessionSubscriptionHandlers, SpaceChannelBindingRecord, SpaceEventName, WebSocketConnectionState } from "./apis/spaces.js";
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ export { CohubHttpClient, createHttpClient } from "./http.js";
2
2
  export { CohubClient, createCohubClient } from "./client.js";
3
3
  export { WebsocketClient, createWebsocketClient } from "./websocket.js";
4
4
  export { SessionPatchReducer, createSessionPatchReducer } from "./session-patch-reducer.js";
5
+ export { SessionGenerationStreamClient, createSessionGenerationStreamClient, parseAssistantMessageCommit, } from "./session-generation-stream.js";
5
6
  export { HttpError } from "./transport.js";
6
7
  export { COHUB_ENVIRONMENTS, normalizeBaseUrl, normalizeWebsocketUrl, resolveApiBaseUrl, resolveCohubEnvironment, resolveWebsocketUrl, } from "./environment.js";
7
8
  export * from "./types.js";
@@ -0,0 +1,114 @@
1
+ import type { ContentBlock, Usage } from "@neta-art/cohub-protocol/core";
2
+ import type { MessageRecord, SessionTurnRecord } from "@neta-art/cohub-protocol/model";
3
+ import { type SessionPatchState } from "./session-patch-reducer.js";
4
+ import type { WebsocketClient, WebsocketEventPayload } from "./websocket.js";
5
+ export type AssistantMessageCommit = {
6
+ kind: "intermediate";
7
+ message: MessageRecord;
8
+ isFinal: false;
9
+ } | {
10
+ kind: "final";
11
+ message: MessageRecord;
12
+ isFinal: true;
13
+ } | {
14
+ kind: "error";
15
+ message: MessageRecord;
16
+ isFinal: true;
17
+ } | {
18
+ kind: "ignored";
19
+ message: MessageRecord;
20
+ isFinal: false;
21
+ };
22
+ export type GenerationStreamIntermediateMessage = {
23
+ id?: string;
24
+ sessionId?: string;
25
+ role?: "user" | "assistant" | "system";
26
+ messageId: string | null;
27
+ messageOrdinal: number | null;
28
+ content: ContentBlock[];
29
+ text?: string | null;
30
+ provider?: string | null;
31
+ model?: string | null;
32
+ stopReason?: string | null;
33
+ errorMessage?: string | null;
34
+ usage?: Usage | null;
35
+ toolCallsObjectKey?: string | null;
36
+ meta?: Record<string, unknown> | null;
37
+ createdAt?: string;
38
+ };
39
+ export type GenerationStreamStateEvent = {
40
+ type: "state";
41
+ source: "snapshot" | "patch" | "progress";
42
+ state: SessionPatchState;
43
+ messageId: string | null;
44
+ messageOrdinal: number | null;
45
+ intermediateMessages: GenerationStreamIntermediateMessage[];
46
+ rawEvent: WebsocketEventPayload;
47
+ };
48
+ export type GenerationStreamCommitEvent = {
49
+ type: "commit";
50
+ commit: AssistantMessageCommit;
51
+ rawEvent: WebsocketEventPayload;
52
+ };
53
+ export type GenerationStreamFinalizedEvent = {
54
+ type: "finalized";
55
+ turn: SessionTurnRecord;
56
+ rawEvent: WebsocketEventPayload;
57
+ };
58
+ export type GenerationStreamTurnUpdatedEvent = {
59
+ type: "turn_updated";
60
+ turn: Partial<SessionTurnRecord>;
61
+ rawEvent: WebsocketEventPayload;
62
+ };
63
+ export type GenerationStreamErrorEvent = {
64
+ type: "error";
65
+ message: string;
66
+ rawEvent: WebsocketEventPayload;
67
+ };
68
+ export type GenerationStreamOutOfSyncEvent = {
69
+ type: "out_of_sync";
70
+ source: "snapshot" | "patch";
71
+ reason: "duplicate" | "version_mismatch" | "invalid";
72
+ state: SessionPatchState;
73
+ rawEvent: WebsocketEventPayload;
74
+ };
75
+ export type GenerationStreamEvent = GenerationStreamStateEvent | GenerationStreamCommitEvent | GenerationStreamFinalizedEvent | GenerationStreamTurnUpdatedEvent | GenerationStreamErrorEvent | GenerationStreamOutOfSyncEvent;
76
+ export type GenerationStreamSubscriptionHandlers = {
77
+ event?: (event: GenerationStreamEvent) => void;
78
+ state?: (event: GenerationStreamStateEvent) => void;
79
+ commit?: (event: GenerationStreamCommitEvent) => void;
80
+ finalized?: (event: GenerationStreamFinalizedEvent) => void;
81
+ turnUpdated?: (event: GenerationStreamTurnUpdatedEvent) => void;
82
+ error?: (event: GenerationStreamErrorEvent) => void;
83
+ outOfSync?: (event: GenerationStreamOutOfSyncEvent) => void;
84
+ };
85
+ export declare function parseAssistantMessageCommit(message: MessageRecord): AssistantMessageCommit;
86
+ export declare class SessionGenerationStreamClient {
87
+ private readonly websocketClient;
88
+ private readonly spaceId;
89
+ private readonly sessionId;
90
+ private readonly reducer;
91
+ private messageId;
92
+ private messageOrdinal;
93
+ private intermediateMessages;
94
+ private progressState;
95
+ constructor(websocketClient: WebsocketClient | null, spaceId: string, sessionId: string);
96
+ subscribe(handlers: GenerationStreamSubscriptionHandlers): () => void;
97
+ private emit;
98
+ private resetCurrentMessage;
99
+ private appendCurrentMessage;
100
+ private addIntermediateMessage;
101
+ private handleAppliedState;
102
+ private prepareMessageBoundary;
103
+ private handleSnapshot;
104
+ private handlePatch;
105
+ private handleProgress;
106
+ private handlePersisted;
107
+ private handleFinalized;
108
+ private handleEvent;
109
+ }
110
+ export declare function createSessionGenerationStreamClient(input: {
111
+ websocketClient: WebsocketClient | null;
112
+ spaceId: string;
113
+ sessionId: string;
114
+ }): SessionGenerationStreamClient;
@@ -0,0 +1,513 @@
1
+ import { ensureRealtimeConnected } from "./realtime.js";
2
+ import { SessionPatchReducer, } from "./session-patch-reducer.js";
3
+ const isRecord = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
4
+ const isContentBlockArray = (value) => Array.isArray(value) &&
5
+ value.every((item) => isRecord(item) && typeof item.type === "string");
6
+ const stringField = (record, key) => {
7
+ const value = record[key];
8
+ return typeof value === "string" ? value : null;
9
+ };
10
+ const numberField = (record, key) => {
11
+ const value = record[key];
12
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
13
+ };
14
+ const getMessageKind = (message) => {
15
+ const kind = message.meta?.messageKind;
16
+ return typeof kind === "string" ? kind : null;
17
+ };
18
+ export function parseAssistantMessageCommit(message) {
19
+ if (message.role !== "assistant") {
20
+ return { kind: "ignored", message, isFinal: false };
21
+ }
22
+ const kind = getMessageKind(message);
23
+ if (kind === "assistant_intermediate") {
24
+ return { kind: "intermediate", message, isFinal: false };
25
+ }
26
+ if (kind === "assistant_final") {
27
+ return { kind: "final", message, isFinal: true };
28
+ }
29
+ if (kind === "assistant_error") {
30
+ return { kind: "error", message, isFinal: true };
31
+ }
32
+ return { kind: "ignored", message, isFinal: false };
33
+ }
34
+ function cloneContentBlock(block) {
35
+ return structuredClone(block);
36
+ }
37
+ function getStreamIndex(block) {
38
+ const value = block._meta?.streamIndex;
39
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
40
+ }
41
+ function findMergeTargetIndex(result, block) {
42
+ const streamIndex = getStreamIndex(block);
43
+ if (streamIndex != null) {
44
+ return result.findIndex((existing) => existing.type === block.type && getStreamIndex(existing) === streamIndex);
45
+ }
46
+ if (block.type === "tool_use") {
47
+ return result.findIndex((existing) => existing.type === "tool_use" && existing.id === block.id);
48
+ }
49
+ if (block.type === "tool_result") {
50
+ return result.findIndex((existing) => existing.type === "tool_result" &&
51
+ existing.tool_use_id === block.tool_use_id);
52
+ }
53
+ return -1;
54
+ }
55
+ function mergeStreamingDeltaBlocks(existing, delta) {
56
+ if (delta.length === 0)
57
+ return existing;
58
+ const result = existing.map(cloneContentBlock);
59
+ for (const block of delta) {
60
+ const targetIndex = findMergeTargetIndex(result, block);
61
+ if (targetIndex === -1) {
62
+ result.push(cloneContentBlock(block));
63
+ continue;
64
+ }
65
+ const target = result[targetIndex];
66
+ if (block.type === "text" && target?.type === "text") {
67
+ target.text += block.text;
68
+ continue;
69
+ }
70
+ if (block.type === "thinking" && target?.type === "thinking") {
71
+ target.thinking += block.thinking;
72
+ if (block.signature)
73
+ target.signature = block.signature;
74
+ if (block._meta)
75
+ target._meta = { ...(target._meta ?? {}), ...block._meta };
76
+ continue;
77
+ }
78
+ result[targetIndex] = Object.assign(target ?? {}, cloneContentBlock(block));
79
+ }
80
+ return result;
81
+ }
82
+ function parseSnapshotMessage(value) {
83
+ if (!isRecord(value) || !isContentBlockArray(value.content))
84
+ return null;
85
+ return {
86
+ messageId: stringField(value, "messageId"),
87
+ messageOrdinal: numberField(value, "messageOrdinal"),
88
+ content: value.content,
89
+ ...(typeof value.id === "string" ? { id: value.id } : {}),
90
+ ...(typeof value.sessionId === "string" ? { sessionId: value.sessionId } : {}),
91
+ ...(value.role === "user" ||
92
+ value.role === "assistant" ||
93
+ value.role === "system"
94
+ ? { role: value.role }
95
+ : {}),
96
+ ...(typeof value.text === "string" ? { text: value.text } : {}),
97
+ ...(typeof value.provider === "string" ? { provider: value.provider } : {}),
98
+ ...(typeof value.model === "string" ? { model: value.model } : {}),
99
+ ...(typeof value.stopReason === "string"
100
+ ? { stopReason: value.stopReason }
101
+ : {}),
102
+ ...(typeof value.errorMessage === "string"
103
+ ? { errorMessage: value.errorMessage }
104
+ : {}),
105
+ ...(isRecord(value.usage) ? { usage: value.usage } : {}),
106
+ ...(typeof value.toolCallsObjectKey === "string"
107
+ ? { toolCallsObjectKey: value.toolCallsObjectKey }
108
+ : {}),
109
+ ...(isRecord(value.meta) ? { meta: value.meta } : {}),
110
+ ...(typeof value.createdAt === "string" ? { createdAt: value.createdAt } : {}),
111
+ };
112
+ }
113
+ function messageRecordToIntermediate(message) {
114
+ if (!isContentBlockArray(message.content) || message.content.length === 0) {
115
+ return null;
116
+ }
117
+ const meta = isRecord(message.meta) ? message.meta : {};
118
+ return {
119
+ id: message.id,
120
+ sessionId: message.sessionId,
121
+ role: message.role,
122
+ messageId: typeof meta.streamMessageId === "string"
123
+ ? meta.streamMessageId
124
+ : message.id ?? null,
125
+ messageOrdinal: typeof meta.messageOrdinal === "number" ? meta.messageOrdinal : null,
126
+ content: message.content,
127
+ text: message.text,
128
+ provider: message.provider,
129
+ model: message.model,
130
+ stopReason: message.stopReason,
131
+ errorMessage: message.errorMessage,
132
+ usage: message.usage,
133
+ meta: message.meta,
134
+ createdAt: message.createdAt,
135
+ };
136
+ }
137
+ function resolveStreamMessageId(input) {
138
+ if (input.messageId?.trim())
139
+ return input.messageId.trim();
140
+ if (input.messageOrdinal == null)
141
+ return null;
142
+ if (input.turnId?.trim()) {
143
+ return `turn:${input.turnId.trim()}:assistant:${input.messageOrdinal}`;
144
+ }
145
+ return `session:${input.sessionId}:assistant:${input.messageOrdinal}:${input.anchorUserMessageId ?? "unknown"}`;
146
+ }
147
+ function getTurnIdFromMessage(message) {
148
+ const turnId = message.meta?.turnId;
149
+ return typeof turnId === "string" ? turnId : null;
150
+ }
151
+ export class SessionGenerationStreamClient {
152
+ websocketClient;
153
+ spaceId;
154
+ sessionId;
155
+ reducer = new SessionPatchReducer();
156
+ messageId = null;
157
+ messageOrdinal = null;
158
+ intermediateMessages = [];
159
+ progressState = null;
160
+ constructor(websocketClient, spaceId, sessionId) {
161
+ this.websocketClient = websocketClient;
162
+ this.spaceId = spaceId;
163
+ this.sessionId = sessionId;
164
+ }
165
+ subscribe(handlers) {
166
+ if (!this.websocketClient) {
167
+ throw new Error("realtime transport is not configured for this client");
168
+ }
169
+ ensureRealtimeConnected(this.websocketClient);
170
+ const unsubscribe = this.websocketClient.on("event", (event) => {
171
+ if (event.spaceId !== this.spaceId || event.sessionId !== this.sessionId) {
172
+ return;
173
+ }
174
+ this.handleEvent(event, handlers);
175
+ });
176
+ return () => unsubscribe();
177
+ }
178
+ emit(handlers, event) {
179
+ handlers.event?.(event);
180
+ if (event.type === "state")
181
+ handlers.state?.(event);
182
+ if (event.type === "commit")
183
+ handlers.commit?.(event);
184
+ if (event.type === "finalized")
185
+ handlers.finalized?.(event);
186
+ if (event.type === "turn_updated")
187
+ handlers.turnUpdated?.(event);
188
+ if (event.type === "error")
189
+ handlers.error?.(event);
190
+ if (event.type === "out_of_sync")
191
+ handlers.outOfSync?.(event);
192
+ }
193
+ resetCurrentMessage() {
194
+ this.messageId = null;
195
+ this.messageOrdinal = null;
196
+ this.progressState = null;
197
+ }
198
+ appendCurrentMessage(state) {
199
+ if (state.contentBlocks.length === 0)
200
+ return;
201
+ this.addIntermediateMessage({
202
+ messageId: this.messageId,
203
+ messageOrdinal: this.messageOrdinal,
204
+ content: state.contentBlocks,
205
+ });
206
+ }
207
+ addIntermediateMessage(message) {
208
+ const index = this.intermediateMessages.findIndex((existing) => {
209
+ if (message.messageId && existing.messageId) {
210
+ return existing.messageId === message.messageId;
211
+ }
212
+ return (message.messageOrdinal !== null &&
213
+ existing.messageOrdinal === message.messageOrdinal);
214
+ });
215
+ if (index < 0) {
216
+ this.intermediateMessages = [...this.intermediateMessages, message];
217
+ return;
218
+ }
219
+ this.intermediateMessages = this.intermediateMessages.map((existing, i) => i === index ? { ...existing, ...message } : existing);
220
+ }
221
+ handleAppliedState(handlers, source, result, rawEvent, messageId, messageOrdinal) {
222
+ if (!result.applied) {
223
+ this.emit(handlers, {
224
+ type: "out_of_sync",
225
+ source: source === "progress" ? "patch" : source,
226
+ reason: result.reason,
227
+ state: result.state,
228
+ rawEvent,
229
+ });
230
+ return;
231
+ }
232
+ this.progressState = result.state;
233
+ this.messageId = messageId;
234
+ this.messageOrdinal = messageOrdinal;
235
+ this.emit(handlers, {
236
+ type: "state",
237
+ source,
238
+ state: result.state,
239
+ messageId,
240
+ messageOrdinal,
241
+ intermediateMessages: [...this.intermediateMessages],
242
+ rawEvent,
243
+ });
244
+ }
245
+ prepareMessageBoundary(input) {
246
+ const current = this.progressState ??
247
+ this.reducer.get({
248
+ spaceId: this.spaceId,
249
+ sessionId: this.sessionId,
250
+ });
251
+ const nextMessageId = resolveStreamMessageId({
252
+ sessionId: this.sessionId,
253
+ turnId: input.turnId,
254
+ anchorUserMessageId: input.anchorUserMessageId,
255
+ messageId: input.messageId,
256
+ messageOrdinal: input.messageOrdinal,
257
+ });
258
+ const differentTurn = Boolean(current.turnId && input.turnId && current.turnId !== input.turnId);
259
+ const messageChanged = Boolean(nextMessageId &&
260
+ current.contentBlocks.length > 0 &&
261
+ this.messageId &&
262
+ nextMessageId !== this.messageId);
263
+ if (differentTurn) {
264
+ this.intermediateMessages = [];
265
+ this.resetCurrentMessage();
266
+ this.reducer.start({
267
+ spaceId: this.spaceId,
268
+ sessionId: this.sessionId,
269
+ turnId: input.turnId,
270
+ });
271
+ }
272
+ else if (messageChanged) {
273
+ this.appendCurrentMessage(current);
274
+ this.resetCurrentMessage();
275
+ this.reducer.start({
276
+ spaceId: this.spaceId,
277
+ sessionId: this.sessionId,
278
+ turnId: input.turnId ?? current.turnId,
279
+ });
280
+ }
281
+ return nextMessageId;
282
+ }
283
+ handleSnapshot(event, handlers) {
284
+ const payload = event.payload;
285
+ const current = isRecord(payload.current) ? payload.current : null;
286
+ const content = current ? current.content : null;
287
+ const seq = typeof payload.seq === "number" ? payload.seq : null;
288
+ if (!current || !isContentBlockArray(content) || seq === null) {
289
+ this.emit(handlers, {
290
+ type: "out_of_sync",
291
+ source: "snapshot",
292
+ reason: "invalid",
293
+ state: this.reducer.get({ spaceId: this.spaceId, sessionId: this.sessionId }),
294
+ rawEvent: event,
295
+ });
296
+ return;
297
+ }
298
+ const turnId = typeof payload.turnId === "string" ? payload.turnId : null;
299
+ const anchorUserMessageId = typeof payload.anchorUserMessageId === "string"
300
+ ? payload.anchorUserMessageId
301
+ : null;
302
+ const messageOrdinal = numberField(current, "messageOrdinal");
303
+ const messageId = this.prepareMessageBoundary({
304
+ turnId,
305
+ messageId: stringField(current, "messageId"),
306
+ messageOrdinal,
307
+ anchorUserMessageId,
308
+ });
309
+ this.intermediateMessages = Array.isArray(payload.intermediateMessages)
310
+ ? payload.intermediateMessages
311
+ .map(parseSnapshotMessage)
312
+ .filter((message) => message !== null)
313
+ : [];
314
+ const result = this.reducer.applySnapshot({
315
+ spaceId: this.spaceId,
316
+ sessionId: this.sessionId,
317
+ turnId,
318
+ seq,
319
+ contentBlocks: content,
320
+ anchorUserMessageId,
321
+ appendPath: stringField(current, "appendPath"),
322
+ });
323
+ this.handleAppliedState(handlers, "snapshot", result, event, messageId, messageOrdinal);
324
+ }
325
+ handlePatch(event, handlers) {
326
+ const payload = event.payload;
327
+ const seq = typeof payload.seq === "number" ? payload.seq : null;
328
+ const baseSeq = typeof payload.baseSeq === "number" ? payload.baseSeq : null;
329
+ if (seq === null || baseSeq === null || !Array.isArray(payload.ops)) {
330
+ this.emit(handlers, {
331
+ type: "out_of_sync",
332
+ source: "patch",
333
+ reason: "invalid",
334
+ state: this.reducer.get({ spaceId: this.spaceId, sessionId: this.sessionId }),
335
+ rawEvent: event,
336
+ });
337
+ return;
338
+ }
339
+ const turnId = typeof payload.turnId === "string" ? payload.turnId : null;
340
+ const anchorUserMessageId = typeof payload.anchorUserMessageId === "string"
341
+ ? payload.anchorUserMessageId
342
+ : null;
343
+ const messageOrdinal = typeof payload.messageOrdinal === "number" ? payload.messageOrdinal : null;
344
+ const messageId = this.prepareMessageBoundary({
345
+ turnId,
346
+ messageId: typeof payload.messageId === "string" ? payload.messageId : null,
347
+ messageOrdinal,
348
+ anchorUserMessageId,
349
+ });
350
+ const input = {
351
+ spaceId: this.spaceId,
352
+ sessionId: this.sessionId,
353
+ turnId,
354
+ seq,
355
+ baseSeq,
356
+ ops: payload.ops,
357
+ anchorUserMessageId,
358
+ };
359
+ const result = this.reducer.applyPatch(input);
360
+ this.handleAppliedState(handlers, "patch", result, event, messageId, messageOrdinal);
361
+ }
362
+ handleProgress(event, handlers) {
363
+ const payload = event.payload;
364
+ if (!isContentBlockArray(payload.content))
365
+ return;
366
+ const current = this.progressState ??
367
+ this.reducer.get({ spaceId: this.spaceId, sessionId: this.sessionId });
368
+ const turnId = typeof payload.turnId === "string" ? payload.turnId : current.turnId;
369
+ const anchorUserMessageId = typeof payload.anchorUserMessageId === "string"
370
+ ? payload.anchorUserMessageId
371
+ : current.anchorUserMessageId;
372
+ const messageOrdinal = typeof payload.messageOrdinal === "number"
373
+ ? payload.messageOrdinal
374
+ : this.messageOrdinal;
375
+ const messageId = this.prepareMessageBoundary({
376
+ turnId,
377
+ messageId: typeof payload.messageId === "string" ? payload.messageId : this.messageId,
378
+ messageOrdinal,
379
+ anchorUserMessageId,
380
+ });
381
+ const base = this.progressState?.turnId === turnId ? this.progressState : current;
382
+ const state = {
383
+ ...base,
384
+ spaceId: this.spaceId,
385
+ sessionId: this.sessionId,
386
+ status: "streaming",
387
+ contentBlocks: mergeStreamingDeltaBlocks(base.contentBlocks, payload.content),
388
+ anchorUserMessageId,
389
+ turnId,
390
+ };
391
+ this.progressState = state;
392
+ this.messageId = messageId;
393
+ this.messageOrdinal = messageOrdinal;
394
+ this.emit(handlers, {
395
+ type: "state",
396
+ source: "progress",
397
+ state,
398
+ messageId,
399
+ messageOrdinal,
400
+ intermediateMessages: [...this.intermediateMessages],
401
+ rawEvent: event,
402
+ });
403
+ }
404
+ handlePersisted(event, handlers) {
405
+ const message = event.payload.message;
406
+ if (!isRecord(message))
407
+ return;
408
+ const commit = parseAssistantMessageCommit(message);
409
+ if (commit.kind === "intermediate") {
410
+ const intermediate = messageRecordToIntermediate(commit.message);
411
+ if (intermediate) {
412
+ this.addIntermediateMessage(intermediate);
413
+ }
414
+ this.reducer.reset({ spaceId: this.spaceId, sessionId: this.sessionId });
415
+ this.resetCurrentMessage();
416
+ }
417
+ if (commit.kind === "final") {
418
+ this.reducer.complete({
419
+ spaceId: this.spaceId,
420
+ sessionId: this.sessionId,
421
+ turnId: getTurnIdFromMessage(commit.message),
422
+ });
423
+ this.resetCurrentMessage();
424
+ }
425
+ if (commit.kind === "error") {
426
+ this.reducer.fail({
427
+ spaceId: this.spaceId,
428
+ sessionId: this.sessionId,
429
+ turnId: getTurnIdFromMessage(commit.message),
430
+ });
431
+ this.resetCurrentMessage();
432
+ }
433
+ this.emit(handlers, {
434
+ type: "commit",
435
+ commit,
436
+ rawEvent: event,
437
+ });
438
+ }
439
+ handleFinalized(event, handlers) {
440
+ const turn = event.payload.turn;
441
+ if (!isRecord(turn))
442
+ return;
443
+ const typedTurn = turn;
444
+ if (typedTurn.status === "interrupted") {
445
+ this.reducer.interrupt({
446
+ spaceId: this.spaceId,
447
+ sessionId: this.sessionId,
448
+ turnId: typedTurn.id,
449
+ });
450
+ }
451
+ else {
452
+ this.reducer.complete({
453
+ spaceId: this.spaceId,
454
+ sessionId: this.sessionId,
455
+ turnId: typedTurn.id,
456
+ });
457
+ }
458
+ this.resetCurrentMessage();
459
+ this.emit(handlers, {
460
+ type: "finalized",
461
+ turn: typedTurn,
462
+ rawEvent: event,
463
+ });
464
+ }
465
+ handleEvent(event, handlers) {
466
+ switch (event.type) {
467
+ case "session.turn.snapshot":
468
+ this.handleSnapshot(event, handlers);
469
+ return;
470
+ case "session.turn.patch":
471
+ this.handlePatch(event, handlers);
472
+ return;
473
+ case "session.turn.progress":
474
+ this.handleProgress(event, handlers);
475
+ return;
476
+ case "session.message.persisted":
477
+ this.handlePersisted(event, handlers);
478
+ return;
479
+ case "session.turn.finalized":
480
+ this.handleFinalized(event, handlers);
481
+ return;
482
+ case "session.turn.updated": {
483
+ const turn = event.payload.turn;
484
+ if (!isRecord(turn))
485
+ return;
486
+ this.emit(handlers, {
487
+ type: "turn_updated",
488
+ turn: turn,
489
+ rawEvent: event,
490
+ });
491
+ return;
492
+ }
493
+ case "session.turn.error": {
494
+ const message = typeof event.payload.error === "string" && event.payload.error.trim()
495
+ ? event.payload.error.trim()
496
+ : "Generation failed";
497
+ this.reducer.fail({ spaceId: this.spaceId, sessionId: this.sessionId });
498
+ this.resetCurrentMessage();
499
+ this.emit(handlers, {
500
+ type: "error",
501
+ message,
502
+ rawEvent: event,
503
+ });
504
+ return;
505
+ }
506
+ default:
507
+ return;
508
+ }
509
+ }
510
+ }
511
+ export function createSessionGenerationStreamClient(input) {
512
+ return new SessionGenerationStreamClient(input.websocketClient, input.spaceId, input.sessionId);
513
+ }
@@ -20,9 +20,27 @@ function getStreamIndex(block) {
20
20
  const value = block._meta?.streamIndex;
21
21
  return typeof value === "number" && Number.isFinite(value) ? value : null;
22
22
  }
23
+ function blockIdentityCompatible(prev, next) {
24
+ if (prev.type !== next.type)
25
+ return false;
26
+ if (prev.type === "tool_use" && next.type === "tool_use")
27
+ return prev.id === next.id && prev.name === next.name;
28
+ if (prev.type === "tool_result" && next.type === "tool_result")
29
+ return prev.tool_use_id === next.tool_use_id;
30
+ return true;
31
+ }
23
32
  function findBlockByStreamIndex(blocks, streamIndex) {
24
33
  return blocks.findIndex((block) => getStreamIndex(block) === streamIndex);
25
34
  }
35
+ function findBlockForReplacement(blocks, streamIndex, nextBlock) {
36
+ const compatibleIndex = blocks.findIndex((block) => getStreamIndex(block) === streamIndex && blockIdentityCompatible(block, nextBlock));
37
+ if (compatibleIndex >= 0)
38
+ return compatibleIndex;
39
+ const sameStreamIndex = blocks.findIndex((block) => getStreamIndex(block) === streamIndex);
40
+ if (sameStreamIndex >= 0 && nextBlock.type !== "tool_use" && nextBlock.type !== "tool_result")
41
+ return sameStreamIndex;
42
+ return -1;
43
+ }
26
44
  function sortBlocksByStreamIndex(blocks) {
27
45
  return [...blocks].sort((a, b) => {
28
46
  const aIndex = getStreamIndex(a);
@@ -227,7 +245,7 @@ function applyPatchOpsToBlocks(current, ops, initialAppendPath) {
227
245
  const streamIndex = Number(match[1]);
228
246
  const block = cloneBlock(op.v);
229
247
  block._meta = { ...(block._meta ?? {}), streamIndex };
230
- const blockIndex = findBlockByStreamIndex(next, streamIndex);
248
+ const blockIndex = findBlockForReplacement(next, streamIndex, block);
231
249
  if (blockIndex >= 0) {
232
250
  next[blockIndex] = block;
233
251
  }
package/dist/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { SessionBindingRecord as ProtocolSessionBindingRecord, SessionRecord as ProtocolSessionRecord, SessionTurnIndexItem, SessionTurnRecord } from "@neta-art/cohub-protocol/model";
1
+ import type { SessionBindingRecord as ProtocolSessionBindingRecord, SessionRecord as ProtocolSessionRecord, SessionForkRecord, SessionTurnIndexItem, SessionTurnRecord, SessionTurnSegmentRecord } from "@neta-art/cohub-protocol/model";
2
2
  import type { ChannelConfig } from "@neta-art/cohub-protocol/gateway";
3
3
  import type { ContentBlock, Usage } from "@neta-art/cohub-protocol/core";
4
4
  import type { MessageRecord } from "@neta-art/cohub-protocol/model";
@@ -24,7 +24,7 @@ export type UserRulesResponse = {
24
24
  source: "config-space";
25
25
  path: string;
26
26
  };
27
- export type { ContentBlock, MessageRecord, SessionTurnRecord, SessionTurnIndexItem };
27
+ export type { ContentBlock, MessageRecord, SessionTurnRecord, SessionTurnIndexItem, SessionForkRecord, SessionTurnSegmentRecord };
28
28
  export type SpaceFsEntry = {
29
29
  name: string;
30
30
  path: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neta-art/cohub",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Cohub SDK for spaces, sessions, checkpoints, and realtime agent collaboration.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -44,7 +44,7 @@
44
44
  "README.md"
45
45
  ],
46
46
  "dependencies": {
47
- "@neta-art/cohub-protocol": "1.4.0"
47
+ "@neta-art/cohub-protocol": "1.5.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "typescript": "^6.0.3"