@neta-art/cohub 1.5.1 → 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;
@@ -148,7 +157,21 @@ export declare class SessionClient {
148
157
  rename(title: string | null, customFetch?: Fetch): Promise<{
149
158
  session: SessionRecord;
150
159
  }>;
160
+ abort(optionsOrFetch?: {
161
+ turnId?: string | null;
162
+ } | Fetch, customFetch?: Fetch): Promise<{
163
+ ok: true;
164
+ }>;
165
+ turn(turnId: string): {
166
+ fork: (input?: {
167
+ title?: string | null;
168
+ }) => Promise<{
169
+ session: SessionRecord;
170
+ fork: SessionForkRecord;
171
+ }>;
172
+ };
151
173
  subscribe(handlers: SessionSubscriptionHandlers): () => void;
174
+ subscribeGeneration(handlers: GenerationStreamSubscriptionHandlers): () => void;
152
175
  on(type: SessionEventName, handler: (event: WebsocketEventPayload) => void): () => void;
153
176
  }
154
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}`, {
@@ -425,9 +428,31 @@ export class SessionClient {
425
428
  fetch: customFetch,
426
429
  });
427
430
  }
431
+ abort(optionsOrFetch, customFetch) {
432
+ const options = typeof optionsOrFetch === "function" ? undefined : optionsOrFetch;
433
+ const fetch = typeof optionsOrFetch === "function" ? optionsOrFetch : customFetch;
434
+ return this.transport.request(`/api/sessions/${this.id}/abort`, {
435
+ method: "POST",
436
+ headers: { "Content-Type": "application/json" },
437
+ body: JSON.stringify({ turnId: options?.turnId ?? null }),
438
+ fetch,
439
+ });
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
+ }
428
450
  subscribe(handlers) {
429
451
  return this.realtime.subscribe(handlers);
430
452
  }
453
+ subscribeGeneration(handlers) {
454
+ return this.generation.subscribe(handlers);
455
+ }
431
456
  on(type, handler) {
432
457
  return this.realtime.on(type, handler);
433
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
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ContentBlock } from "@neta-art/cohub-protocol/core";
2
2
  import type { RealtimePatchOperation, SessionTurnPatchEvent } from "@neta-art/cohub-protocol/realtime";
3
- export type SessionPatchStatus = "idle" | "pending" | "streaming" | "completed" | "failed";
3
+ export type SessionPatchStatus = "idle" | "pending" | "streaming" | "completed" | "failed" | "interrupted";
4
4
  export type SessionPatchState = {
5
5
  spaceId: string | null;
6
6
  sessionId: string;
@@ -50,6 +50,7 @@ export declare class SessionPatchReducer {
50
50
  }): SessionPatchState;
51
51
  complete(input: SessionPatchKeyInput): SessionPatchState;
52
52
  fail(input: SessionPatchKeyInput): SessionPatchState;
53
+ interrupt(input: SessionPatchKeyInput): SessionPatchState;
53
54
  reset(input: SessionPatchKeyInput): void;
54
55
  applySnapshot(input: SessionPatchSnapshotInput): SessionPatchApplyResult;
55
56
  resetAll(): void;
@@ -1,6 +1,8 @@
1
1
  const blockSubPathPattern = /^\/message\/content\/blocks\/(\d+)\/(.+)$/;
2
2
  const blockPathPattern = /^\/message\/content\/blocks\/(\d+)$/;
3
3
  const blockMetaPathPattern = /^\/message\/content\/blocks\/(\d+)\/_meta$/;
4
+ const TERMINAL_PATCH_STATUSES = new Set(["completed", "failed", "interrupted"]);
5
+ const isTerminalPatchStatus = (status) => TERMINAL_PATCH_STATUSES.has(status);
4
6
  const createIdleState = (input) => ({
5
7
  spaceId: input.spaceId ?? null,
6
8
  sessionId: input.sessionId,
@@ -18,9 +20,27 @@ function getStreamIndex(block) {
18
20
  const value = block._meta?.streamIndex;
19
21
  return typeof value === "number" && Number.isFinite(value) ? value : null;
20
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
+ }
21
32
  function findBlockByStreamIndex(blocks, streamIndex) {
22
33
  return blocks.findIndex((block) => getStreamIndex(block) === streamIndex);
23
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
+ }
24
44
  function sortBlocksByStreamIndex(blocks) {
25
45
  return [...blocks].sort((a, b) => {
26
46
  const aIndex = getStreamIndex(a);
@@ -225,7 +245,7 @@ function applyPatchOpsToBlocks(current, ops, initialAppendPath) {
225
245
  const streamIndex = Number(match[1]);
226
246
  const block = cloneBlock(op.v);
227
247
  block._meta = { ...(block._meta ?? {}), streamIndex };
228
- const blockIndex = findBlockByStreamIndex(next, streamIndex);
248
+ const blockIndex = findBlockForReplacement(next, streamIndex, block);
229
249
  if (blockIndex >= 0) {
230
250
  next[blockIndex] = block;
231
251
  }
@@ -305,6 +325,19 @@ export class SessionPatchReducer {
305
325
  this.states.set(this.key(input), state);
306
326
  return state;
307
327
  }
328
+ interrupt(input) {
329
+ const current = this.get(input);
330
+ const state = {
331
+ ...current,
332
+ turnId: input.turnId ?? current.turnId,
333
+ status: "interrupted",
334
+ contentBlocks: [],
335
+ anchorUserMessageId: null,
336
+ appendPath: null,
337
+ };
338
+ this.states.set(this.key(input), state);
339
+ return state;
340
+ }
308
341
  reset(input) {
309
342
  this.states.delete(this.key(input));
310
343
  }
@@ -313,7 +346,8 @@ export class SessionPatchReducer {
313
346
  const inputTurnId = input.turnId ?? null;
314
347
  const currentTurnId = current.turnId;
315
348
  const isDifferentKnownTurn = Boolean(currentTurnId && inputTurnId && currentTurnId !== inputTurnId);
316
- if (!isDifferentKnownTurn && input.seq < current.patchSeq) {
349
+ const isTerminalSameTurn = isTerminalPatchStatus(current.status) && Boolean(currentTurnId) && currentTurnId === inputTurnId;
350
+ if (isTerminalSameTurn || (!isDifferentKnownTurn && input.seq < current.patchSeq)) {
317
351
  return { applied: false, reason: "duplicate", state: current };
318
352
  }
319
353
  const state = {
@@ -356,7 +390,7 @@ export class SessionPatchReducer {
356
390
  currentTurnId === inputTurnId &&
357
391
  input.baseSeq === 0 &&
358
392
  input.seq >= currentSeq);
359
- const isTerminalSameTurn = (current.status === "completed" || current.status === "failed") &&
393
+ const isTerminalSameTurn = isTerminalPatchStatus(current.status) &&
360
394
  Boolean(currentTurnId) &&
361
395
  currentTurnId === inputTurnId;
362
396
  if (isTerminalSameTurn) {
package/dist/types.d.ts CHANGED
@@ -1,6 +1,6 @@
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
- import type { ContentBlock } from "@neta-art/cohub-protocol/core";
3
+ import type { ContentBlock, Usage } from "@neta-art/cohub-protocol/core";
4
4
  import type { MessageRecord } from "@neta-art/cohub-protocol/model";
5
5
  export type { ChannelConfig, DiscordChannelConfig, } from "@neta-art/cohub-protocol/gateway";
6
6
  export type ApiError = {
@@ -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;
@@ -226,6 +226,18 @@ export type SessionTurnStreamSnapshotResponse = {
226
226
  messageId: string | null;
227
227
  messageOrdinal: number | null;
228
228
  content: ContentBlock[];
229
+ id?: string;
230
+ sessionId?: string;
231
+ role?: "user" | "assistant" | "system";
232
+ text?: string | null;
233
+ provider?: string | null;
234
+ model?: string | null;
235
+ stopReason?: string | null;
236
+ errorMessage?: string | null;
237
+ usage?: Usage | null;
238
+ toolCallsObjectKey?: string | null;
239
+ meta?: Record<string, unknown> | null;
240
+ createdAt?: string;
229
241
  }>;
230
242
  updatedAt: number;
231
243
  } | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neta-art/cohub",
3
- "version": "1.5.1",
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.3.0"
47
+ "@neta-art/cohub-protocol": "1.5.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "typescript": "^6.0.3"