@yadsh/dsh-qa-surface 0.13.0 → 0.14.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 +148 -22
- package/compatibility.json +7 -3
- package/lib/access/capability-catalog.js +22 -6
- package/lib/access/capability-catalog.js.map +1 -1
- package/lib/access/model.js +36 -2
- package/lib/access/model.js.map +1 -1
- package/lib/access/service.js +19 -4
- package/lib/access/service.js.map +1 -1
- package/lib/account-remotes.js +4 -0
- package/lib/account-remotes.js.map +1 -1
- package/lib/accounts/database.js +49 -9
- package/lib/accounts/database.js.map +1 -1
- package/lib/accounts/errors.js.map +1 -1
- package/lib/accounts/file.js.map +1 -1
- package/lib/accounts/store.js +53 -8
- package/lib/accounts/store.js.map +1 -1
- package/lib/admin/permissions.js +6 -0
- package/lib/admin/permissions.js.map +1 -1
- package/lib/admin/quality-store.js +95 -19
- package/lib/admin/quality-store.js.map +1 -1
- package/lib/admin/service.js +178 -0
- package/lib/admin/service.js.map +1 -1
- package/lib/cli.js +24 -4
- package/lib/cli.js.map +1 -1
- package/lib/client/QaAccountsController.js +52 -5
- package/lib/client/QaAccountsController.js.map +1 -1
- package/lib/client/QaConfigController.js.map +1 -1
- package/lib/client/QaSessionController.js +626 -63
- package/lib/client/QaSessionController.js.map +1 -1
- package/lib/client/QaSurface.js +279 -43
- package/lib/client/QaSurface.js.map +1 -1
- package/lib/client/QaSurfaceGuard.js +2 -2
- package/lib/client/QaSurfaceGuard.js.map +1 -1
- package/lib/client/QaTranscriptAdapter.js +4 -2
- package/lib/client/QaTranscriptAdapter.js.map +1 -1
- package/lib/client/admin/QaAdmin.js +59 -34
- package/lib/client/admin/QaAdmin.js.map +1 -1
- package/lib/client/admin/pages/Conversations.js +12 -12
- package/lib/client/admin/pages/Conversations.js.map +1 -1
- package/lib/client/admin/pages/ExpertMemory.js +210 -0
- package/lib/client/admin/pages/ExpertMemory.js.map +1 -0
- package/lib/client/admin/pages/Overview.js +4 -4
- package/lib/client/admin/pages/Overview.js.map +1 -1
- package/lib/client/admin/pages/Quality.js +5 -5
- package/lib/client/admin/pages/Quality.js.map +1 -1
- package/lib/client/admin/pages/Review.js +5 -5
- package/lib/client/admin/pages/Review.js.map +1 -1
- package/lib/client/admin/pages/SkillFiles.js +3 -3
- package/lib/client/admin/pages/SkillFiles.js.map +1 -1
- package/lib/client/admin/pages/Users.js +12 -12
- package/lib/client/admin/pages/Users.js.map +1 -1
- package/lib/client/admin/routes.js +4 -0
- package/lib/client/admin/routes.js.map +1 -1
- package/lib/client/admin/shared.js +15 -6
- package/lib/client/admin/shared.js.map +1 -1
- package/lib/client/admin/styles.js +3 -0
- package/lib/client/admin/styles.js.map +1 -1
- package/lib/client/attestation.js +11 -2
- package/lib/client/attestation.js.map +1 -1
- package/lib/client/audit/QaAuditDialog.js +2 -2
- package/lib/client/audit/QaAuditDialog.js.map +1 -1
- package/lib/client/audit/QaAuditRowBadge.js +1 -1
- package/lib/client/audit/QaAuditRowBadge.js.map +1 -1
- package/lib/client/components/Markdown.js +7 -2
- package/lib/client/components/Markdown.js.map +1 -1
- package/lib/client/components/QaAgentsDrawer.js +7 -5
- package/lib/client/components/QaAgentsDrawer.js.map +1 -1
- package/lib/client/components/QaApproval.js +2 -2
- package/lib/client/components/QaApproval.js.map +1 -1
- package/lib/client/components/QaAuthGate.js +6 -6
- package/lib/client/components/QaAuthGate.js.map +1 -1
- package/lib/client/components/QaChangelog.js +64 -0
- package/lib/client/components/QaChangelog.js.map +1 -1
- package/lib/client/components/QaComposer.js +9 -6
- package/lib/client/components/QaComposer.js.map +1 -1
- package/lib/client/components/QaFileAttachment.js +1 -1
- package/lib/client/components/QaFileAttachment.js.map +1 -1
- package/lib/client/components/QaFilesPanel.js +7 -7
- package/lib/client/components/QaFilesPanel.js.map +1 -1
- package/lib/client/components/QaHeader.js +4 -4
- package/lib/client/components/QaHeader.js.map +1 -1
- package/lib/client/components/QaMessage.js +12 -12
- package/lib/client/components/QaMessage.js.map +1 -1
- package/lib/client/components/QaModal.js +11 -2
- package/lib/client/components/QaModal.js.map +1 -1
- package/lib/client/components/QaQuestions.js +4 -4
- package/lib/client/components/QaQuestions.js.map +1 -1
- package/lib/client/components/QaQueueDock.js +137 -0
- package/lib/client/components/QaQueueDock.js.map +1 -0
- package/lib/client/components/QaRequestQueue.js +22 -0
- package/lib/client/components/QaRequestQueue.js.map +1 -0
- package/lib/client/components/QaRightRail.js +2 -2
- package/lib/client/components/QaRightRail.js.map +1 -1
- package/lib/client/components/QaSidebar.js +66 -6
- package/lib/client/components/QaSidebar.js.map +1 -1
- package/lib/client/components/QaSourcesPanel.js +10 -8
- package/lib/client/components/QaSourcesPanel.js.map +1 -1
- package/lib/client/components/QaThemeSwitcher.js +19 -0
- package/lib/client/components/QaThemeSwitcher.js.map +1 -0
- package/lib/client/components/QaTurnNotice.js +40 -0
- package/lib/client/components/QaTurnNotice.js.map +1 -0
- package/lib/client/components/QaTurnRail.js +3 -3
- package/lib/client/components/QaTurnRail.js.map +1 -1
- package/lib/client/components/QaWelcomeNotice.js +2 -6
- package/lib/client/components/QaWelcomeNotice.js.map +1 -1
- package/lib/client/components/QaWidthHandle.js +2 -2
- package/lib/client/components/QaWidthHandle.js.map +1 -1
- package/lib/client/components/QaWorkGroup.js +5 -5
- package/lib/client/components/QaWorkGroup.js.map +1 -1
- package/lib/client/components/QaWorkspaceBrowser.js +10 -10
- package/lib/client/components/QaWorkspaceBrowser.js.map +1 -1
- package/lib/client/components/VariantSwitcher.js +1 -1
- package/lib/client/components/VariantSwitcher.js.map +1 -1
- package/lib/client/failure-copy.js +26 -8
- package/lib/client/failure-copy.js.map +1 -1
- package/lib/client/focus-ring.js +193 -0
- package/lib/client/focus-ring.js.map +1 -0
- package/lib/client/index.js +40 -20
- package/lib/client/index.js.map +1 -1
- package/lib/client/lineage.js +3 -16
- package/lib/client/lineage.js.map +1 -1
- package/lib/client/markdown/CodeBlock.js +2 -2
- package/lib/client/markdown/CodeBlock.js.map +1 -1
- package/lib/client/markdown/blocks.js +118 -31
- package/lib/client/markdown/blocks.js.map +1 -1
- package/lib/client/markdown/math.js +1 -1
- package/lib/client/markdown/math.js.map +1 -1
- package/lib/client/markdown/render.js +32 -16
- package/lib/client/markdown/render.js.map +1 -1
- package/lib/client/markdown/source-chip.js +3 -3
- package/lib/client/markdown/source-chip.js.map +1 -1
- package/lib/client/notifications/notification-dispatcher.js +90 -0
- package/lib/client/notifications/notification-dispatcher.js.map +1 -0
- package/lib/client/notifications/preferences.js +61 -0
- package/lib/client/notifications/preferences.js.map +1 -0
- package/lib/client/notifications/turn-completion-source.js +82 -0
- package/lib/client/notifications/turn-completion-source.js.map +1 -0
- package/lib/client/notifications/use-turn-notifications.js +108 -0
- package/lib/client/notifications/use-turn-notifications.js.map +1 -0
- package/lib/client/panels/PanelHost.js +5 -5
- package/lib/client/panels/PanelHost.js.map +1 -1
- package/lib/client/panels/PanelLauncher.js +2 -2
- package/lib/client/panels/PanelLauncher.js.map +1 -1
- package/lib/client/project-session-state.js +156 -4
- package/lib/client/project-session-state.js.map +1 -1
- package/lib/client/role/RoleSelector.js +9 -4
- package/lib/client/role/RoleSelector.js.map +1 -1
- package/lib/client/settings/card.js +58 -17
- package/lib/client/settings/card.js.map +1 -1
- package/lib/client/settings/fields.js +11 -10
- package/lib/client/settings/fields.js.map +1 -1
- package/lib/client/settings/format.js +0 -9
- package/lib/client/settings/format.js.map +1 -1
- package/lib/client/settings/sections/access.js +5 -5
- package/lib/client/settings/sections/access.js.map +1 -1
- package/lib/client/settings/sections/accounts.js +10 -10
- package/lib/client/settings/sections/accounts.js.map +1 -1
- package/lib/client/settings/sections/attachments.js +6 -6
- package/lib/client/settings/sections/attachments.js.map +1 -1
- package/lib/client/settings/sections/branding.js +7 -7
- package/lib/client/settings/sections/branding.js.map +1 -1
- package/lib/client/settings/sections/common.js +4 -2
- package/lib/client/settings/sections/common.js.map +1 -1
- package/lib/client/settings/sections/docs.js +4 -3
- package/lib/client/settings/sections/docs.js.map +1 -1
- package/lib/client/settings/sections/embedding.js +2 -2
- package/lib/client/settings/sections/embedding.js.map +1 -1
- package/lib/client/settings/sections/interface.js +12 -12
- package/lib/client/settings/sections/interface.js.map +1 -1
- package/lib/client/settings/sections/lockdown.js +9 -9
- package/lib/client/settings/sections/lockdown.js.map +1 -1
- package/lib/client/settings/sections/notes.js +12 -12
- package/lib/client/settings/sections/notes.js.map +1 -1
- package/lib/client/settings/sections/session.js +15 -12
- package/lib/client/settings/sections/session.js.map +1 -1
- package/lib/client/settings/sections/slash.js +9 -9
- package/lib/client/settings/sections/slash.js.map +1 -1
- package/lib/client/settings/sections/sources.js +26 -26
- package/lib/client/settings/sections/sources.js.map +1 -1
- package/lib/client/settings/sections/status.js +2 -2
- package/lib/client/settings/sections/status.js.map +1 -1
- package/lib/client/settings/styles.js +15 -9
- package/lib/client/settings/styles.js.map +1 -1
- package/lib/client/slash/SlashPalette.js +4 -4
- package/lib/client/slash/SlashPalette.js.map +1 -1
- package/lib/client/styles.js +133 -83
- package/lib/client/styles.js.map +1 -1
- package/lib/client/theme-preference.js +162 -0
- package/lib/client/theme-preference.js.map +1 -0
- package/lib/client/types.js +8 -0
- package/lib/client/types.js.map +1 -1
- package/lib/client/use-session-ui-state.js +7 -5
- package/lib/client/use-session-ui-state.js.map +1 -1
- package/lib/client/user-settings/GeneralSettingsPage.js +6 -6
- package/lib/client/user-settings/GeneralSettingsPage.js.map +1 -1
- package/lib/client/user-settings/IntegrationTokensPage.js +6 -6
- package/lib/client/user-settings/IntegrationTokensPage.js.map +1 -1
- package/lib/client/user-settings/NotificationSettingsPage.js +107 -0
- package/lib/client/user-settings/NotificationSettingsPage.js.map +1 -0
- package/lib/client/user-settings/PasswordSettingsPage.js +1 -1
- package/lib/client/user-settings/PasswordSettingsPage.js.map +1 -1
- package/lib/client/user-settings/ProfileSettingsPage.js +2 -2
- package/lib/client/user-settings/ProfileSettingsPage.js.map +1 -1
- package/lib/client/user-settings/SkillCatalog.js +3 -3
- package/lib/client/user-settings/SkillCatalog.js.map +1 -1
- package/lib/client/user-settings/SkillEditor.js +26 -8
- package/lib/client/user-settings/SkillEditor.js.map +1 -1
- package/lib/client/user-settings/SkillToolPicker.js +3 -3
- package/lib/client/user-settings/SkillToolPicker.js.map +1 -1
- package/lib/client/user-settings/SkillsSettingsPage.js +1 -1
- package/lib/client/user-settings/SkillsSettingsPage.js.map +1 -1
- package/lib/client/user-settings/StartersSettingsPage.js +6 -6
- package/lib/client/user-settings/StartersSettingsPage.js.map +1 -1
- package/lib/client/user-settings/UserSettingsDialog.js +7 -2
- package/lib/client/user-settings/UserSettingsDialog.js.map +1 -1
- package/lib/client/user-settings/copy.js +2 -0
- package/lib/client/user-settings/copy.js.map +1 -1
- package/lib/client/user-settings/draft.js +4 -1
- package/lib/client/user-settings/draft.js.map +1 -1
- package/lib/client/user-settings/fields.js +5 -5
- package/lib/client/user-settings/fields.js.map +1 -1
- package/lib/client.js +7228 -4002
- package/lib/client.js.map +1 -1
- package/lib/config-resolvers/defaults.js +7 -0
- package/lib/config-resolvers/defaults.js.map +1 -1
- package/lib/config-resolvers/lockdown.js +7 -0
- package/lib/config-resolvers/lockdown.js.map +1 -1
- package/lib/config-resolvers/notifications.js +15 -0
- package/lib/config-resolvers/notifications.js.map +1 -0
- package/lib/config-resolvers/session.js +11 -1
- package/lib/config-resolvers/session.js.map +1 -1
- package/lib/config.js +67 -18
- package/lib/config.js.map +1 -1
- package/lib/enforcement/tool-grants.js +6 -6
- package/lib/enforcement/tool-grants.js.map +1 -1
- package/lib/index.js +145 -30
- package/lib/index.js.map +1 -1
- package/lib/integration/answer.js +74 -8
- package/lib/integration/answer.js.map +1 -1
- package/lib/integration/expert-memory.js +39 -0
- package/lib/integration/expert-memory.js.map +1 -0
- package/lib/integration/host-runner.js +26 -14
- package/lib/integration/host-runner.js.map +1 -1
- package/lib/notifications.js +66 -0
- package/lib/notifications.js.map +1 -0
- package/lib/personal-skills/errors.js.map +1 -1
- package/lib/personal-skills/service.js +81 -7
- package/lib/personal-skills/service.js.map +1 -1
- package/lib/prompt-notes.js +2 -10
- package/lib/prompt-notes.js.map +1 -1
- package/lib/qa-tools/docs-tools.js +1 -1
- package/lib/qa-tools/docs-tools.js.map +1 -1
- package/lib/qa-tools/lifecycle.js +6 -3
- package/lib/qa-tools/lifecycle.js.map +1 -1
- package/lib/request-queue.js +32 -0
- package/lib/request-queue.js.map +1 -0
- package/lib/resolve-config.js +2 -0
- package/lib/resolve-config.js.map +1 -1
- package/lib/secure-session.js +7 -2
- package/lib/secure-session.js.map +1 -1
- package/lib/shared/settings.js +19 -2
- package/lib/shared/settings.js.map +1 -1
- package/lib/turn-failure-log.js +67 -0
- package/lib/turn-failure-log.js.map +1 -0
- package/lib/typert.host.js +1398 -651
- package/lib/typert.remote-client.d.ts +15 -1
- package/lib/typert.remote-client.js +1315 -645
- package/lib/types/access/capability-catalog.d.ts +20 -4
- package/lib/types/access/model.d.ts +23 -0
- package/lib/types/access/service.d.ts +2 -3
- package/lib/types/account-remotes.d.ts +8 -1
- package/lib/types/accounts/database.d.ts +13 -3
- package/lib/types/accounts/errors.d.ts +9 -1
- package/lib/types/accounts/file.d.ts +12 -0
- package/lib/types/accounts/store.d.ts +30 -2
- package/lib/types/admin/quality-store.d.ts +33 -0
- package/lib/types/admin/service.d.ts +60 -1
- package/lib/types/client/QaAccountsController.d.ts +25 -2
- package/lib/types/client/QaConfigController.d.ts +2 -2
- package/lib/types/client/QaSessionController.d.ts +199 -6
- package/lib/types/client/QaSurface.d.ts +6 -1
- package/lib/types/client/admin/pages/ExpertMemory.d.ts +19 -0
- package/lib/types/client/admin/routes.d.ts +3 -1
- package/lib/types/client/admin/shared.d.ts +4 -0
- package/lib/types/client/components/Markdown.d.ts +8 -1
- package/lib/types/client/components/QaHeader.d.ts +3 -1
- package/lib/types/client/components/QaModal.d.ts +7 -0
- package/lib/types/client/components/QaQueueDock.d.ts +27 -0
- package/lib/types/client/components/QaRequestQueue.d.ts +20 -0
- package/lib/types/client/components/QaThemeSwitcher.d.ts +14 -0
- package/lib/types/client/components/QaTurnNotice.d.ts +55 -0
- package/lib/types/client/focus-ring.d.ts +65 -0
- package/lib/types/client/lineage.d.ts +3 -13
- package/lib/types/client/markdown/blocks.d.ts +30 -2
- package/lib/types/client/markdown/render.d.ts +4 -1
- package/lib/types/client/notifications/notification-dispatcher.d.ts +68 -0
- package/lib/types/client/notifications/preferences.d.ts +44 -0
- package/lib/types/client/notifications/turn-completion-source.d.ts +114 -0
- package/lib/types/client/notifications/use-turn-notifications.d.ts +51 -0
- package/lib/types/client/project-session-state.d.ts +35 -1
- package/lib/types/client/role/RoleSelector.d.ts +7 -1
- package/lib/types/client/settings/card.d.ts +36 -5
- package/lib/types/client/settings/fields.d.ts +24 -0
- package/lib/types/client/settings/format.d.ts +0 -5
- package/lib/types/client/settings/sections/common.d.ts +3 -1
- package/lib/types/client/settings/sections/session.d.ts +2 -1
- package/lib/types/client/settings/styles.d.ts +9 -4
- package/lib/types/client/settings-extensions/user-session.d.ts +1 -1
- package/lib/types/client/theme-preference.d.ts +69 -0
- package/lib/types/client/types.d.ts +25 -1
- package/lib/types/client/use-session-ui-state.d.ts +6 -4
- package/lib/types/client/user-settings/GeneralSettingsPage.d.ts +5 -5
- package/lib/types/client/user-settings/NotificationSettingsPage.d.ts +40 -0
- package/lib/types/client/user-settings/UserSettingsDialog.d.ts +12 -2
- package/lib/types/client/user-settings/draft.d.ts +4 -1
- package/lib/types/client/user-settings/fields.d.ts +7 -0
- package/lib/types/config-resolvers/notifications.d.ts +10 -0
- package/lib/types/config-resolvers/session.d.ts +6 -0
- package/lib/types/config.d.ts +20 -1
- package/lib/types/index.d.ts +53 -5
- package/lib/types/integration/answer.d.ts +27 -0
- package/lib/types/integration/expert-memory.d.ts +49 -0
- package/lib/types/notifications.d.ts +32 -0
- package/lib/types/personal-skills/errors.d.ts +1 -1
- package/lib/types/personal-skills/service.d.ts +6 -0
- package/lib/types/prompt-notes.d.ts +17 -4
- package/lib/types/request-queue.d.ts +32 -0
- package/lib/types/shared/settings.d.ts +19 -2
- package/lib/types/turn-failure-log.d.ts +18 -0
- package/lib/types/types.d.ts +180 -4
- package/package.json +64 -63
- package/scripts/attach-workspace-sessions.mjs +24 -5
- package/scripts/repair-session-events.mjs +25 -5
- package/lib/client/markdown/MermaidBlock.js +0 -80
- package/lib/client/markdown/MermaidBlock.js.map +0 -1
- package/lib/types/client/markdown/MermaidBlock.d.ts +0 -6
|
@@ -4,7 +4,7 @@ import { resolveSlashRoute } from "./slash/slash-router.js";
|
|
|
4
4
|
import { SLASH_DISABLED_COPY, slashAttachmentsCopy, slashExecuteFailureCopy, slashHostRefusalCopy, slashRefusalCopy, slashWithheldCopy, } from "./slash/copy.js";
|
|
5
5
|
import { canOpenAsCompatibilityReadOnly, QaPolicyAttestationError, } from "./attestation.js";
|
|
6
6
|
import { QaChatIndex } from "./chat-index.js";
|
|
7
|
-
import { isDelegatedSession, visibleSubagentCandidates
|
|
7
|
+
import { isDelegatedSession, visibleSubagentCandidates } from "./lineage.js";
|
|
8
8
|
import { SessionAssetRepository } from "./session-assets.js";
|
|
9
9
|
import { createQaSession } from "./create-session.js";
|
|
10
10
|
import { buildQaPromptContent, buildQaSlashAttachments, stageQaFiles, } from "./prompt-content.js";
|
|
@@ -17,8 +17,14 @@ import { QA_SESSION_IDLE_STATE } from "./types.js";
|
|
|
17
17
|
import { waitFor } from "./wait-for.js";
|
|
18
18
|
import { QA_REGENERATE_MARKER } from "./QaTranscriptAdapter.js";
|
|
19
19
|
import { readableSubagentName } from "./settlement.js";
|
|
20
|
-
import { projectBoundSessionState } from "./project-session-state.js";
|
|
21
|
-
|
|
20
|
+
import { hostNamedSubmissionIds, projectBoundSessionState, } from "./project-session-state.js";
|
|
21
|
+
/**
|
|
22
|
+
* `rc.2` deleted the Host-wide navigation: a Session lives while the view that
|
|
23
|
+
* retained it holds the reference, so binding a chat means retaining it for as
|
|
24
|
+
* long as this controller projects it.
|
|
25
|
+
*/
|
|
26
|
+
const QA_SURFACE_SESSION_SOURCE = "qaSurface";
|
|
27
|
+
import { chatLegacyOf, projectTurnSources, sourceAnchorRoot, } from "./turn-sources.js";
|
|
22
28
|
const CONFIGURATION_ERROR = "Настройки помощника недоступны.";
|
|
23
29
|
/**
|
|
24
30
|
* How long a catalog answer stays fresh enough to skip a re-read when the
|
|
@@ -32,6 +38,46 @@ const SLASH_CATALOG_STALE_MS = 3_000;
|
|
|
32
38
|
* second of latency is invisible; the poll only runs while a turn runs.
|
|
33
39
|
*/
|
|
34
40
|
const PENDING_POLL_MS = 1_000;
|
|
41
|
+
/**
|
|
42
|
+
* Source of chat identities. Page-wide rather than per controller: the surface
|
|
43
|
+
* re-creates this controller whenever the account, the config or the route
|
|
44
|
+
* changes, and a counter that restarted at its first value would name the new
|
|
45
|
+
* controller's chat exactly as it named the old one's — which is how a draft,
|
|
46
|
+
* its attachments and its per-chat UI state walked from one chat into another.
|
|
47
|
+
* `0` stays with `QA_SESSION_IDLE_STATE`, the snapshot the surface reads before
|
|
48
|
+
* any controller exists.
|
|
49
|
+
*/
|
|
50
|
+
let chatKeySequence = 0;
|
|
51
|
+
function nextChatKey() {
|
|
52
|
+
chatKeySequence += 1;
|
|
53
|
+
return chatKeySequence;
|
|
54
|
+
}
|
|
55
|
+
/** What the strip says when the Host refuses one queue operation. */
|
|
56
|
+
const QUEUE_FAILURE_COPY = {
|
|
57
|
+
edit: "Не удалось изменить сообщение в очереди. Возможно, оно уже отправлено.",
|
|
58
|
+
remove: "Не удалось убрать сообщение из очереди. Возможно, оно уже отправлено.",
|
|
59
|
+
steer: "Не удалось отправить сообщение сразу. Попробуйте ещё раз.",
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Image previews for the Host echo of a queued send. A data URL, not the
|
|
63
|
+
* composer's blob URL: the composer revokes the blob the moment the send is
|
|
64
|
+
* accepted, while the Host keeps the echo until its queue occurrence lands.
|
|
65
|
+
* Files are left out — the Host echo wants a durable attachment reference, and
|
|
66
|
+
* a staged receipt is a write handle, not one.
|
|
67
|
+
*/
|
|
68
|
+
function submissionImages(attachments) {
|
|
69
|
+
return attachments.flatMap((attachment) => attachment.kind === "image"
|
|
70
|
+
? [
|
|
71
|
+
{
|
|
72
|
+
type: "image",
|
|
73
|
+
value: {
|
|
74
|
+
previewUrl: `data:${attachment.mediaType};base64,${attachment.data}`,
|
|
75
|
+
name: attachment.name,
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
]
|
|
79
|
+
: []);
|
|
80
|
+
}
|
|
35
81
|
/**
|
|
36
82
|
* The only module that couples QA behavior to DSH Session/client APIs. It owns
|
|
37
83
|
* the chat lifecycle state machine; the observable wait, browser-local chat
|
|
@@ -59,8 +105,11 @@ export class QaSessionController {
|
|
|
59
105
|
streamPublisher;
|
|
60
106
|
state = QA_SESSION_IDLE_STATE;
|
|
61
107
|
session;
|
|
108
|
+
/** The reference that keeps {@link session} alive; released with the binding. */
|
|
109
|
+
sessionReference;
|
|
62
110
|
conversationBinding;
|
|
63
111
|
unsubscribeSession;
|
|
112
|
+
unsubscribeInbox;
|
|
64
113
|
unsubscribeChat;
|
|
65
114
|
unsubscribeConnection;
|
|
66
115
|
ensuring;
|
|
@@ -69,6 +118,18 @@ export class QaSessionController {
|
|
|
69
118
|
admissionPending = false;
|
|
70
119
|
pendingSubmission;
|
|
71
120
|
pendingSequence = 0;
|
|
121
|
+
/**
|
|
122
|
+
* Request ids this binding has seen the Host name — in the Inbox queue or as a
|
|
123
|
+
* durable transcript row. The mask that keeps an echo the session library
|
|
124
|
+
* failed to retire from coming back as a question still crossing the
|
|
125
|
+
* transport: the retirement is `@deepseek-ai/dsh-api-session-controller`'s own
|
|
126
|
+
* contract, deferred to an animation frame that a surface with no frame clock
|
|
127
|
+
* never runs, and `projectQueue` in project-session-state.ts carries the
|
|
128
|
+
* citation, what the substitute costs and does not cost, and the point where
|
|
129
|
+
* this set is dead code. Kept for the whole binding: a request id is minted per
|
|
130
|
+
* submission, so an id the Host has named can never belong to a later send.
|
|
131
|
+
*/
|
|
132
|
+
admittedSubmissions = new Set();
|
|
72
133
|
policyReady = false;
|
|
73
134
|
/** Historical transcript retained after the Host classifies policy drift. */
|
|
74
135
|
compatibilityReadOnly = false;
|
|
@@ -79,6 +140,31 @@ export class QaSessionController {
|
|
|
79
140
|
connectedOnce;
|
|
80
141
|
disposed = false;
|
|
81
142
|
generation = 0;
|
|
143
|
+
/**
|
|
144
|
+
* The session the current chat identity names, or null while the chat it
|
|
145
|
+
* names has no session yet. This is what makes the identity a chat identity
|
|
146
|
+
* rather than a counter: {@link bind} compares the session it is about to
|
|
147
|
+
* adopt against it, so any path that ends up in another session takes another
|
|
148
|
+
* identity — and a draft adopting its first session keeps the one it holds.
|
|
149
|
+
* It only ever names a session this controller holds: {@link bind} hands it
|
|
150
|
+
* out once the adoption has cleared every wait that caller asked it to take —
|
|
151
|
+
* a chat is opened and attested, a subagent transcript is only opened — and
|
|
152
|
+
* both a failed adoption and {@link unbind} take it back. The converse does
|
|
153
|
+
* not hold: a held session may name nothing, which is the transcript of an
|
|
154
|
+
* attestation this chat could not pass and whose caller still reads it. So a
|
|
155
|
+
* chat whose first send fell short keeps its identity while naming no session,
|
|
156
|
+
* and the retry — which brings a fresh id — stays in the composer the question
|
|
157
|
+
* was typed into instead of reading as a move to another chat.
|
|
158
|
+
*/
|
|
159
|
+
namedSession = null;
|
|
160
|
+
/**
|
|
161
|
+
* Chat identity handed to the surface (`QaSessionState.chatKey`). Taken from
|
|
162
|
+
* the page-wide sequence on every move to another chat, and never when the
|
|
163
|
+
* bound draft merely creates its session: a component that keeps per-chat
|
|
164
|
+
* state in React therefore survives the first prompt of a new chat, while two
|
|
165
|
+
* chats never read the same identity.
|
|
166
|
+
*/
|
|
167
|
+
chatKey = nextChatKey();
|
|
82
168
|
chatsRevision = 0;
|
|
83
169
|
selectedSubrole;
|
|
84
170
|
adminPreview;
|
|
@@ -88,6 +174,9 @@ export class QaSessionController {
|
|
|
88
174
|
hostApprovals;
|
|
89
175
|
/** Host-side question requests of the bound chat waiting for the operator. */
|
|
90
176
|
hostQuestions;
|
|
177
|
+
queueRemote;
|
|
178
|
+
/** Last send the stand had no room for; drives the request-ceiling dialog. */
|
|
179
|
+
requestQueueNotice = null;
|
|
91
180
|
/** Set while a running turn is polled for parked requests. */
|
|
92
181
|
pendingTimer;
|
|
93
182
|
/**
|
|
@@ -146,6 +235,7 @@ export class QaSessionController {
|
|
|
146
235
|
this.hostSources = new QaHostSourceBridge(this.sourceApi, () => this.accounts?.token() ?? "");
|
|
147
236
|
this.hostApprovals = new QaHostApprovalBridge(options.approvalApi);
|
|
148
237
|
this.hostQuestions = new QaHostQuestionBridge(options.questionApi);
|
|
238
|
+
this.queueRemote = options.queueStatus;
|
|
149
239
|
this.timeoutMs = options.timeoutMs ?? 15_000;
|
|
150
240
|
this.streamPublisher = new StreamPublisher(options.streamIntervalMs ?? 66);
|
|
151
241
|
this.connectedOnce = this.connection.getSnapshot() !== undefined;
|
|
@@ -192,11 +282,22 @@ export class QaSessionController {
|
|
|
192
282
|
if (this.session === undefined) {
|
|
193
283
|
// A draft chat materializes its session only now: nothing was created
|
|
194
284
|
// when the user pressed "New chat", so the first submission pays for it.
|
|
195
|
-
if (
|
|
285
|
+
if (this.materializing !== undefined) {
|
|
286
|
+
// A first prompt is already paying for the session; this one would
|
|
287
|
+
// only race it. Say so, because the alternative is a silent drop.
|
|
288
|
+
this.refuseSend("Чат ещё создаётся. Отправьте сообщение ещё раз.");
|
|
196
289
|
return undefined;
|
|
197
|
-
|
|
290
|
+
}
|
|
291
|
+
if (!this.drafting) {
|
|
292
|
+
this.refuseSend("Не удалось отправить сообщение: чат не открыт.");
|
|
198
293
|
return undefined;
|
|
199
294
|
}
|
|
295
|
+
const created = await this.materializeDraft();
|
|
296
|
+
// A refusal reports itself inside materializeDraft, with the Host's
|
|
297
|
+
// reason, and a draft the user left while it was being created is
|
|
298
|
+
// attested by whichever chat took its place: neither answer here.
|
|
299
|
+
if (!created || this.session === undefined)
|
|
300
|
+
return undefined;
|
|
200
301
|
}
|
|
201
302
|
const step = await this.attestPolicy();
|
|
202
303
|
if (step.kind === "stale") {
|
|
@@ -269,13 +370,43 @@ export class QaSessionController {
|
|
|
269
370
|
this.publish();
|
|
270
371
|
return false;
|
|
271
372
|
}
|
|
373
|
+
// A message that joins the queue of a chat which is already answering costs
|
|
374
|
+
// the stand no second place — the turn is running anyway — so the ceiling
|
|
375
|
+
// has nothing to say about it. Read before the round-trip below: asking a
|
|
376
|
+
// busy Host how busy it is, in order to refuse the very queue that keeps
|
|
377
|
+
// the busy chat from stalling, would be the wrong answer twice over.
|
|
378
|
+
const joiningQueue = this.session?.getSnapshot().running === true;
|
|
379
|
+
if (this.config.session.maxActiveRequests > 0 && !joiningQueue) {
|
|
380
|
+
// The ceiling is read before anything is spent. A draft chat has no
|
|
381
|
+
// session yet, so a question this stand has no room for must not
|
|
382
|
+
// materialize one, must not enter the transcript, and must not leave the
|
|
383
|
+
// composer — which is what a refusal ahead of `beginSubmission` buys.
|
|
384
|
+
const bound = this.session;
|
|
385
|
+
const ceiling = await this.readQueueStatus();
|
|
386
|
+
// A chat that went away during the read answers for its own sends. One
|
|
387
|
+
// that merely arrived is this draft's session landing underneath, which
|
|
388
|
+
// the submission rides exactly the way it always did.
|
|
389
|
+
if (this.disposed || (bound !== undefined && this.session !== bound))
|
|
390
|
+
return false;
|
|
391
|
+
if (ceiling !== null && ceiling.full) {
|
|
392
|
+
this.requestQueueNotice = ceiling;
|
|
393
|
+
this.publish();
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
272
397
|
this.operationError = null;
|
|
398
|
+
this.requestQueueNotice = null;
|
|
399
|
+
// A running turn has nowhere for a new question to land but its queue, and
|
|
400
|
+
// the queue is the Host's state, not the transcript's: echoing it into the
|
|
401
|
+
// transcript would show the same message twice — once waiting, once sent.
|
|
402
|
+
const queueing = this.session?.getSnapshot().running === true;
|
|
273
403
|
// Regeneration rides a hidden marker and must never leak it into the UI.
|
|
274
|
-
const submission = prompt === QA_REGENERATE_MARKER
|
|
404
|
+
const submission = prompt === QA_REGENERATE_MARKER || queueing
|
|
275
405
|
? undefined
|
|
276
406
|
: this.beginSubmission(prompt, attachments);
|
|
277
407
|
let accepted = false;
|
|
278
408
|
let target;
|
|
409
|
+
let echo;
|
|
279
410
|
try {
|
|
280
411
|
target = await this.liveTarget();
|
|
281
412
|
if (target === undefined)
|
|
@@ -284,6 +415,7 @@ export class QaSessionController {
|
|
|
284
415
|
// Recovery may have replaced the original chat with a fresh session;
|
|
285
416
|
// reconcile against the actual prompt target, not the abandoned one.
|
|
286
417
|
submission.baselineUserCount = this.state.messages.filter((message) => message.role === "user").length;
|
|
418
|
+
submission.baselineTurnEnds = this.durableTurnEndCount();
|
|
287
419
|
}
|
|
288
420
|
let receipts = new Map();
|
|
289
421
|
if (files.length > 0) {
|
|
@@ -299,11 +431,30 @@ export class QaSessionController {
|
|
|
299
431
|
return false;
|
|
300
432
|
}
|
|
301
433
|
receipts = outcome.receipts;
|
|
434
|
+
// The upload was an external round-trip: the user may have left this
|
|
435
|
+
// chat or closed the surface while it ran. Re-checked before anything is
|
|
436
|
+
// written to the state and before the prompt is sent, so a draft meant
|
|
437
|
+
// for the abandoned chat cannot land in it or in its replacement.
|
|
438
|
+
if (this.disposed || this.session !== target)
|
|
439
|
+
return false;
|
|
302
440
|
}
|
|
441
|
+
const content = buildQaPromptContent(prompt, attachments, receipts);
|
|
442
|
+
// A queued send needs the Host's own echo, not the transcript one: the
|
|
443
|
+
// Host mints the identity the prompt carries, retires the echo when its
|
|
444
|
+
// queue occurrence arrives, and retires it again when an identified
|
|
445
|
+
// prompt fails, so the strip never keeps a row the server refused.
|
|
446
|
+
echo = queueing
|
|
447
|
+
? target.beginSubmission({
|
|
448
|
+
mode: "queue",
|
|
449
|
+
text: prompt,
|
|
450
|
+
attachments: submissionImages(attachments),
|
|
451
|
+
})
|
|
452
|
+
: undefined;
|
|
303
453
|
this.admissionPending = true;
|
|
304
454
|
this.publish();
|
|
305
|
-
const
|
|
306
|
-
|
|
455
|
+
const result = queueing
|
|
456
|
+
? await target.prompt(content, "queue", undefined, echo?.requestId)
|
|
457
|
+
: await target.prompt(content, "queue");
|
|
307
458
|
if (this.session !== target)
|
|
308
459
|
return false;
|
|
309
460
|
if (!result.ok) {
|
|
@@ -321,6 +472,9 @@ export class QaSessionController {
|
|
|
321
472
|
}
|
|
322
473
|
catch (error) {
|
|
323
474
|
this.admissionPending = false;
|
|
475
|
+
// The prompt call never completed, so nothing settled the echo; a queued
|
|
476
|
+
// row would otherwise sit in the strip claiming to be on its way.
|
|
477
|
+
echo?.abandon();
|
|
324
478
|
console.error("dsh-qa-surface: prompt failed", error);
|
|
325
479
|
if (this.session === target) {
|
|
326
480
|
this.operationError = "Не удалось отправить сообщение.";
|
|
@@ -337,6 +491,37 @@ export class QaSessionController {
|
|
|
337
491
|
}
|
|
338
492
|
}
|
|
339
493
|
}
|
|
494
|
+
/**
|
|
495
|
+
* What the stand answers about its load, or null when it does not answer.
|
|
496
|
+
*
|
|
497
|
+
* Every failure here sends the question anyway. The ceiling exists to keep a
|
|
498
|
+
* weak model from drowning, not to be a door a browser cannot open: an
|
|
499
|
+
* unreadable count says nothing about the load, and refusing a visitor on
|
|
500
|
+
* that would be a worse answer than the one they would have got.
|
|
501
|
+
*/
|
|
502
|
+
async readQueueStatus() {
|
|
503
|
+
if (this.queueRemote === undefined)
|
|
504
|
+
return null;
|
|
505
|
+
try {
|
|
506
|
+
const result = await this.queueRemote();
|
|
507
|
+
return result.ok ? result.value : null;
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
console.error("dsh-qa-surface: queue status unavailable", error);
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Close the request-ceiling dialog. Nothing is retried and nothing is
|
|
516
|
+
* dropped: the held-back question is still the composer's own text, so asking
|
|
517
|
+
* it again is the visitor's keystroke, taken once the stand has a place free.
|
|
518
|
+
*/
|
|
519
|
+
dismissRequestQueueNotice() {
|
|
520
|
+
if (this.requestQueueNotice === null)
|
|
521
|
+
return;
|
|
522
|
+
this.requestQueueNotice = null;
|
|
523
|
+
this.publish();
|
|
524
|
+
}
|
|
340
525
|
/**
|
|
341
526
|
* Run one admitted human command. The Host answers it and writes the
|
|
342
527
|
* lifecycle into the session log; this method only decides what the composer
|
|
@@ -378,6 +563,12 @@ export class QaSessionController {
|
|
|
378
563
|
return false;
|
|
379
564
|
}
|
|
380
565
|
receipts = outcome.receipts;
|
|
566
|
+
// The upload was an external round-trip: the user may have left this
|
|
567
|
+
// chat or closed the surface while it ran. Re-checked before the command
|
|
568
|
+
// reaches the Host, so a command meant for the abandoned chat cannot run
|
|
569
|
+
// in it or in its replacement.
|
|
570
|
+
if (this.disposed || this.session !== target)
|
|
571
|
+
return false;
|
|
381
572
|
}
|
|
382
573
|
const result = await this.slashApi.execute(this.accounts?.token() ?? "", String(target.sessionId), line, buildQaSlashAttachments(attachments, receipts));
|
|
383
574
|
if (this.session !== target)
|
|
@@ -529,6 +720,16 @@ export class QaSessionController {
|
|
|
529
720
|
this.slashViewCache = view;
|
|
530
721
|
return view;
|
|
531
722
|
}
|
|
723
|
+
/**
|
|
724
|
+
* Turns the loaded Chat window has recorded as durably finished. The Host
|
|
725
|
+
* writes one per `turn/end`, so the count only moves on a fact the
|
|
726
|
+
* transcript itself carries — unlike the session's running bit, which the
|
|
727
|
+
* list relay can also deliver stale.
|
|
728
|
+
*/
|
|
729
|
+
durableTurnEndCount() {
|
|
730
|
+
return chatLegacyOf(this.conversationBinding?.snapshot.getSnapshot())
|
|
731
|
+
.turnEnds.size;
|
|
732
|
+
}
|
|
532
733
|
/** Publish a browser-only copy before any network or Host admission awaits. */
|
|
533
734
|
beginSubmission(text, attachments) {
|
|
534
735
|
const submission = {
|
|
@@ -560,8 +761,8 @@ export class QaSessionController {
|
|
|
560
761
|
: []),
|
|
561
762
|
},
|
|
562
763
|
baselineUserCount: this.state.messages.filter((message) => message.role === "user").length,
|
|
764
|
+
baselineTurnEnds: this.durableTurnEndCount(),
|
|
563
765
|
accepted: false,
|
|
564
|
-
sawRunning: false,
|
|
565
766
|
};
|
|
566
767
|
this.pendingSubmission = submission;
|
|
567
768
|
this.publish();
|
|
@@ -596,11 +797,54 @@ export class QaSessionController {
|
|
|
596
797
|
}
|
|
597
798
|
this.publish();
|
|
598
799
|
}
|
|
800
|
+
/**
|
|
801
|
+
* Edit, send now, or drop one message that still waits for its turn. Every
|
|
802
|
+
* operation addresses the occurrence id the Host's queue frame carried, so a
|
|
803
|
+
* row the agent claimed a moment ago is refused by the Host rather than
|
|
804
|
+
* quietly dropped here.
|
|
805
|
+
*
|
|
806
|
+
* The answer is the refusal text rather than a flag, and the strip beside the
|
|
807
|
+
* composer keeps showing it: a session frame follows a refusal within
|
|
808
|
+
* milliseconds, and the shared error line is cleared by exactly that frame.
|
|
809
|
+
*/
|
|
810
|
+
async queueAction(id, action, text = "") {
|
|
811
|
+
const target = this.session;
|
|
812
|
+
if (target === undefined || !this.state.canEditQueue)
|
|
813
|
+
return null;
|
|
814
|
+
const wire = action === "remove"
|
|
815
|
+
? { kind: "remove" }
|
|
816
|
+
: action === "steer"
|
|
817
|
+
? { kind: "steer" }
|
|
818
|
+
: { kind: "edit", content: [{ type: "text", text }] };
|
|
819
|
+
try {
|
|
820
|
+
const result = await target.updateQueue(
|
|
821
|
+
// The strip holds the id as text; only the Host's brand knows it back.
|
|
822
|
+
id, wire);
|
|
823
|
+
if (this.session !== target)
|
|
824
|
+
return null;
|
|
825
|
+
if (!result.ok)
|
|
826
|
+
return QUEUE_FAILURE_COPY[action];
|
|
827
|
+
this.publish();
|
|
828
|
+
return null;
|
|
829
|
+
}
|
|
830
|
+
catch (error) {
|
|
831
|
+
console.error("dsh-qa-surface: queue operation failed", error);
|
|
832
|
+
if (this.session !== target)
|
|
833
|
+
return null;
|
|
834
|
+
return QUEUE_FAILURE_COPY[action];
|
|
835
|
+
}
|
|
836
|
+
}
|
|
599
837
|
/**
|
|
600
838
|
* Enter the "new chat" draft: show an empty composer without creating any
|
|
601
839
|
* session. Nothing appears in the chat list and no Host session is spent
|
|
602
840
|
* until the first prompt is actually sent, which materializes the session
|
|
603
841
|
* lazily ({@link materializeDraft}). Fixed-policy deployments cannot draft.
|
|
842
|
+
*
|
|
843
|
+
* A draft is a chat of its own, so entering one takes the screen the way
|
|
844
|
+
* {@link switchTo} does; see the note where the generation moves. Stopping the
|
|
845
|
+
* turn that was running first takes a round-trip, and a chat opened through it
|
|
846
|
+
* keeps the screen: the draft is what the visitor asked for before that click,
|
|
847
|
+
* so this call gives up rather than taking the chat back from under them.
|
|
604
848
|
*/
|
|
605
849
|
async startDraft(policyChange = false) {
|
|
606
850
|
if (this.disposed ||
|
|
@@ -610,6 +854,7 @@ export class QaSessionController {
|
|
|
610
854
|
!this.config.lockdown.allowSessionReset))
|
|
611
855
|
return;
|
|
612
856
|
const previous = this.session;
|
|
857
|
+
const owned = this.generation;
|
|
613
858
|
if (!this.compatibilityReadOnly &&
|
|
614
859
|
previous?.getSnapshot().running === true) {
|
|
615
860
|
try {
|
|
@@ -618,15 +863,32 @@ export class QaSessionController {
|
|
|
618
863
|
catch (error) {
|
|
619
864
|
console.error("dsh-qa-surface: stop before draft failed", error);
|
|
620
865
|
}
|
|
866
|
+
// Asking the Host to stop a running turn is a round-trip, and the visitor
|
|
867
|
+
// reaches the chat list through it. Whoever took the screen during that
|
|
868
|
+
// window owns it: a draft asked for before the trip would otherwise take
|
|
869
|
+
// the screen back, unbind the chat that had just been adopted, and answer
|
|
870
|
+
// its send with nothing — the same takeover this method's own generation
|
|
871
|
+
// raise exists to stop.
|
|
872
|
+
if (this.disposed || this.generation !== owned)
|
|
873
|
+
return;
|
|
621
874
|
}
|
|
875
|
+
// Taking the screen from the chat it was showing moves the generation, as
|
|
876
|
+
// every other path that retires a binding does: an adoption still looking
|
|
877
|
+
// for its session measures itself against that generation, and would
|
|
878
|
+
// otherwise take the screen back for the chat the visitor just left. Its
|
|
879
|
+
// materialization goes the same way — it belongs to the ending chat, and must
|
|
880
|
+
// not hold this draft in "chat still being created" until a round-trip nobody
|
|
881
|
+
// is waiting for answers.
|
|
882
|
+
this.generation += 1;
|
|
883
|
+
this.materializing = undefined;
|
|
622
884
|
this.drafting = true;
|
|
623
|
-
this.
|
|
885
|
+
this.openChat();
|
|
624
886
|
this.unbind();
|
|
625
887
|
this.operationError = null;
|
|
626
|
-
this.admissionPending = false;
|
|
627
888
|
this.policyReady = false;
|
|
628
889
|
this.state = {
|
|
629
890
|
...QA_SESSION_IDLE_STATE,
|
|
891
|
+
chatKey: this.chatKey,
|
|
630
892
|
chatsRevision: this.chatsRevision,
|
|
631
893
|
};
|
|
632
894
|
this.publish();
|
|
@@ -652,7 +914,12 @@ export class QaSessionController {
|
|
|
652
914
|
});
|
|
653
915
|
if (this.disposed || operation !== this.generation)
|
|
654
916
|
return false;
|
|
655
|
-
|
|
917
|
+
// A step back is not a materialized draft: this adoption never ended up
|
|
918
|
+
// holding a session, so the draft has to stay a draft. Calling it done
|
|
919
|
+
// leaves a chat with neither a session nor a draft behind, and every
|
|
920
|
+
// later question in it answered with "chat not open".
|
|
921
|
+
if (!(await this.bind(id, { operation })))
|
|
922
|
+
return false;
|
|
656
923
|
if (this.disposed || operation !== this.generation)
|
|
657
924
|
return false;
|
|
658
925
|
this.drafting = false;
|
|
@@ -692,14 +959,14 @@ export class QaSessionController {
|
|
|
692
959
|
return;
|
|
693
960
|
const operation = ++this.generation;
|
|
694
961
|
this.drafting = false;
|
|
695
|
-
this.
|
|
962
|
+
this.openChat();
|
|
696
963
|
this.viewingSubagent = null;
|
|
697
964
|
this.unbind();
|
|
698
965
|
this.operationError = null;
|
|
699
|
-
this.admissionPending = false;
|
|
700
966
|
this.policyReady = false;
|
|
701
967
|
this.state = {
|
|
702
968
|
...QA_SESSION_IDLE_STATE,
|
|
969
|
+
chatKey: this.chatKey,
|
|
703
970
|
chatsRevision: this.chatsRevision,
|
|
704
971
|
phase: "creating",
|
|
705
972
|
};
|
|
@@ -721,7 +988,15 @@ export class QaSessionController {
|
|
|
721
988
|
this.forgetChat(sessionId);
|
|
722
989
|
throw new Error("Этот чат больше недоступен.");
|
|
723
990
|
}
|
|
724
|
-
await this.bind(sessionId, {
|
|
991
|
+
const adopted = await this.bind(sessionId, {
|
|
992
|
+
operation,
|
|
993
|
+
allowCompatibilityReadOnly: true,
|
|
994
|
+
});
|
|
995
|
+
// The chat this switch was asked for is on screen only if this adoption
|
|
996
|
+
// finished: a step back means another chat took the screen in the meantime,
|
|
997
|
+
// and persisting this id as the open one would reopen it on the next load.
|
|
998
|
+
if (!adopted)
|
|
999
|
+
return;
|
|
725
1000
|
if (this.disposed || operation !== this.generation)
|
|
726
1001
|
return;
|
|
727
1002
|
this.chats.saveActive(sessionId);
|
|
@@ -751,14 +1026,14 @@ export class QaSessionController {
|
|
|
751
1026
|
}
|
|
752
1027
|
const operation = ++this.generation;
|
|
753
1028
|
this.drafting = false;
|
|
754
|
-
this.
|
|
1029
|
+
this.openChat();
|
|
755
1030
|
this.viewingSubagent = { id, title };
|
|
756
1031
|
this.unbind();
|
|
757
1032
|
this.operationError = null;
|
|
758
|
-
this.admissionPending = false;
|
|
759
1033
|
this.policyReady = false;
|
|
760
1034
|
this.state = {
|
|
761
1035
|
...QA_SESSION_IDLE_STATE,
|
|
1036
|
+
chatKey: this.chatKey,
|
|
762
1037
|
chatsRevision: this.chatsRevision,
|
|
763
1038
|
viewingSubagent: this.viewingSubagent,
|
|
764
1039
|
phase: "creating",
|
|
@@ -766,7 +1041,16 @@ export class QaSessionController {
|
|
|
766
1041
|
this.emit();
|
|
767
1042
|
try {
|
|
768
1043
|
await this.waitForConnection();
|
|
769
|
-
|
|
1044
|
+
// A step back here is the visitor having left this view for another chat,
|
|
1045
|
+
// which shows its own transcript — there is nothing of ours left to publish
|
|
1046
|
+
// and nothing of theirs to mark as a failed open.
|
|
1047
|
+
if (!(await this.bind(id, {
|
|
1048
|
+
operation,
|
|
1049
|
+
attest: false,
|
|
1050
|
+
track: false,
|
|
1051
|
+
report: false,
|
|
1052
|
+
})))
|
|
1053
|
+
return;
|
|
770
1054
|
}
|
|
771
1055
|
catch (error) {
|
|
772
1056
|
// A newer operation superseded this view request: leave its state alone.
|
|
@@ -875,6 +1159,7 @@ export class QaSessionController {
|
|
|
875
1159
|
this.drafting = false;
|
|
876
1160
|
this.state = {
|
|
877
1161
|
...QA_SESSION_IDLE_STATE,
|
|
1162
|
+
chatKey: this.chatKey,
|
|
878
1163
|
chatsRevision: this.chatsRevision,
|
|
879
1164
|
phase: "creating",
|
|
880
1165
|
};
|
|
@@ -926,10 +1211,15 @@ export class QaSessionController {
|
|
|
926
1211
|
// replace it. A freshly created session must report a refusal at once —
|
|
927
1212
|
// it is the deployment's only chance to learn the reason.
|
|
928
1213
|
try {
|
|
929
|
-
|
|
1214
|
+
// A step back is not a refusal: another operation owns the screen now,
|
|
1215
|
+
// so this bootstrap must neither run its recovery ladder over it nor
|
|
1216
|
+
// persist a chat it never adopted.
|
|
1217
|
+
if (!(await this.bind(id, {
|
|
1218
|
+
operation,
|
|
930
1219
|
report: !restored,
|
|
931
1220
|
allowCompatibilityReadOnly: existing,
|
|
932
|
-
})
|
|
1221
|
+
})))
|
|
1222
|
+
return;
|
|
933
1223
|
}
|
|
934
1224
|
catch (error) {
|
|
935
1225
|
// A stale restore must not start its recovery ladder against a newer
|
|
@@ -947,6 +1237,9 @@ export class QaSessionController {
|
|
|
947
1237
|
this.unbind();
|
|
948
1238
|
this.operationError = null;
|
|
949
1239
|
this.chats.clearActive();
|
|
1240
|
+
// Nothing is published for the replacement yet: until it exists this is
|
|
1241
|
+
// still the chat on screen, and a fresh session the stand refuses to
|
|
1242
|
+
// create must not cost the visitor the draft they were reading.
|
|
950
1243
|
id = await createQaSession({
|
|
951
1244
|
createSession: this.createSessionRemote,
|
|
952
1245
|
token: this.accounts?.token() ?? "",
|
|
@@ -955,7 +1248,15 @@ export class QaSessionController {
|
|
|
955
1248
|
});
|
|
956
1249
|
if (this.disposed || operation !== this.generation)
|
|
957
1250
|
return;
|
|
958
|
-
|
|
1251
|
+
// The replacement is another chat — empty, and not one the user asked to
|
|
1252
|
+
// open — so it takes an identity of its own, dropping the refused chat's
|
|
1253
|
+
// unsent text, staged attachments and drawers rather than handing them
|
|
1254
|
+
// to a conversation nobody chose. The bind that refused it left the
|
|
1255
|
+
// identity naming no session, so without this the replacement would
|
|
1256
|
+
// adopt the refused chat's.
|
|
1257
|
+
this.openChat();
|
|
1258
|
+
if (!(await this.bind(id, { operation })))
|
|
1259
|
+
return;
|
|
959
1260
|
}
|
|
960
1261
|
if (this.disposed || operation !== this.generation)
|
|
961
1262
|
return;
|
|
@@ -991,14 +1292,14 @@ export class QaSessionController {
|
|
|
991
1292
|
}
|
|
992
1293
|
const operation = ++this.generation;
|
|
993
1294
|
this.unbind();
|
|
994
|
-
this.admissionPending = false;
|
|
995
1295
|
this.policyReady = false;
|
|
996
1296
|
this.state = { ...this.state, phase: "creating" };
|
|
997
1297
|
this.emit();
|
|
998
1298
|
try {
|
|
999
1299
|
await this.waitForConnection();
|
|
1000
1300
|
try {
|
|
1001
|
-
await this.bind(id)
|
|
1301
|
+
if (!(await this.bind(id, { operation })))
|
|
1302
|
+
return null;
|
|
1002
1303
|
if (this.disposed || operation !== this.generation)
|
|
1003
1304
|
return null;
|
|
1004
1305
|
if (this.policyReady)
|
|
@@ -1014,6 +1315,11 @@ export class QaSessionController {
|
|
|
1014
1315
|
this.fail(CONFIGURATION_ERROR, new QaPolicyAttestationError());
|
|
1015
1316
|
return null;
|
|
1016
1317
|
}
|
|
1318
|
+
// Only a chat that never received a prompt is replaced here, so the fresh
|
|
1319
|
+
// id continues the chat the user is writing into: leaving the binding
|
|
1320
|
+
// above takes the identity's name back, and bind() then adopts the fresh
|
|
1321
|
+
// id under the identity still on screen — the question this send carries
|
|
1322
|
+
// has not left the composer.
|
|
1017
1323
|
this.unbind();
|
|
1018
1324
|
this.chats.clearActive();
|
|
1019
1325
|
const created = await createQaSession({
|
|
@@ -1024,7 +1330,8 @@ export class QaSessionController {
|
|
|
1024
1330
|
});
|
|
1025
1331
|
if (this.disposed || operation !== this.generation)
|
|
1026
1332
|
return null;
|
|
1027
|
-
await this.bind(created)
|
|
1333
|
+
if (!(await this.bind(created, { operation })))
|
|
1334
|
+
return null;
|
|
1028
1335
|
if (this.disposed || operation !== this.generation)
|
|
1029
1336
|
return null;
|
|
1030
1337
|
this.chats.saveActive(created);
|
|
@@ -1039,17 +1346,92 @@ export class QaSessionController {
|
|
|
1039
1346
|
return null;
|
|
1040
1347
|
}
|
|
1041
1348
|
}
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1349
|
+
/**
|
|
1350
|
+
* Begin a chat of its own: take a fresh identity from the page-wide sequence,
|
|
1351
|
+
* leave it naming no session, and retire the send the ending chat had in
|
|
1352
|
+
* flight. Called on every move between chats — reset, switch, subagent view —
|
|
1353
|
+
* by {@link bind} when it ends up in a session the identity does not name, and
|
|
1354
|
+
* by the bootstrap whose restored chat was refused, for the replacement it is
|
|
1355
|
+
* about to open.
|
|
1356
|
+
*/
|
|
1357
|
+
openChat() {
|
|
1358
|
+
this.namedSession = null;
|
|
1359
|
+
this.chatKey = nextChatKey();
|
|
1360
|
+
// A send in flight belongs to the chat that is ending. The projection reads
|
|
1361
|
+
// the optimistic row and the busy flag out of it without asking which chat
|
|
1362
|
+
// is on screen, so unless it dies here, the chat that takes this identity
|
|
1363
|
+
// shows a question it never received and waits for a send it never made.
|
|
1364
|
+
this.pendingSubmission = undefined;
|
|
1365
|
+
this.admissionPending = false;
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* Adopt one Host session as the chat on screen: take its binding, wait for it
|
|
1369
|
+
* to open, attest the policy, and only then hand the chat identity to the
|
|
1370
|
+
* session, so {@link namedSession} never names one this controller does not
|
|
1371
|
+
* hold. A caller that asks for no attestation — the subagent view, whose
|
|
1372
|
+
* transcript is read rather than written into — is named once its session is
|
|
1373
|
+
* open.
|
|
1374
|
+
*
|
|
1375
|
+
* Answers whether the adoption happened. `true` is a finished one: this call
|
|
1376
|
+
* holds the session and the chat identity names it, so the caller may publish
|
|
1377
|
+
* what it kept for that chat — the draft it retired, the id it persisted.
|
|
1378
|
+
* `false` is a step back: the chat on screen moved on while this call waited,
|
|
1379
|
+
* it undoes nothing of the chat that replaced it and leaves the screen holding
|
|
1380
|
+
* nothing of this one ({@link stepBack}), and the caller must keep its own
|
|
1381
|
+
* state as unclaimed — a draft that was never materialized stays a draft, and a
|
|
1382
|
+
* chat that was never adopted is not persisted as the open one.
|
|
1383
|
+
*
|
|
1384
|
+
* Any of those waits can outlive the operation it belongs to — the user moves
|
|
1385
|
+
* to another chat while a first send is still looking for its session. Two
|
|
1386
|
+
* questions decide that at each wait: is this still the generation the caller
|
|
1387
|
+
* owns, and is this still the session this call took? The first is the page's
|
|
1388
|
+
* own — every path that takes the screen raises the generation. The second is
|
|
1389
|
+
* kept beside it because a generation only records who was asked to start, not
|
|
1390
|
+
* what the controller holds by the time an adoption resumes: a binding
|
|
1391
|
+
* somebody else retired in between is no more this one's to finish.
|
|
1392
|
+
*/
|
|
1393
|
+
async bind(id, options) {
|
|
1394
|
+
const { operation, report = true, attest = true, track = true, allowCompatibilityReadOnly = false, } = options;
|
|
1395
|
+
const reference = this.sessions.retain(id, {
|
|
1396
|
+
source: QA_SURFACE_SESSION_SOURCE,
|
|
1397
|
+
});
|
|
1045
1398
|
let binding = this.sessions.binding(id);
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1399
|
+
try {
|
|
1400
|
+
if (binding === undefined) {
|
|
1401
|
+
await reference.ready;
|
|
1402
|
+
binding = this.sessions.binding(id);
|
|
1403
|
+
}
|
|
1404
|
+
if (binding === undefined) {
|
|
1405
|
+
await waitFor(this.sessions.list, (snapshot) => Object.hasOwn(snapshot.byId, id), this.timeoutMs);
|
|
1406
|
+
binding = this.sessions.binding(id);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
finally {
|
|
1410
|
+
// A binding this controller never got to hold must not leave a reference
|
|
1411
|
+
// counting against the Session it could not show.
|
|
1412
|
+
if (binding === undefined)
|
|
1413
|
+
reference.release();
|
|
1049
1414
|
}
|
|
1050
1415
|
if (binding === undefined)
|
|
1051
1416
|
throw new Error("Session binding is unavailable.");
|
|
1417
|
+
if (this.disposed || operation !== this.generation) {
|
|
1418
|
+
// A newer operation owns the chat on screen while this one was still
|
|
1419
|
+
// looking for its session. Adopting now would retire the binding that
|
|
1420
|
+
// newer operation is showing, so this one gives up before it takes
|
|
1421
|
+
// anything; the reference it retained is its own to release.
|
|
1422
|
+
reference.release();
|
|
1423
|
+
return false;
|
|
1424
|
+
}
|
|
1425
|
+
// Another session under the identity that named a different one is another
|
|
1426
|
+
// chat: it takes its own identity, so nothing the previous chat was holding
|
|
1427
|
+
// — an unsent question, staged attachments, an open drawer — walks into it.
|
|
1428
|
+
// A chat that names no session adopts under the identity it already has,
|
|
1429
|
+
// which is what keeps a retried first send — it brings a fresh id — in the
|
|
1430
|
+
// composer the question was typed into.
|
|
1431
|
+
if (this.namedSession !== null && this.namedSession !== id)
|
|
1432
|
+
this.openChat();
|
|
1052
1433
|
this.unbind();
|
|
1434
|
+
this.sessionReference = reference;
|
|
1053
1435
|
this.session = binding.session;
|
|
1054
1436
|
this.conversationBinding = this.conversation.binding(id);
|
|
1055
1437
|
this.policyReady = false;
|
|
@@ -1059,6 +1441,12 @@ export class QaSessionController {
|
|
|
1059
1441
|
this.operationError = null;
|
|
1060
1442
|
this.publishSessionUpdate();
|
|
1061
1443
|
});
|
|
1444
|
+
// The queue strip reads the Inbox projection, which the Host publishes on
|
|
1445
|
+
// its own channel: a claim or a splice changes no session snapshot, so the
|
|
1446
|
+
// strip would otherwise keep the rows the agent has already taken.
|
|
1447
|
+
this.unsubscribeInbox = binding.session.projections
|
|
1448
|
+
.faceOf("inbox")
|
|
1449
|
+
.subscribe(() => this.publishSessionUpdate());
|
|
1062
1450
|
// Subscribing the Chat target activates it, so the assembled transcript
|
|
1063
1451
|
// (nodes, partials, running calls) materializes for the projection.
|
|
1064
1452
|
this.unsubscribeChat = this.conversationBinding
|
|
@@ -1066,42 +1454,128 @@ export class QaSessionController {
|
|
|
1066
1454
|
.subscribe(() => {
|
|
1067
1455
|
this.publishSessionUpdate();
|
|
1068
1456
|
});
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1457
|
+
try {
|
|
1458
|
+
await waitFor(binding.session, (snapshot) => snapshot.openState === "open" || snapshot.openState === "error", this.timeoutMs);
|
|
1459
|
+
if (binding.session.getSnapshot().openState !== "open") {
|
|
1460
|
+
throw new Error("Session binding could not be opened.");
|
|
1461
|
+
}
|
|
1462
|
+
if (attest) {
|
|
1463
|
+
// Attestation both reads and writes the chat on screen: it proves the
|
|
1464
|
+
// session the controller holds and stamps this adoption's verdict onto
|
|
1465
|
+
// it. Ask the two questions before spending a proof on the wrong chat,
|
|
1466
|
+
// and answer only for the session this call took — not for whichever
|
|
1467
|
+
// one the screen holds by the time the proof comes back.
|
|
1468
|
+
if (this.stepBack(operation, binding.session))
|
|
1469
|
+
return false;
|
|
1470
|
+
const step = await this.attestPolicy(report, binding.session);
|
|
1471
|
+
// A chat that moved on during the proof is attested by whichever
|
|
1472
|
+
// adoption holds it now, so this verdict is not this one's to write.
|
|
1473
|
+
if (this.stepBack(operation, binding.session))
|
|
1474
|
+
return false;
|
|
1475
|
+
if (step.kind === "refused") {
|
|
1476
|
+
if (!allowCompatibilityReadOnly ||
|
|
1477
|
+
!canOpenAsCompatibilityReadOnly(step.reason)) {
|
|
1478
|
+
throw new QaPolicyAttestationError(step.reason);
|
|
1479
|
+
}
|
|
1480
|
+
// The Host classified the existing binding before returning these
|
|
1481
|
+
// reason classes. Preserve the transcript, but never mark the binding
|
|
1482
|
+
// policy-ready: every mutating operation remains disabled.
|
|
1483
|
+
this.compatibilityReadOnly = true;
|
|
1484
|
+
this.operationError = null;
|
|
1079
1485
|
}
|
|
1080
|
-
// The Host classified the existing binding before returning these
|
|
1081
|
-
// reason classes. Preserve the transcript, but never mark the binding
|
|
1082
|
-
// policy-ready: every mutating operation remains disabled.
|
|
1083
|
-
this.compatibilityReadOnly = true;
|
|
1084
|
-
this.operationError = null;
|
|
1085
1486
|
}
|
|
1086
|
-
|
|
1087
|
-
|
|
1487
|
+
if (this.stepBack(operation, binding.session)) {
|
|
1488
|
+
// The chat on screen moved on while this adoption waited: what replaced
|
|
1489
|
+
// it holds the identity now, so there is nothing here left to finish —
|
|
1490
|
+
// and this call gives back the binding it installed, which is all the
|
|
1491
|
+
// screen ever took from it.
|
|
1492
|
+
return false;
|
|
1493
|
+
}
|
|
1494
|
+
if (track) {
|
|
1495
|
+
this.chatSessionId = String(binding.session.sessionId);
|
|
1496
|
+
this.viewingSubagent = null;
|
|
1497
|
+
this.chats.addChat(String(binding.session.sessionId));
|
|
1498
|
+
this.accounts?.onSessionCreated(String(binding.session.sessionId));
|
|
1088
1499
|
}
|
|
1089
1500
|
}
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1501
|
+
catch (error) {
|
|
1502
|
+
// An adoption that fell short must not leave the chat naming the session
|
|
1503
|
+
// it could not finish with: a retried first send brings another id, and an
|
|
1504
|
+
// identity still pointing here would read that retry as a move to another
|
|
1505
|
+
// chat — rebuilding the composer over the very question that has to go
|
|
1506
|
+
// again. A session that never opened is not one this controller holds, so
|
|
1507
|
+
// it goes too; a session whose attestation was refused stays, because its
|
|
1508
|
+
// caller reads the transcript before deciding whether the chat may be
|
|
1509
|
+
// replaced. Once a newer chat has retired the binding this call took, none
|
|
1510
|
+
// of it is ours to undo — that chat owns the identity from then on, and
|
|
1511
|
+
// there is nothing of ours left installed.
|
|
1512
|
+
if (this.ownsAdoption(operation, binding.session)) {
|
|
1513
|
+
this.namedSession = null;
|
|
1514
|
+
if (!(error instanceof QaPolicyAttestationError))
|
|
1515
|
+
this.unbind();
|
|
1516
|
+
}
|
|
1517
|
+
else if (this.session === binding.session) {
|
|
1518
|
+
// Left behind, and the failure is not a caller's to read any more: the
|
|
1519
|
+
// hand that took the screen was the bootstrap, which retires nothing, so
|
|
1520
|
+
// this adoption's own binding and subscriptions go with this throw.
|
|
1521
|
+
this.unbind();
|
|
1522
|
+
}
|
|
1523
|
+
throw error;
|
|
1095
1524
|
}
|
|
1525
|
+
// Every wait this adoption was asked to take is behind it: only now does the
|
|
1526
|
+
// chat take this session as its identity.
|
|
1527
|
+
this.namedSession = id;
|
|
1096
1528
|
this.publish();
|
|
1097
1529
|
// Deliberately not awaited: the palette is a convenience, and a chat must
|
|
1098
1530
|
// open at once whether or not the skill and command registries answer.
|
|
1099
1531
|
void this.refreshSlashCatalog(true);
|
|
1532
|
+
return true;
|
|
1533
|
+
}
|
|
1534
|
+
/**
|
|
1535
|
+
* Give up on an adoption the chat on screen has outgrown, and answer whether
|
|
1536
|
+
* that is what happened (`true` — the caller has nothing left to publish).
|
|
1537
|
+
*
|
|
1538
|
+
* Past the binding this call installed a session of its own: the retained
|
|
1539
|
+
* reference, the three subscriptions beside it, and whatever those
|
|
1540
|
+
* subscriptions write on every frame. The path that took the screen usually
|
|
1541
|
+
* retired all of that with its own {@link unbind} — but the bootstrap raises the
|
|
1542
|
+
* generation while keeping the transcript readable until its own session
|
|
1543
|
+
* exists, and an adoption left behind by it would otherwise go on holding a
|
|
1544
|
+
* Host session nobody is in and publishing that chat's frames into the surface:
|
|
1545
|
+
* each frame clears the error the bootstrap published and answers for a binding
|
|
1546
|
+
* the page has given up on. Undoing the install is therefore this call's own
|
|
1547
|
+
* hand — and only its own: the chat that already replaced this one holds the
|
|
1548
|
+
* name, the subscriptions and the policy proof, and emptying its screen is the
|
|
1549
|
+
* failure this method exists to prevent.
|
|
1550
|
+
*/
|
|
1551
|
+
stepBack(operation, session) {
|
|
1552
|
+
if (this.ownsAdoption(operation, session))
|
|
1553
|
+
return false;
|
|
1554
|
+
if (this.session === session)
|
|
1555
|
+
this.unbind();
|
|
1556
|
+
return true;
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* Is this adoption still the one on screen? The generation says whether the
|
|
1560
|
+
* operation that asked for it still owns the page — every path that takes the
|
|
1561
|
+
* screen raises it, {@link startDraft} among them — and the session says
|
|
1562
|
+
* whether the binding this call took is still the one held, which a generation
|
|
1563
|
+
* alone cannot answer: a newer chat raises it before it has taken any name, and
|
|
1564
|
+
* a caller can retire the binding it holds inside its own generation.
|
|
1565
|
+
*/
|
|
1566
|
+
ownsAdoption(operation, session) {
|
|
1567
|
+
return (!this.disposed &&
|
|
1568
|
+
operation === this.generation &&
|
|
1569
|
+
this.session === session);
|
|
1100
1570
|
}
|
|
1101
1571
|
unbind() {
|
|
1572
|
+
this.sessionReference?.release();
|
|
1573
|
+
this.sessionReference = undefined;
|
|
1102
1574
|
this.streamPublisher.clear();
|
|
1103
1575
|
this.unsubscribeSession?.();
|
|
1104
1576
|
this.unsubscribeSession = undefined;
|
|
1577
|
+
this.unsubscribeInbox?.();
|
|
1578
|
+
this.unsubscribeInbox = undefined;
|
|
1105
1579
|
this.unsubscribeChat?.();
|
|
1106
1580
|
this.unsubscribeChat = undefined;
|
|
1107
1581
|
this.conversationBinding = undefined;
|
|
@@ -1111,6 +1585,10 @@ export class QaSessionController {
|
|
|
1111
1585
|
this.assets.release(String(this.session.sessionId));
|
|
1112
1586
|
}
|
|
1113
1587
|
this.session = undefined;
|
|
1588
|
+
// A chat that holds no session names no session: {@link bind} hands the
|
|
1589
|
+
// identity out only at the end of a finished adoption, and leaving a
|
|
1590
|
+
// binding is the hand that takes it back.
|
|
1591
|
+
this.namedSession = null;
|
|
1114
1592
|
this.hostSources.reset();
|
|
1115
1593
|
this.hostApprovals.reset();
|
|
1116
1594
|
this.hostQuestions.reset();
|
|
@@ -1118,6 +1596,12 @@ export class QaSessionController {
|
|
|
1118
1596
|
// The next binding probes the Host again even for the same chat.
|
|
1119
1597
|
this.pendingProbeKey = "";
|
|
1120
1598
|
this.admissionPending = false;
|
|
1599
|
+
// Request ids are minted per submission, so another chat's queue listing
|
|
1600
|
+
// says nothing about this one's submissions still crossing the transport.
|
|
1601
|
+
this.admittedSubmissions.clear();
|
|
1602
|
+
// A held-back question belongs to the chat that asked it; another binding
|
|
1603
|
+
// answers for its own sends.
|
|
1604
|
+
this.requestQueueNotice = null;
|
|
1121
1605
|
this.policyReady = false;
|
|
1122
1606
|
this.compatibilityReadOnly = false;
|
|
1123
1607
|
// The catalog belongs to the chat that produced it: another chat has a
|
|
@@ -1137,6 +1621,11 @@ export class QaSessionController {
|
|
|
1137
1621
|
/** Project one session notification; the running-turn spacing policy
|
|
1138
1622
|
* (first frame at once, further frames absorbed) lives in the publisher. */
|
|
1139
1623
|
publishSessionUpdate() {
|
|
1624
|
+
// Read before the spacing, from the notification itself: a frame absorbed
|
|
1625
|
+
// into a running turn's window is dropped rather than replayed, so an
|
|
1626
|
+
// admission landing in such a window would otherwise go unrecorded and the
|
|
1627
|
+
// claim frame after it would draw the ghost row this retires.
|
|
1628
|
+
this.trackAdmittedSubmissions();
|
|
1140
1629
|
this.streamPublisher.publish(this.session?.getSnapshot().running === true, () => this.publish());
|
|
1141
1630
|
}
|
|
1142
1631
|
publish() {
|
|
@@ -1150,12 +1639,14 @@ export class QaSessionController {
|
|
|
1150
1639
|
this.pendingSubmission !== undefined;
|
|
1151
1640
|
this.state = {
|
|
1152
1641
|
...QA_SESSION_IDLE_STATE,
|
|
1642
|
+
chatKey: this.chatKey,
|
|
1153
1643
|
phase: materializing
|
|
1154
1644
|
? "creating"
|
|
1155
1645
|
: !connected && this.connectedOnce
|
|
1156
1646
|
? "reconnecting"
|
|
1157
1647
|
: "idle",
|
|
1158
1648
|
error: this.operationError,
|
|
1649
|
+
requestQueue: this.requestQueueNotice,
|
|
1159
1650
|
canSend: connected && !materializing,
|
|
1160
1651
|
pendingMessage: this.pendingSubmission?.message ?? null,
|
|
1161
1652
|
chatsRevision: this.chatsRevision,
|
|
@@ -1168,6 +1659,7 @@ export class QaSessionController {
|
|
|
1168
1659
|
...this.state,
|
|
1169
1660
|
phase: !connected && this.connectedOnce ? "reconnecting" : this.state.phase,
|
|
1170
1661
|
error: this.operationError ?? this.state.error,
|
|
1662
|
+
requestQueue: this.requestQueueNotice,
|
|
1171
1663
|
};
|
|
1172
1664
|
this.emit();
|
|
1173
1665
|
return;
|
|
@@ -1182,9 +1674,11 @@ export class QaSessionController {
|
|
|
1182
1674
|
}
|
|
1183
1675
|
this.probePending(sessionId, connected);
|
|
1184
1676
|
this.syncPendingPolling(snapshot.running === true && !this.compatibilityReadOnly);
|
|
1677
|
+
const queuedMessages = this.queuedMessages();
|
|
1185
1678
|
const projectionInput = {
|
|
1186
1679
|
connected,
|
|
1187
1680
|
sessionId,
|
|
1681
|
+
chatKey: this.chatKey,
|
|
1188
1682
|
sessionSnapshot: snapshot,
|
|
1189
1683
|
conversationSnapshot,
|
|
1190
1684
|
sourceBundles,
|
|
@@ -1194,11 +1688,16 @@ export class QaSessionController {
|
|
|
1194
1688
|
// carries its owner's name when an admin reads it.
|
|
1195
1689
|
author: this.accounts?.messageAuthorOf(sessionId),
|
|
1196
1690
|
operationError: this.operationError,
|
|
1691
|
+
requestQueue: this.requestQueueNotice,
|
|
1197
1692
|
policyReady: this.policyReady,
|
|
1198
1693
|
compatibilityReadOnly: this.compatibilityReadOnly,
|
|
1199
1694
|
admissionPending: this.admissionPending || this.pendingSubmission !== undefined,
|
|
1200
1695
|
chatsRevision: this.chatsRevision,
|
|
1201
1696
|
viewingSubagent: this.viewingSubagent,
|
|
1697
|
+
queuedMessages,
|
|
1698
|
+
// The live set, not a copy: `projectQueue` only asks it `has` and keeps no
|
|
1699
|
+
// reference, so nothing here could go stale by the next frame.
|
|
1700
|
+
admittedSubmissions: this.admittedSubmissions,
|
|
1202
1701
|
slash: this.slashView(),
|
|
1203
1702
|
config: this.config,
|
|
1204
1703
|
subagentNames: this.subagentNames(),
|
|
@@ -1206,11 +1705,16 @@ export class QaSessionController {
|
|
|
1206
1705
|
let projected = projectBoundSessionState(projectionInput);
|
|
1207
1706
|
const pending = this.pendingSubmission;
|
|
1208
1707
|
if (pending !== undefined) {
|
|
1209
|
-
|
|
1210
|
-
|
|
1708
|
+
// The optimistic copy retires on a fact the transcript itself carries —
|
|
1709
|
+
// its own row arriving, or a turn closing — and not on the session's
|
|
1710
|
+
// running bit: the Session list relays that one too, and a stale false
|
|
1711
|
+
// at the start of a turn retired the copy before the Chat slice had
|
|
1712
|
+
// assembled the node, which is the frame the question vanished in.
|
|
1211
1713
|
const committedUserCount = projected.messages.filter((message) => message.role === "user").length;
|
|
1212
1714
|
if (committedUserCount > pending.baselineUserCount ||
|
|
1213
|
-
(pending.accepted &&
|
|
1715
|
+
(pending.accepted &&
|
|
1716
|
+
chatLegacyOf(conversationSnapshot).turnEnds.size >
|
|
1717
|
+
pending.baselineTurnEnds)) {
|
|
1214
1718
|
this.pendingSubmission = undefined;
|
|
1215
1719
|
// The first projection was intentionally busy while the optimistic
|
|
1216
1720
|
// row existed. Recompute once so the same notification can restore
|
|
@@ -1227,6 +1731,52 @@ export class QaSessionController {
|
|
|
1227
1731
|
};
|
|
1228
1732
|
this.emit();
|
|
1229
1733
|
}
|
|
1734
|
+
/**
|
|
1735
|
+
* The messages the Host has accepted for the agent's next turn. They arrive
|
|
1736
|
+
* through the session's Inbox projection rather than the snapshot the queue
|
|
1737
|
+
* strip used to read: `rc.2` moved pending input onto the projection surface,
|
|
1738
|
+
* and a key the Host never published reads as no queued work.
|
|
1739
|
+
*/
|
|
1740
|
+
queuedMessages() {
|
|
1741
|
+
const inbox = this.session?.projections.faceOf("inbox").getSnapshot();
|
|
1742
|
+
return inbox?.["next-turn"] ?? [];
|
|
1743
|
+
}
|
|
1744
|
+
/**
|
|
1745
|
+
* Record the submissions this binding has seen the Host name — the queue rows
|
|
1746
|
+
* it holds and the durable input rows it has written. Naming the message is the
|
|
1747
|
+
* server's own receipt for it, so from that notification on the row belongs to
|
|
1748
|
+
* the chat, not to the transport: an echo the claim leaves behind must not read
|
|
1749
|
+
* again as a question still crossing. The scan is only paid for while some
|
|
1750
|
+
* queued echo still waits for its name.
|
|
1751
|
+
*
|
|
1752
|
+
* Both lists are read because neither is reliable alone, and the transcript is
|
|
1753
|
+
* the durable one: a send admitted and claimed between two notifications never
|
|
1754
|
+
* appears in a queue frame this browser is handed, but its row stays in the
|
|
1755
|
+
* transcript, so the next frame names it. And the receipt is never dropped
|
|
1756
|
+
* while the binding lives — an id the server has named belongs to the submission
|
|
1757
|
+
* that minted it (`beginSubmission` returns a fresh `randomUUID`), so no later
|
|
1758
|
+
* send can need the row this hides, whereas forgetting the receipt re-draws the
|
|
1759
|
+
* ghost the moment the snapshot registers the echo again.
|
|
1760
|
+
*
|
|
1761
|
+
* Read from the notification, not from the projected frame: the projection of a
|
|
1762
|
+
* running turn is spaced, and an absorbed frame is dropped rather than
|
|
1763
|
+
* replayed, so spacing must not decide whether this browser ever saw the name.
|
|
1764
|
+
*/
|
|
1765
|
+
trackAdmittedSubmissions() {
|
|
1766
|
+
const snapshot = this.session?.getSnapshot();
|
|
1767
|
+
if (snapshot === undefined)
|
|
1768
|
+
return;
|
|
1769
|
+
// Only an echo still waiting can draw a row, so a chat whose queued sends are
|
|
1770
|
+
// all settled costs no scan of the transcript — and a browser registers the
|
|
1771
|
+
// echo before it sends, so an id with no echo there has nothing to settle.
|
|
1772
|
+
const unsettled = snapshot.pendingSubmissions.some((item) => item.placement === "queued" &&
|
|
1773
|
+
!this.admittedSubmissions.has(String(item.requestId)));
|
|
1774
|
+
if (!unsettled)
|
|
1775
|
+
return;
|
|
1776
|
+
for (const requestId of hostNamedSubmissionIds(this.queuedMessages(), this.conversationBinding?.snapshot.getSnapshot())) {
|
|
1777
|
+
this.admittedSubmissions.add(requestId);
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1230
1780
|
/**
|
|
1231
1781
|
* The directory the transcript's source paths are anchored on: the chat's
|
|
1232
1782
|
* own cwd, never the configured pin while the chat is known.
|
|
@@ -1266,7 +1816,7 @@ export class QaSessionController {
|
|
|
1266
1816
|
const names = {};
|
|
1267
1817
|
const list = this.sessions.list.getSnapshot();
|
|
1268
1818
|
const chats = this.visibleChatIds();
|
|
1269
|
-
const candidates = visibleSubagentCandidates(list.byId ?? {},
|
|
1819
|
+
const candidates = visibleSubagentCandidates(list.byId ?? {}, chats);
|
|
1270
1820
|
for (const candidate of candidates) {
|
|
1271
1821
|
const name = readableSubagentName(candidate.label, candidate.id);
|
|
1272
1822
|
if (name !== undefined)
|
|
@@ -1284,6 +1834,14 @@ export class QaSessionController {
|
|
|
1284
1834
|
return undefined;
|
|
1285
1835
|
return new Set(this.accounts.ownedIds());
|
|
1286
1836
|
}
|
|
1837
|
+
/**
|
|
1838
|
+
* Tell the user that a submission they pressed did not go. The composer keeps
|
|
1839
|
+
* its text through a refused send, so silence here would read as success.
|
|
1840
|
+
*/
|
|
1841
|
+
refuseSend(message) {
|
|
1842
|
+
this.operationError = message;
|
|
1843
|
+
this.publish();
|
|
1844
|
+
}
|
|
1287
1845
|
fail(message, error) {
|
|
1288
1846
|
// Policy attestation refusals already logged their precise reason; a
|
|
1289
1847
|
// second stack trace for the wrapper error is only console noise.
|
|
@@ -1295,6 +1853,7 @@ export class QaSessionController {
|
|
|
1295
1853
|
this.operationError = message;
|
|
1296
1854
|
this.state = {
|
|
1297
1855
|
...QA_SESSION_IDLE_STATE,
|
|
1856
|
+
chatKey: this.chatKey,
|
|
1298
1857
|
phase: "error",
|
|
1299
1858
|
error: message,
|
|
1300
1859
|
chatsRevision: this.chatsRevision,
|
|
@@ -1302,20 +1861,24 @@ export class QaSessionController {
|
|
|
1302
1861
|
this.emit();
|
|
1303
1862
|
}
|
|
1304
1863
|
/**
|
|
1305
|
-
* Attest the currently bound
|
|
1306
|
-
* was issued for; `stale` means the binding changed
|
|
1307
|
-
* in flight, so nothing was written — the newer binding
|
|
1308
|
-
* attestation; `refused` is a genuine admission refusal of
|
|
1864
|
+
* Attest `session`, which defaults to the one currently bound. `ok` carries
|
|
1865
|
+
* the session id the proof was issued for; `stale` means the binding changed
|
|
1866
|
+
* while the request was in flight, so nothing was written — the newer binding
|
|
1867
|
+
* manages its own attestation; `refused` is a genuine admission refusal of
|
|
1868
|
+
* this binding.
|
|
1309
1869
|
*/
|
|
1310
|
-
async attestPolicy(reportFailure = true) {
|
|
1311
|
-
const session = this.session;
|
|
1870
|
+
async attestPolicy(reportFailure = true, session = this.session) {
|
|
1312
1871
|
if (session === undefined)
|
|
1313
1872
|
return { kind: "refused", reason: null };
|
|
1314
1873
|
const sessionId = String(session.sessionId);
|
|
1315
|
-
if (!this.config.lockdown.enabled) {
|
|
1874
|
+
if (!this.config.lockdown.enabled && this.accounts === undefined) {
|
|
1316
1875
|
this.policyReady = true;
|
|
1317
1876
|
return { kind: "ok", sessionId };
|
|
1318
1877
|
}
|
|
1878
|
+
// With lockdown off there is no policy to pin, but the Host call still
|
|
1879
|
+
// runs: account identity and ownership are admitted there independently of
|
|
1880
|
+
// lockdown, and skipping the call is what let a browser that restored
|
|
1881
|
+
// another account's chat write into it without ever being asked who spoke.
|
|
1319
1882
|
this.policyReady = false;
|
|
1320
1883
|
this.publish();
|
|
1321
1884
|
const outcome = await attestQaPolicy({
|