@yadsh/dsh-qa-surface 0.1.1 → 0.3.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.
Files changed (192) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +239 -16
  3. package/SPEC-dsh-qa-surface.md +600 -96
  4. package/capability-policy.json +5 -0
  5. package/compatibility.json +18 -4
  6. package/cordis.patch.yml +11 -0
  7. package/docs/ARCHITECTURE.md +50 -8
  8. package/docs/COMPATIBILITY.md +5 -4
  9. package/docs/CONFIGURATION.md +184 -3
  10. package/docs/SOURCES-MIGRATION.md +28 -0
  11. package/docs/SOURCES-PROVENANCE-AUDIT.md +88 -0
  12. package/docs/SPEC-dsh-qa-surface-accounts.md +219 -0
  13. package/docs/SPEC-dsh-qa-surface-sources-provenance.md +1692 -0
  14. package/lib/accounts/index.js +2 -0
  15. package/lib/accounts/index.js.map +1 -0
  16. package/lib/accounts/store.js +478 -0
  17. package/lib/accounts/store.js.map +1 -0
  18. package/lib/attestation.js +10 -0
  19. package/lib/attestation.js.map +1 -0
  20. package/lib/cli.js +143 -0
  21. package/lib/cli.js.map +1 -0
  22. package/lib/client/QaAccountsController.js +329 -0
  23. package/lib/client/QaAccountsController.js.map +1 -0
  24. package/lib/client/QaConfigController.js +111 -4
  25. package/lib/client/QaConfigController.js.map +1 -1
  26. package/lib/client/QaSessionController.js +621 -163
  27. package/lib/client/QaSessionController.js.map +1 -1
  28. package/lib/client/QaSurface.js +303 -24
  29. package/lib/client/QaSurface.js.map +1 -1
  30. package/lib/client/QaTranscriptAdapter.js +324 -49
  31. package/lib/client/QaTranscriptAdapter.js.map +1 -1
  32. package/lib/client/attestation.js +66 -0
  33. package/lib/client/attestation.js.map +1 -0
  34. package/lib/client/chat-index.js +86 -0
  35. package/lib/client/chat-index.js.map +1 -0
  36. package/lib/client/components/Markdown.js +124 -13
  37. package/lib/client/components/Markdown.js.map +1 -1
  38. package/lib/client/components/QaAgentsDrawer.js +40 -0
  39. package/lib/client/components/QaAgentsDrawer.js.map +1 -0
  40. package/lib/client/components/QaAuthGate.js +40 -0
  41. package/lib/client/components/QaAuthGate.js.map +1 -0
  42. package/lib/client/components/QaChangelog.js +112 -0
  43. package/lib/client/components/QaChangelog.js.map +1 -0
  44. package/lib/client/components/QaComposer.js +132 -15
  45. package/lib/client/components/QaComposer.js.map +1 -1
  46. package/lib/client/components/QaMessage.js +214 -10
  47. package/lib/client/components/QaMessage.js.map +1 -1
  48. package/lib/client/components/QaSidebar.js +167 -0
  49. package/lib/client/components/QaSidebar.js.map +1 -0
  50. package/lib/client/components/QaSourcesDrawer.js +127 -0
  51. package/lib/client/components/QaSourcesDrawer.js.map +1 -0
  52. package/lib/client/components/QaTurnRail.js +281 -0
  53. package/lib/client/components/QaTurnRail.js.map +1 -0
  54. package/lib/client/components/QaWelcomeNotice.js +125 -0
  55. package/lib/client/components/QaWelcomeNotice.js.map +1 -0
  56. package/lib/client/components/QaWidthHandle.js +142 -0
  57. package/lib/client/components/QaWidthHandle.js.map +1 -0
  58. package/lib/client/components/QaWorkGroup.js +135 -0
  59. package/lib/client/components/QaWorkGroup.js.map +1 -0
  60. package/lib/client/components/VariantSwitcher.js +29 -0
  61. package/lib/client/components/VariantSwitcher.js.map +1 -0
  62. package/lib/client/components/format.js +48 -0
  63. package/lib/client/components/format.js.map +1 -0
  64. package/lib/client/components/source-refs.js +78 -0
  65. package/lib/client/components/source-refs.js.map +1 -0
  66. package/lib/client/create-session.js +51 -0
  67. package/lib/client/create-session.js.map +1 -0
  68. package/lib/client/index.js +166 -35
  69. package/lib/client/index.js.map +1 -1
  70. package/lib/client/project-session-state.js +78 -0
  71. package/lib/client/project-session-state.js.map +1 -0
  72. package/lib/client/session-admission.js +38 -0
  73. package/lib/client/session-admission.js.map +1 -0
  74. package/lib/client/session-assets.js +52 -0
  75. package/lib/client/session-assets.js.map +1 -0
  76. package/lib/client/session-sources.js +61 -0
  77. package/lib/client/session-sources.js.map +1 -0
  78. package/lib/client/settlement.js +24 -0
  79. package/lib/client/settlement.js.map +1 -0
  80. package/lib/client/styles.js +450 -43
  81. package/lib/client/styles.js.map +1 -1
  82. package/lib/client/tool-presentation.js +102 -0
  83. package/lib/client/tool-presentation.js.map +1 -0
  84. package/lib/client/turn-sources.js +98 -0
  85. package/lib/client/turn-sources.js.map +1 -0
  86. package/lib/client/types.js +15 -0
  87. package/lib/client/types.js.map +1 -0
  88. package/lib/client/use-session-ui-state.js +68 -0
  89. package/lib/client/use-session-ui-state.js.map +1 -0
  90. package/lib/client/use-transcript-view.js +44 -0
  91. package/lib/client/use-transcript-view.js.map +1 -0
  92. package/lib/client/wait-for.js +49 -0
  93. package/lib/client/wait-for.js.map +1 -0
  94. package/lib/client.js +12551 -918
  95. package/lib/client.js.map +1 -1
  96. package/lib/config.js +226 -58
  97. package/lib/config.js.map +1 -1
  98. package/lib/entry-redirect.js +44 -0
  99. package/lib/entry-redirect.js.map +1 -0
  100. package/lib/host-route.js +25 -1
  101. package/lib/host-route.js.map +1 -1
  102. package/lib/index.js +354 -72
  103. package/lib/index.js.map +1 -1
  104. package/lib/launch-token.js +42 -0
  105. package/lib/launch-token.js.map +1 -0
  106. package/lib/lockdown-policy.js +20 -0
  107. package/lib/lockdown-policy.js.map +1 -0
  108. package/lib/provenance/collector.js +40 -0
  109. package/lib/provenance/collector.js.map +1 -0
  110. package/lib/provenance/connectors.js +147 -0
  111. package/lib/provenance/connectors.js.map +1 -0
  112. package/lib/provenance/dedupe.js +89 -0
  113. package/lib/provenance/dedupe.js.map +1 -0
  114. package/lib/provenance/extractors.js +340 -0
  115. package/lib/provenance/extractors.js.map +1 -0
  116. package/lib/provenance/file-preview.js +39 -0
  117. package/lib/provenance/file-preview.js.map +1 -0
  118. package/lib/provenance/host-store.js +387 -0
  119. package/lib/provenance/host-store.js.map +1 -0
  120. package/lib/provenance/index.js +9 -0
  121. package/lib/provenance/index.js.map +1 -0
  122. package/lib/provenance/normalize.js +128 -0
  123. package/lib/provenance/normalize.js.map +1 -0
  124. package/lib/provenance/reported.js +53 -0
  125. package/lib/provenance/reported.js.map +1 -0
  126. package/lib/provenance/types.js +2 -0
  127. package/lib/provenance/types.js.map +1 -0
  128. package/lib/resolve-config.js +283 -10
  129. package/lib/resolve-config.js.map +1 -1
  130. package/lib/secure-session.js +212 -0
  131. package/lib/secure-session.js.map +1 -0
  132. package/lib/typert.host.d.ts +3 -0
  133. package/lib/typert.host.js +784 -0
  134. package/lib/typert.remote-client.d.ts +40 -0
  135. package/lib/typert.remote-client.js +597 -0
  136. package/lib/types/accounts/index.d.ts +2 -0
  137. package/lib/types/accounts/store.d.ts +121 -0
  138. package/lib/types/attestation.d.ts +17 -0
  139. package/lib/types/cli.d.ts +9 -0
  140. package/lib/types/client/QaAccountsController.d.ts +95 -0
  141. package/lib/types/client/QaConfigController.d.ts +35 -3
  142. package/lib/types/client/QaSessionController.d.ts +146 -20
  143. package/lib/types/client/QaSurface.d.ts +11 -5
  144. package/lib/types/client/QaTranscriptAdapter.d.ts +13 -3
  145. package/lib/types/client/attestation.d.ts +25 -0
  146. package/lib/types/client/chat-index.d.ts +28 -0
  147. package/lib/types/client/components/Markdown.d.ts +17 -3
  148. package/lib/types/client/components/QaAgentsDrawer.d.ts +25 -0
  149. package/lib/types/client/components/QaAuthGate.d.ts +15 -0
  150. package/lib/types/client/components/QaChangelog.d.ts +21 -0
  151. package/lib/types/client/components/QaComposer.d.ts +12 -2
  152. package/lib/types/client/components/QaMessage.d.ts +22 -2
  153. package/lib/types/client/components/QaSidebar.d.ts +69 -0
  154. package/lib/types/client/components/QaSourcesDrawer.d.ts +20 -0
  155. package/lib/types/client/components/QaTurnRail.d.ts +59 -0
  156. package/lib/types/client/components/QaWelcomeNotice.d.ts +27 -0
  157. package/lib/types/client/components/QaWidthHandle.d.ts +36 -0
  158. package/lib/types/client/components/QaWorkGroup.d.ts +19 -0
  159. package/lib/types/client/components/VariantSwitcher.d.ts +19 -0
  160. package/lib/types/client/components/format.d.ts +9 -0
  161. package/lib/types/client/components/source-refs.d.ts +16 -0
  162. package/lib/types/client/create-session.d.ts +16 -0
  163. package/lib/types/client/index.d.ts +2 -5
  164. package/lib/types/client/project-session-state.d.ts +30 -0
  165. package/lib/types/client/session-admission.d.ts +23 -0
  166. package/lib/types/client/session-assets.d.ts +20 -0
  167. package/lib/types/client/session-sources.d.ts +28 -0
  168. package/lib/types/client/settlement.d.ts +6 -0
  169. package/lib/types/client/tool-presentation.d.ts +6 -0
  170. package/lib/types/client/turn-sources.d.ts +14 -0
  171. package/lib/types/client/types.d.ts +93 -0
  172. package/lib/types/client/use-session-ui-state.d.ts +35 -0
  173. package/lib/types/client/use-transcript-view.d.ts +20 -0
  174. package/lib/types/client/wait-for.d.ts +11 -0
  175. package/lib/types/entry-redirect.d.ts +20 -0
  176. package/lib/types/host-route.d.ts +14 -1
  177. package/lib/types/index.d.ts +67 -7
  178. package/lib/types/launch-token.d.ts +21 -0
  179. package/lib/types/lockdown-policy.d.ts +18 -0
  180. package/lib/types/provenance/collector.d.ts +26 -0
  181. package/lib/types/provenance/connectors.d.ts +11 -0
  182. package/lib/types/provenance/dedupe.d.ts +6 -0
  183. package/lib/types/provenance/extractors.d.ts +14 -0
  184. package/lib/types/provenance/file-preview.d.ts +10 -0
  185. package/lib/types/provenance/host-store.d.ts +54 -0
  186. package/lib/types/provenance/index.d.ts +10 -0
  187. package/lib/types/provenance/normalize.d.ts +13 -0
  188. package/lib/types/provenance/reported.d.ts +5 -0
  189. package/lib/types/provenance/types.d.ts +78 -0
  190. package/lib/types/secure-session.d.ts +31 -0
  191. package/lib/types/types.d.ts +304 -1
  192. package/package.json +70 -27
@@ -1,80 +1,85 @@
1
- import { projectTranscript } from "./QaTranscriptAdapter.js";
2
- const EMPTY_STATE = Object.freeze({
3
- phase: "idle",
4
- sessionId: null,
5
- messages: Object.freeze([]),
6
- error: null,
7
- canSend: false,
8
- canStop: false,
9
- });
10
- function waitFor(source, predicate, timeoutMs) {
11
- const current = source.getSnapshot();
12
- if (predicate(current))
13
- return Promise.resolve(current);
14
- return new Promise((resolve, reject) => {
15
- let settled = false;
16
- const timeout = {};
17
- let unsubscribe = () => undefined;
18
- const finish = (value) => {
19
- if (settled)
20
- return;
21
- settled = true;
22
- if (timeout.id !== undefined)
23
- clearTimeout(timeout.id);
24
- unsubscribe();
25
- resolve(value);
26
- };
27
- unsubscribe = source.subscribe(() => {
28
- const next = source.getSnapshot();
29
- if (predicate(next))
30
- finish(next);
31
- });
32
- // Tolerate observable implementations that notify synchronously while a
33
- // subscriber is being installed, and close that subscription afterward.
34
- if (settled) {
35
- unsubscribe();
36
- return;
37
- }
38
- const afterSubscribe = source.getSnapshot();
39
- if (predicate(afterSubscribe)) {
40
- finish(afterSubscribe);
41
- return;
42
- }
43
- timeout.id = setTimeout(() => {
44
- if (settled)
45
- return;
46
- settled = true;
47
- unsubscribe();
48
- reject(new Error("Timed out waiting for the DSH runtime."));
49
- }, timeoutMs);
50
- });
1
+ function bytesToBase64(bytes) {
2
+ let binary = "";
3
+ for (let index = 0; index < bytes.length; index += 0x8000) {
4
+ binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
5
+ }
6
+ return btoa(binary);
51
7
  }
52
- /** The only module that couples QA behavior to DSH Session/client APIs. */
8
+ import { QaPolicyAttestationError } from "./attestation.js";
9
+ import { QaChatIndex } from "./chat-index.js";
10
+ import { SessionAssetRepository } from "./session-assets.js";
11
+ import { createQaSession } from "./create-session.js";
12
+ import { attestQaPolicy } from "./session-admission.js";
13
+ import { QaHostSourceBridge } from "./session-sources.js";
14
+ import { QA_SESSION_IDLE_STATE } from "./types.js";
15
+ import { waitFor } from "./wait-for.js";
16
+ import { QA_REGENERATE_MARKER } from "./QaTranscriptAdapter.js";
17
+ import { projectBoundSessionState } from "./project-session-state.js";
18
+ import { projectTurnSources } from "./turn-sources.js";
19
+ const CONFIGURATION_ERROR = "Настройки помощника недоступны.";
20
+ /**
21
+ * The only module that couples QA behavior to DSH Session/client APIs. It owns
22
+ * the chat lifecycle state machine; the observable wait, browser-local chat
23
+ * bookkeeping and policy-attestation details live in their own modules.
24
+ */
53
25
  export class QaSessionController {
54
26
  listeners = new Set();
55
27
  sessions;
56
28
  api;
29
+ conversation;
57
30
  connection;
58
31
  config;
59
- storage;
32
+ secureSessionRemote;
33
+ sourceApi;
34
+ chats;
35
+ accounts;
36
+ /** Per-chat attachment URL cache; blob URLs die with the chat binding. */
37
+ assets = new SessionAssetRepository();
60
38
  timeoutMs;
61
- state = EMPTY_STATE;
39
+ streamIntervalMs;
40
+ state = QA_SESSION_IDLE_STATE;
62
41
  session;
42
+ conversationBinding;
63
43
  unsubscribeSession;
44
+ unsubscribeChat;
64
45
  unsubscribeConnection;
65
46
  ensuring;
47
+ materializing;
66
48
  operationError = null;
67
49
  admissionPending = false;
50
+ policyReady = false;
51
+ drafting = false;
52
+ streamFlushTimer;
53
+ /** The chat session to return to when a subagent view closes. */
54
+ chatSessionId = null;
55
+ viewingSubagent = null;
68
56
  connectedOnce;
69
57
  disposed = false;
70
58
  generation = 0;
59
+ chatsRevision = 0;
60
+ /** Host-side provenance of the bound chat, merged into the projection. */
61
+ hostSources;
71
62
  constructor(options) {
72
63
  this.sessions = options.sessions;
73
64
  this.api = options.api;
65
+ this.conversation = options.conversation;
74
66
  this.connection = options.connection;
75
67
  this.config = options.config;
76
- this.storage = options.storage;
68
+ this.secureSessionRemote = options.secureSession;
69
+ this.sourceApi =
70
+ options.sourceApi ??
71
+ {
72
+ sources: async () => ({ ok: true, value: [] }),
73
+ readSourceFile: async () => ({
74
+ ok: false,
75
+ error: "unavailable",
76
+ }),
77
+ };
78
+ this.chats = new QaChatIndex(options.storage, `${options.config.session.storageKey}:v1:${options.config.route.path}`);
79
+ this.accounts = options.accounts;
80
+ this.hostSources = new QaHostSourceBridge(this.sourceApi, () => this.accounts?.token() ?? "");
77
81
  this.timeoutMs = options.timeoutMs ?? 15_000;
82
+ this.streamIntervalMs = options.streamIntervalMs ?? 66;
78
83
  this.connectedOnce = this.connection.getSnapshot() !== undefined;
79
84
  this.unsubscribeConnection = this.connection.subscribe(() => {
80
85
  if (this.connection.getSnapshot() !== undefined)
@@ -97,25 +102,65 @@ export class QaSessionController {
97
102
  this.ensuring = operation;
98
103
  return operation;
99
104
  }
100
- async send(text) {
105
+ async send(text, images = []) {
101
106
  const prompt = text.trim();
102
- if (prompt === "" || this.session === undefined || !this.state.canSend) {
107
+ if ((prompt === "" && images.length === 0) || !this.state.canSend) {
103
108
  return false;
104
109
  }
105
110
  if (prompt.startsWith("/")) {
106
- this.operationError =
107
- "Slash commands are not available in this assistant view.";
111
+ this.operationError = "Команды со слешем недоступны в режиме помощника.";
108
112
  this.publish();
109
113
  return false;
110
114
  }
115
+ if (this.session === undefined) {
116
+ // A draft chat materializes its session only now: nothing was created
117
+ // when the user pressed "New chat", so the first prompt pays for it.
118
+ if (!this.drafting || this.materializing !== undefined)
119
+ return false;
120
+ if (!(await this.materializeDraft()) || this.session === undefined) {
121
+ return false;
122
+ }
123
+ }
111
124
  this.operationError = null;
125
+ const step = await this.attestPolicy();
126
+ if (step.kind === "stale") {
127
+ // The user moved to another chat while the proof was pending; that
128
+ // binding attests through its own flow and must not receive a prompt
129
+ // meant for the old one.
130
+ return false;
131
+ }
132
+ let attestedId = step.kind === "ok" ? step.sessionId : null;
133
+ if (attestedId === null) {
134
+ attestedId = await this.recoverForSend();
135
+ if (attestedId === null)
136
+ return false;
137
+ }
138
+ // Nothing can interleave between the last await and here, so matching the
139
+ // attested id proves the prompt rides exactly the session it proved.
140
+ const target = this.session;
141
+ if (this.disposed ||
142
+ target === undefined ||
143
+ String(target.sessionId) !== attestedId) {
144
+ return false;
145
+ }
112
146
  this.admissionPending = true;
113
147
  this.publish();
114
148
  try {
115
- const result = await this.session.prompt([{ type: "text", text: prompt }], "queue");
149
+ const content = [
150
+ ...(prompt === "" ? [] : [{ type: "text", text: prompt }]),
151
+ ...images.map((image) => ({
152
+ type: "image",
153
+ mediaType: image.mediaType,
154
+ data: image.data,
155
+ name: image.name,
156
+ })),
157
+ ];
158
+ const result = await target.prompt(content, "queue");
159
+ if (this.session !== target)
160
+ return false;
116
161
  if (!result.ok) {
117
162
  this.admissionPending = false;
118
- this.operationError = "Your message could not be sent.";
163
+ this.operationError = "Не удалось отправить сообщение.";
119
164
  this.publish();
120
165
  return false;
121
166
  }
@@ -125,27 +170,52 @@ export class QaSessionController {
125
170
  catch (error) {
126
171
  this.admissionPending = false;
127
172
  console.error("dsh-qa-surface: prompt failed", error);
128
- this.operationError = "Your message could not be sent.";
129
- this.publish();
173
+ if (this.session === target) {
174
+ this.operationError = "Не удалось отправить сообщение.";
175
+ this.publish();
176
+ }
130
177
  return false;
131
178
  }
132
179
  }
180
+ /**
181
+ * Ask for a fresh variant of the previous answer. The session has no
182
+ * truncation seam, so this sends the hidden regeneration instruction as an
183
+ * ordinary prompt: the model produces a follow-up turn, the projection
184
+ * hides the marker message, and the consecutive turns read as variants of
185
+ * the original question (switched in the surface).
186
+ */
187
+ async regenerate() {
188
+ return this.send(QA_REGENERATE_MARKER);
189
+ }
133
190
  async stop() {
134
- if (this.session === undefined || !this.state.canStop)
191
+ const target = this.session;
192
+ if (target === undefined || !this.state.canStop)
135
193
  return;
136
194
  try {
137
- const result = await this.session.cancel();
195
+ const result = await target.cancel();
196
+ if (this.session !== target)
197
+ return;
138
198
  if (!result.ok)
139
- this.operationError = "The response could not be stopped.";
199
+ this.operationError = "Не удалось остановить ответ.";
140
200
  }
141
201
  catch (error) {
142
202
  console.error("dsh-qa-surface: stop failed", error);
143
- this.operationError = "The response could not be stopped.";
203
+ if (this.session !== target)
204
+ return;
205
+ this.operationError = "Не удалось остановить ответ.";
144
206
  }
145
207
  this.publish();
146
208
  }
147
- async reset() {
148
- if (this.config.session.policy === "fixed")
209
+ /**
210
+ * Enter the "new chat" draft: show an empty composer without creating any
211
+ * session. Nothing appears in the chat list and no Host session is spent
212
+ * until the first prompt is actually sent, which materializes the session
213
+ * lazily ({@link materializeDraft}). Fixed-policy deployments cannot draft.
214
+ */
215
+ async startDraft() {
216
+ if (this.disposed ||
217
+ this.config.session.policy === "fixed" ||
218
+ (this.config.lockdown.enabled && !this.config.lockdown.allowSessionReset))
149
219
  return;
150
220
  const previous = this.session;
151
221
  if (previous?.getSnapshot().running === true) {
@@ -153,44 +223,253 @@ export class QaSessionController {
153
223
  await previous.cancel();
154
224
  }
155
225
  catch (error) {
156
- console.error("dsh-qa-surface: stop before reset failed", error);
226
+ console.error("dsh-qa-surface: stop before draft failed", error);
157
227
  }
158
228
  }
229
+ this.drafting = true;
230
+ this.unbind();
231
+ this.operationError = null;
232
+ this.admissionPending = false;
233
+ this.policyReady = false;
234
+ this.state = {
235
+ ...QA_SESSION_IDLE_STATE,
236
+ chatsRevision: this.chatsRevision,
237
+ };
238
+ this.publish();
239
+ }
240
+ /**
241
+ * Turn the current draft into a real attested session. Runs inside the
242
+ * send flow; concurrent sends share one materialization. A refusal here is
243
+ * reported at once — a fresh session's first attestation is the
244
+ * deployment's only chance to learn the reason.
245
+ */
246
+ materializeDraft() {
247
+ if (this.materializing !== undefined)
248
+ return this.materializing;
159
249
  const operation = ++this.generation;
250
+ const attempt = (async () => {
251
+ try {
252
+ await this.waitForConnection();
253
+ const id = await createQaSession({
254
+ sessions: this.sessions,
255
+ api: this.api,
256
+ config: this.config,
257
+ });
258
+ if (this.disposed || operation !== this.generation)
259
+ return false;
260
+ await this.bind(id);
261
+ if (this.disposed || operation !== this.generation)
262
+ return false;
263
+ this.drafting = false;
264
+ if (this.config.session.policy === "browser-persistent") {
265
+ this.chats.saveActive(id);
266
+ }
267
+ return true;
268
+ }
269
+ catch (error) {
270
+ if (this.disposed || operation !== this.generation)
271
+ return false;
272
+ this.fail(this.operationError === CONFIGURATION_ERROR
273
+ ? this.operationError
274
+ : "Не удалось начать чат.", error);
275
+ return false;
276
+ }
277
+ })();
278
+ this.materializing = attempt;
279
+ this.publish();
280
+ // `attempt` never rejects: every failure path is caught and reports false.
281
+ void attempt.then(() => {
282
+ if (this.materializing === attempt)
283
+ this.materializing = undefined;
284
+ });
285
+ return attempt;
286
+ }
287
+ /**
288
+ * Open one of this browser's indexed chats. The id must be present in the
289
+ * host session list and pass policy attestation; an unknown id is forgotten
290
+ * from the index. Fixed-policy deployments cannot switch.
291
+ */
292
+ async switchTo(sessionId) {
293
+ if (this.disposed || this.config.session.policy === "fixed")
294
+ return;
295
+ if (this.session !== undefined &&
296
+ String(this.session.sessionId) === sessionId)
297
+ return;
298
+ const operation = ++this.generation;
299
+ this.drafting = false;
300
+ this.viewingSubagent = null;
160
301
  this.unbind();
161
302
  this.operationError = null;
162
303
  this.admissionPending = false;
163
- this.state = { ...EMPTY_STATE, phase: "creating" };
304
+ this.policyReady = false;
305
+ this.state = {
306
+ ...QA_SESSION_IDLE_STATE,
307
+ chatsRevision: this.chatsRevision,
308
+ phase: "creating",
309
+ };
164
310
  this.emit();
165
311
  try {
166
312
  await this.waitForConnection();
167
- const id = await this.createSession();
313
+ const list = await waitFor(this.sessions.list, (snapshot) => snapshot.phase === "ready", this.timeoutMs);
314
+ if (this.disposed || operation !== this.generation)
315
+ return;
316
+ if (!Object.hasOwn(list.byId, sessionId)) {
317
+ this.forgetChat(sessionId);
318
+ throw new Error("Этот чат больше недоступен.");
319
+ }
320
+ await this.bind(sessionId);
168
321
  if (this.disposed || operation !== this.generation)
169
322
  return;
170
- this.persist(id);
171
- await this.bind(id);
323
+ this.chats.saveActive(sessionId);
172
324
  }
173
325
  catch (error) {
174
- this.fail("Unable to start a new chat.", error);
326
+ // A newer operation superseded this switch: its own outcome governs.
327
+ if (this.disposed || operation !== this.generation)
328
+ return;
329
+ this.fail(this.operationError === CONFIGURATION_ERROR
330
+ ? this.operationError
331
+ : "Не удалось открыть этот чат.", error);
332
+ }
333
+ }
334
+ /**
335
+ * Watch a subagent's transcript live. The binding is read-only by
336
+ * construction: no attestation runs (attestation admits sending, and the
337
+ * composer stays disabled here), the chat index is untouched, and
338
+ * {@link closeSubagent} returns to the chat the panel was opened from.
339
+ */
340
+ async viewSubagent(id, title) {
341
+ if (this.disposed)
342
+ return;
343
+ if (this.viewingSubagent !== null &&
344
+ this.viewingSubagent.id === id &&
345
+ this.session !== undefined) {
346
+ return;
347
+ }
348
+ const operation = ++this.generation;
349
+ this.drafting = false;
350
+ this.viewingSubagent = { id, title };
351
+ this.unbind();
352
+ this.operationError = null;
353
+ this.admissionPending = false;
354
+ this.policyReady = false;
355
+ this.state = {
356
+ ...QA_SESSION_IDLE_STATE,
357
+ chatsRevision: this.chatsRevision,
358
+ viewingSubagent: this.viewingSubagent,
359
+ phase: "creating",
360
+ };
361
+ this.emit();
362
+ try {
363
+ await this.waitForConnection();
364
+ await this.bind(id, { attest: false, track: false, report: false });
365
+ }
366
+ catch (error) {
367
+ // A newer operation superseded this view request: leave its state alone.
368
+ if (this.disposed || operation !== this.generation)
369
+ return;
370
+ this.viewingSubagent = null;
371
+ this.fail("Не удалось открыть транскрипт субагента.", error);
372
+ }
373
+ if (this.disposed || operation !== this.generation)
374
+ return;
375
+ }
376
+ /** Leave the subagent transcript and re-open the chat it was launched from. */
377
+ async closeSubagent() {
378
+ const chat = this.chatSessionId;
379
+ if (chat === null)
380
+ return;
381
+ await this.switchTo(chat);
382
+ }
383
+ /** This browser's chat ids: the owned list when accounts are on, else the
384
+ * local index. Both intersect the host session list at projection time. */
385
+ chatIds() {
386
+ const owned = this.accounts?.ownedIds() ?? [];
387
+ if (owned.length === 0)
388
+ return this.chats.chatIds();
389
+ const local = this.chats.chatIds();
390
+ return [...new Set([...owned, ...local])];
391
+ }
392
+ /** The bound session id, or null while no chat is bound. */
393
+ activeSessionId() {
394
+ return this.session === undefined ? null : String(this.session.sessionId);
395
+ }
396
+ /**
397
+ * Resolve one durable image attachment of the bound session into a
398
+ * browser-usable URL. Resolutions are cached per chat in the asset
399
+ * repository (attachment ids repeat across chats) and revoked when the chat
400
+ * is unbound; failures surface as broken views and retry on demand.
401
+ */
402
+ async readImage(attachmentId) {
403
+ if (this.session === undefined)
404
+ throw new Error("no bound session");
405
+ const sessionId = String(this.session.sessionId);
406
+ return this.assets.resolve(sessionId, attachmentId, (id) => this.readAttachmentUrl(id));
407
+ }
408
+ async readAttachmentUrl(attachmentId) {
409
+ const session = this.session;
410
+ if (session === undefined)
411
+ throw new Error("no bound session");
412
+ const result = await session.readAttachment(attachmentId);
413
+ if (!result.ok) {
414
+ throw new Error(`${result.error.code}: ${result.error.message}`);
415
+ }
416
+ const { attachment, data } = result.value;
417
+ if (typeof URL.createObjectURL !== "function") {
418
+ return `data:${attachment.mediaType};base64,${bytesToBase64(data)}`;
419
+ }
420
+ return URL.createObjectURL(new Blob([Uint8Array.from(data).buffer], {
421
+ type: attachment.mediaType,
422
+ }));
423
+ }
424
+ /**
425
+ * Remove one chat from this browser's index. Deleting the chat that is
426
+ * currently open also forgets the persisted id and starts a fresh attested
427
+ * session (when the deployment allows session resets); the Host-side
428
+ * session itself is not touched — no host deletion seam exists.
429
+ */
430
+ async deleteChat(id) {
431
+ this.forgetChat(id);
432
+ this.chatsRevision += 1;
433
+ if (this.activeSessionId() === id &&
434
+ this.config.session.policy !== "fixed") {
435
+ // No replacement session is created: deleting the open chat falls back
436
+ // to a draft, and the next prompt materializes a fresh one. The stale
437
+ // persisted id is dropped so a reload cannot resurrect the deleted chat.
438
+ this.chats.clearActive();
439
+ await this.startDraft();
440
+ return;
175
441
  }
442
+ this.publish();
443
+ }
444
+ /** Drop one chat id from this browser's index. */
445
+ forgetChat(sessionId) {
446
+ this.chats.forgetChat(sessionId);
176
447
  }
177
448
  dispose() {
178
449
  if (this.disposed)
179
450
  return;
180
451
  this.disposed = true;
181
452
  this.generation += 1;
453
+ this.clearStreamFlush();
182
454
  this.unsubscribeConnection();
183
455
  this.unbind();
456
+ this.assets.dispose();
184
457
  this.listeners.clear();
185
458
  }
186
459
  async ensureSessionNow() {
187
460
  const operation = ++this.generation;
188
- this.state = { ...EMPTY_STATE, phase: "creating" };
461
+ this.drafting = false;
462
+ this.state = {
463
+ ...QA_SESSION_IDLE_STATE,
464
+ chatsRevision: this.chatsRevision,
465
+ phase: "creating",
466
+ };
189
467
  this.emit();
190
468
  try {
191
469
  await this.waitForConnection();
192
470
  const list = await waitFor(this.sessions.list, (snapshot) => snapshot.phase === "ready", this.timeoutMs);
193
471
  let id = null;
472
+ let restored = false;
194
473
  if (this.config.session.policy === "fixed") {
195
474
  id = this.config.session.fixedSessionId;
196
475
  if (id === null || !Object.hasOwn(list.byId, id)) {
@@ -198,56 +477,138 @@ export class QaSessionController {
198
477
  }
199
478
  }
200
479
  else if (this.config.session.policy === "browser-persistent") {
201
- const stored = this.readPersisted();
480
+ const stored = this.chats.activeId();
202
481
  if (stored !== null && Object.hasOwn(list.byId, stored)) {
203
482
  id = stored;
483
+ restored = true;
204
484
  }
205
485
  else if (stored !== null) {
206
- this.clearPersisted();
486
+ this.chats.clearActive();
207
487
  }
208
488
  }
209
489
  if (id === null) {
210
- id = await this.createSession();
211
- if (this.config.session.policy === "browser-persistent")
212
- this.persist(id);
490
+ id = await createQaSession({
491
+ sessions: this.sessions,
492
+ api: this.api,
493
+ config: this.config,
494
+ });
213
495
  }
214
496
  if (this.disposed || operation !== this.generation)
215
497
  return;
216
- await this.bind(id);
498
+ // A restored id's first bind stays quiet: the recovery path below may
499
+ // replace it. A freshly created session must report a refusal at once —
500
+ // it is the deployment's only chance to learn the reason.
501
+ try {
502
+ await this.bind(id, { report: !restored });
503
+ }
504
+ catch (error) {
505
+ // A stale restore must not start its recovery ladder against a newer
506
+ // operation; rethrow for the generation-guarded outer catch.
507
+ if (this.disposed || operation !== this.generation)
508
+ throw error;
509
+ if (!restored ||
510
+ this.config.session.policy !== "browser-persistent" ||
511
+ !(error instanceof QaPolicyAttestationError)) {
512
+ throw error;
513
+ }
514
+ // A stored id is only a hint. If Host refuses that session under the
515
+ // current QA policy (for example after the configured preset changes),
516
+ // forget it and bootstrap one fresh, policy-attested session instead.
517
+ this.unbind();
518
+ this.operationError = null;
519
+ this.chats.clearActive();
520
+ id = await createQaSession({
521
+ sessions: this.sessions,
522
+ api: this.api,
523
+ config: this.config,
524
+ });
525
+ if (this.disposed || operation !== this.generation)
526
+ return;
527
+ await this.bind(id);
528
+ }
529
+ if (this.disposed || operation !== this.generation)
530
+ return;
531
+ if (this.config.session.policy === "browser-persistent") {
532
+ this.chats.saveActive(id);
533
+ }
217
534
  }
218
535
  catch (error) {
219
- this.fail("Unable to start a chat.", error);
536
+ // A newer operation superseded this bootstrap: its own outcome governs.
537
+ if (this.disposed || operation !== this.generation)
538
+ return;
539
+ this.fail(this.operationError === CONFIGURATION_ERROR
540
+ ? this.operationError
541
+ : "Не удалось начать чат.", error);
220
542
  }
221
543
  }
222
- async createSession() {
223
- const response = await this.api.create({
224
- ...(this.config.session.workspaceId === null
225
- ? {}
226
- : { workspaceId: this.config.session.workspaceId }),
227
- ...(this.config.session.agentPreset === null
228
- ? {}
229
- : { agentPreset: this.config.session.agentPreset }),
230
- });
231
- if (!response.result.ok)
232
- throw new Error(response.result.error.code);
233
- const id = String(response.result.value.sessionId);
234
- await waitFor(this.sessions.list, (snapshot) => Object.hasOwn(snapshot.byId, id), this.timeoutMs);
235
- if (this.config.session.provider !== null &&
236
- this.config.session.model !== null) {
237
- const selected = await this.api.selectModel({
238
- sessionId: id,
239
- provider: this.config.session.provider,
240
- model: this.config.session.model,
241
- ...(this.config.session.reasoningEffort === null
242
- ? {}
243
- : { reasoningEffort: this.config.session.reasoningEffort }),
544
+ /**
545
+ * Recovery ladder after a refused send-time attestation. The prompt was
546
+ * never admitted, so nothing here can duplicate it. Step 1 re-opens the
547
+ * session: a Host that idled the agent out dismantles its tool view, and
548
+ * re-opening re-materializes it. Step 2 — when that refused session is
549
+ * still blank (it never received the prompt, so there is nothing to lose) —
550
+ * starts a fresh attested session instead. A session with history is left
551
+ * alone: recreating it would silently drop the conversation. Returns the
552
+ * attested session id, or null when nothing was attested.
553
+ */
554
+ async recoverForSend() {
555
+ const id = this.session === undefined ? null : String(this.session.sessionId);
556
+ if (id === null ||
557
+ this.disposed ||
558
+ this.config.session.policy === "fixed") {
559
+ return null;
560
+ }
561
+ const operation = ++this.generation;
562
+ this.unbind();
563
+ this.admissionPending = false;
564
+ this.policyReady = false;
565
+ this.state = { ...this.state, phase: "creating" };
566
+ this.emit();
567
+ try {
568
+ await this.waitForConnection();
569
+ try {
570
+ await this.bind(id);
571
+ if (this.disposed || operation !== this.generation)
572
+ return null;
573
+ if (this.policyReady)
574
+ return id;
575
+ }
576
+ catch (error) {
577
+ if (!(error instanceof QaPolicyAttestationError))
578
+ throw error;
579
+ if (this.disposed || operation !== this.generation)
580
+ return null;
581
+ }
582
+ if (this.session !== undefined && !this.session.getSnapshot().blank) {
583
+ this.fail(CONFIGURATION_ERROR, new QaPolicyAttestationError());
584
+ return null;
585
+ }
586
+ this.unbind();
587
+ this.chats.clearActive();
588
+ const created = await createQaSession({
589
+ sessions: this.sessions,
590
+ api: this.api,
591
+ config: this.config,
244
592
  });
245
- if (!selected.result.ok)
246
- throw new Error(selected.result.error.code);
593
+ if (this.disposed || operation !== this.generation)
594
+ return null;
595
+ await this.bind(created);
596
+ if (this.disposed || operation !== this.generation)
597
+ return null;
598
+ this.chats.saveActive(created);
599
+ return this.policyReady ? created : null;
600
+ }
601
+ catch (error) {
602
+ if (this.disposed || operation !== this.generation)
603
+ return null;
604
+ this.fail(this.operationError === CONFIGURATION_ERROR
605
+ ? this.operationError
606
+ : "Не удалось открыть этот чат.", error);
607
+ return null;
247
608
  }
248
- return id;
249
609
  }
250
- async bind(id) {
610
+ async bind(id, options = {}) {
611
+ const { report = true, attest = true, track = true } = options;
251
612
  this.sessions.open(id);
252
613
  let binding = this.sessions.binding(id);
253
614
  if (binding === undefined) {
@@ -258,26 +619,108 @@ export class QaSessionController {
258
619
  throw new Error("Session binding is unavailable.");
259
620
  this.unbind();
260
621
  this.session = binding.session;
622
+ this.conversationBinding = this.conversation.binding(id);
623
+ this.policyReady = false;
261
624
  this.unsubscribeSession = binding.session.subscribe(() => {
262
625
  this.admissionPending = false;
263
626
  this.operationError = null;
264
- this.publish();
627
+ this.publishSessionUpdate();
628
+ });
629
+ // Subscribing the Chat target activates it, so the assembled transcript
630
+ // (nodes, partials, running calls) materializes for the projection.
631
+ this.unsubscribeChat = this.conversationBinding
632
+ .target("chat")
633
+ .subscribe(() => {
634
+ this.publishSessionUpdate();
265
635
  });
636
+ await waitFor(binding.session, (snapshot) => snapshot.openState === "open" || snapshot.openState === "error", this.timeoutMs);
637
+ if (binding.session.getSnapshot().openState !== "open") {
638
+ throw new Error("Session binding could not be opened.");
639
+ }
640
+ if (attest) {
641
+ const step = await this.attestPolicy(report);
642
+ if (step.kind !== "ok")
643
+ throw new QaPolicyAttestationError();
644
+ }
645
+ if (track) {
646
+ this.chatSessionId = String(this.session.sessionId);
647
+ this.viewingSubagent = null;
648
+ this.chats.addChat(String(this.session.sessionId));
649
+ this.accounts?.onSessionCreated(String(this.session.sessionId));
650
+ }
266
651
  this.publish();
267
652
  }
268
653
  unbind() {
654
+ this.clearStreamFlush();
269
655
  this.unsubscribeSession?.();
270
656
  this.unsubscribeSession = undefined;
657
+ this.unsubscribeChat?.();
658
+ this.unsubscribeChat = undefined;
659
+ this.conversationBinding = undefined;
660
+ if (this.session !== undefined) {
661
+ // Leaving the chat retires its attachment URLs; a later re-bind
662
+ // re-resolves them from the durable store.
663
+ this.assets.release(String(this.session.sessionId));
664
+ }
271
665
  this.session = undefined;
666
+ this.hostSources.reset();
272
667
  this.admissionPending = false;
668
+ this.policyReady = false;
273
669
  }
274
670
  waitForConnection() {
275
671
  return waitFor(this.connection, (description) => description !== undefined, this.timeoutMs);
276
672
  }
673
+ /**
674
+ * Project one session notification. A running turn's frames arrive at
675
+ * animation-frame cadence, so re-projections are spaced at least
676
+ * {@link streamIntervalMs} apart: the first frame of a window projects at
677
+ * once and further frames are absorbed (the memoized render path replays
678
+ * nothing, so absorbing a frame only defers it). Everything outside a
679
+ * running turn - phase flips, errors, turn completion - projects
680
+ * immediately.
681
+ */
682
+ publishSessionUpdate() {
683
+ if (this.streamIntervalMs <= 0 ||
684
+ this.session?.getSnapshot().running !== true) {
685
+ this.clearStreamFlush();
686
+ this.publish();
687
+ return;
688
+ }
689
+ if (this.streamFlushTimer !== undefined)
690
+ return;
691
+ this.publish();
692
+ this.streamFlushTimer = setTimeout(() => {
693
+ this.streamFlushTimer = undefined;
694
+ }, this.streamIntervalMs);
695
+ }
696
+ clearStreamFlush() {
697
+ if (this.streamFlushTimer === undefined)
698
+ return;
699
+ clearTimeout(this.streamFlushTimer);
700
+ this.streamFlushTimer = undefined;
701
+ }
277
702
  publish() {
278
703
  if (this.disposed)
279
704
  return;
280
705
  const connected = this.connection.getSnapshot() !== undefined;
706
+ if (this.drafting && this.session === undefined) {
707
+ // Draft state: an empty writable composer without a bound session. The
708
+ // session list is untouched — nothing exists until the first send.
709
+ const materializing = this.materializing !== undefined;
710
+ this.state = {
711
+ ...QA_SESSION_IDLE_STATE,
712
+ phase: materializing
713
+ ? "creating"
714
+ : !connected && this.connectedOnce
715
+ ? "reconnecting"
716
+ : "idle",
717
+ error: this.operationError,
718
+ canSend: connected && !materializing,
719
+ chatsRevision: this.chatsRevision,
720
+ };
721
+ this.emit();
722
+ return;
723
+ }
281
724
  if (this.session === undefined) {
282
725
  this.state = {
283
726
  ...this.state,
@@ -288,75 +731,90 @@ export class QaSessionController {
288
731
  return;
289
732
  }
290
733
  const snapshot = this.session.getSnapshot();
291
- const blocked = snapshot.pending.length > 0;
292
- const error = this.operationError ??
293
- (blocked
294
- ? "This request requires an interaction that is not available in this assistant view."
295
- : snapshot.removed || snapshot.openState === "error"
296
- ? "This chat is no longer available."
297
- : null);
298
- const phase = !connected
299
- ? "reconnecting"
300
- : blocked
301
- ? "blocked"
302
- : snapshot.removed || snapshot.openState === "error"
303
- ? "error"
304
- : snapshot.openState !== "open"
305
- ? "creating"
306
- : snapshot.running || this.admissionPending
307
- ? "running"
308
- : "ready";
309
- this.state = {
310
- phase,
311
- sessionId: String(this.session.sessionId),
312
- messages: projectTranscript(snapshot, {
313
- showToolActivity: this.config.ui.showToolActivity,
314
- }),
315
- error,
316
- canSend: connected && phase === "ready",
317
- canStop: connected && snapshot.running && this.config.ui.showStop && !blocked,
318
- };
734
+ const sessionId = String(this.session.sessionId);
735
+ const conversationSnapshot = this.conversationBinding?.snapshot.getSnapshot();
736
+ const projectedSourceBundles = projectTurnSources(conversationSnapshot, sessionId, this.config.session.cwd ?? undefined);
737
+ const sourceBundles = this.hostSources.merge(projectedSourceBundles);
738
+ if (!snapshot.running) {
739
+ void this.hostSources.refresh(sessionId, () => this.publish());
740
+ }
741
+ this.state = projectBoundSessionState({
742
+ connected,
743
+ sessionId,
744
+ sessionSnapshot: snapshot,
745
+ conversationSnapshot,
746
+ sourceBundles,
747
+ // Ownership is chat-level: every user message of a foreign chat
748
+ // carries its owner's name when an admin reads it.
749
+ author: this.accounts?.messageAuthorOf(sessionId),
750
+ operationError: this.operationError,
751
+ policyReady: this.policyReady,
752
+ admissionPending: this.admissionPending,
753
+ chatsRevision: this.chatsRevision,
754
+ viewingSubagent: this.viewingSubagent,
755
+ config: this.config,
756
+ });
319
757
  this.emit();
320
758
  }
321
759
  fail(message, error) {
322
- console.error("dsh-qa-surface: session operation failed", error);
760
+ // Policy attestation refusals already logged their precise reason; a
761
+ // second stack trace for the wrapper error is only console noise.
762
+ if (!(error instanceof QaPolicyAttestationError)) {
763
+ console.error("dsh-qa-surface: session operation failed", error);
764
+ }
323
765
  if (this.disposed)
324
766
  return;
325
767
  this.operationError = message;
326
768
  this.state = {
327
- ...EMPTY_STATE,
769
+ ...QA_SESSION_IDLE_STATE,
328
770
  phase: "error",
329
771
  error: message,
772
+ chatsRevision: this.chatsRevision,
330
773
  };
331
774
  this.emit();
332
775
  }
333
- storageKey() {
334
- return `${this.config.session.storageKey}:v1:${this.config.route.path}:session`;
335
- }
336
- readPersisted() {
337
- try {
338
- const value = this.storage?.getItem(this.storageKey())?.trim() ?? "";
339
- return value === "" ? null : value;
340
- }
341
- catch {
342
- return null;
776
+ /**
777
+ * Attest the currently bound session. `ok` carries the session id the proof
778
+ * was issued for; `stale` means the binding changed while the request was
779
+ * in flight, so nothing was written — the newer binding manages its own
780
+ * attestation; `refused` is a genuine admission refusal of this binding.
781
+ */
782
+ async attestPolicy(reportFailure = true) {
783
+ const session = this.session;
784
+ if (session === undefined)
785
+ return { kind: "refused" };
786
+ const sessionId = String(session.sessionId);
787
+ if (!this.config.lockdown.enabled) {
788
+ this.policyReady = true;
789
+ return { kind: "ok", sessionId };
343
790
  }
344
- }
345
- persist(id) {
346
- try {
347
- this.storage?.setItem(this.storageKey(), id);
348
- }
349
- catch {
350
- // A denied localStorage write must not prevent a real DSH session.
351
- }
352
- }
353
- clearPersisted() {
354
- try {
355
- this.storage?.removeItem(this.storageKey());
791
+ this.policyReady = false;
792
+ this.publish();
793
+ const outcome = await attestQaPolicy({
794
+ secureSession: this.secureSessionRemote,
795
+ token: this.accounts?.token() ?? "",
796
+ sessionId,
797
+ lockdown: this.config.lockdown,
798
+ report: reportFailure,
799
+ });
800
+ if (this.session !== session)
801
+ return { kind: "stale" };
802
+ if (outcome.ok) {
803
+ this.policyReady = true;
804
+ this.operationError = null;
805
+ this.publish();
806
+ return { kind: "ok", sessionId };
356
807
  }
357
- catch {
358
- // A denied localStorage removal is harmless; the id is revalidated later.
808
+ if (outcome.authRequired) {
809
+ // The identity expired or was rotated: back to the gate instead of
810
+ // the generic configuration error.
811
+ this.accounts?.onAuthRequired();
812
+ return { kind: "refused" };
359
813
  }
814
+ this.policyReady = false;
815
+ this.operationError = CONFIGURATION_ERROR;
816
+ this.publish();
817
+ return { kind: "refused" };
360
818
  }
361
819
  emit() {
362
820
  for (const listener of this.listeners)