@danceiny/gotry 0.2.0-rc.29 → 0.2.0-rc.31
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 +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/gotry-bootstrap.js +56 -83
- package/bin/gotry-extension-status.d.ts +12 -0
- package/bin/gotry-extension-status.js +113 -0
- package/bin/gotry-flyai-verification.d.ts +9 -0
- package/bin/gotry-flyai-verification.js +63 -0
- package/bin/gotry-inner.js +9 -2
- package/client/client.js +183 -0
- package/cordis.gotry-patch.yml +2 -0
- package/dist/capabilities/artifacts.js +41 -4
- package/dist/capabilities/doctor.js +7 -15
- package/dist/scripts/agent-planning-turn-deadline-e2e.js +2 -0
- package/dist/scripts/agent-planning-turn-deadline-tests.js +135 -2
- package/dist/scripts/artifact-client-contract-tests.js +4 -3
- package/dist/scripts/artifact-delivery-tests.js +31 -0
- package/dist/scripts/bootstrap-tests.js +19 -4
- package/dist/scripts/doctor-tests.js +36 -7
- package/dist/scripts/dsh-artifact-web-e2e.js +204 -43
- package/dist/scripts/flyai-setup-tests.js +75 -2
- package/dist/scripts/gotry-web-api-tests.js +114 -0
- package/dist/scripts/gotry-web-client-tests.js +28 -0
- package/dist/scripts/issue-271-liveness-tests.js +9 -1
- package/dist/scripts/turn-handoff-collect-tests.js +7 -1
- package/dist/scripts/turn-handoff-collect.js +68 -20
- package/dist/scripts/turn-handoff-job-tests.js +222 -0
- package/dist/scripts/turn-handoff-web-e2e.js +240 -0
- package/dist/src/artifact-delivery.js +26 -0
- package/dist/src/gotry-web-api.js +157 -0
- package/dist/src/index.js +30 -8
- package/dist/src/turn-deadline.js +113 -16
- package/dist/src/turn-handoff-job.js +124 -0
- package/extension/manifest.json +2 -2
- package/package.json +8 -2
- package/ts/capabilities/artifacts.ts +40 -5
- package/ts/scripts/turn-handoff-collect.ts +63 -21
- package/ts/src/artifact-delivery.ts +25 -0
- package/ts/src/gotry-web-api.ts +140 -0
- package/ts/src/index.ts +30 -8
- package/ts/src/turn-deadline.ts +111 -18
- package/ts/src/turn-handoff-job.ts +120 -0
package/client/client.js
CHANGED
|
@@ -12,6 +12,174 @@ window.__ModuleLoader__.load({
|
|
|
12
12
|
var module = { exports: {} }
|
|
13
13
|
var exports = module.exports
|
|
14
14
|
var React = require('react')
|
|
15
|
+
var UI = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
16
|
+
|
|
17
|
+
var webLocale = {
|
|
18
|
+
en: {
|
|
19
|
+
title: 'GoTry', description: 'Travel data sources and saved itineraries', flyai: 'FlyAI',
|
|
20
|
+
loading: 'Loading…', unavailable: 'Configuration is unavailable. Retry to read its current state.',
|
|
21
|
+
readOnly: 'The current key comes from the launch environment, or this platform cannot safely store it here.',
|
|
22
|
+
key: 'API Key', keyHint: 'Paste the key from the FlyAI console. It never enters the conversation.',
|
|
23
|
+
configured: 'A key is configured', anonymous: 'Anonymous trial · shared quota', verified: 'Current configuration verified',
|
|
24
|
+
unverified: 'Configured · verification required', debug: 'Debug endpoint', console: 'Open FlyAI console',
|
|
25
|
+
save: 'Verify and save', saving: 'Verifying…', saveFailed: 'The new key was not saved; the previous configuration is retained.',
|
|
26
|
+
saved: 'Verified and saved.', clear: 'Clear local key', cleared: 'Local key cleared. Anonymous trial is now active.',
|
|
27
|
+
retry: 'Refresh status', source: 'Source', config: 'Local configuration', env: 'Environment variable',
|
|
28
|
+
'env-debug': 'Debug environment variable', none: 'Shared trial',
|
|
29
|
+
artifacts: 'GoTry artifacts', artifactsHint: 'Browse saved itineraries and planning deliveries',
|
|
30
|
+
search: 'Search artifacts', searchPlaceholder: 'Title or filename', refresh: 'Refresh',
|
|
31
|
+
empty: 'No saved artifacts in this workspace.', noMatches: 'No matching artifacts.', noSession: 'Open a conversation to browse its workspace.',
|
|
32
|
+
previous: 'Previous', next: 'Next', html: 'Open HTML preview', open: 'Open preview',
|
|
33
|
+
htmlHint: 'HTML previews may run the document’s scripts.', failed: 'Could not read the current state. Please retry.',
|
|
34
|
+
},
|
|
35
|
+
zh: {
|
|
36
|
+
title: 'GoTry', description: '旅行数据源与已保存行程', flyai: 'FlyAI(飞猪)',
|
|
37
|
+
loading: '正在读取…', unavailable: '配置暂时不可用,请重新读取当前状态。',
|
|
38
|
+
readOnly: '当前 Key 来自启动环境变量,或此平台无法在这里安全保存。',
|
|
39
|
+
key: 'API Key', keyHint: '粘贴飞猪控制台中的 Key,凭据不会进入对话。',
|
|
40
|
+
configured: '已配置 Key', anonymous: '匿名试用 · 共享额度', verified: '当前配置已验证',
|
|
41
|
+
unverified: '已配置 · 尚未验证通过', debug: '调试服务地址', console: '打开飞猪控制台',
|
|
42
|
+
save: '验证并保存', saving: '正在验证…', saveFailed: '新 Key 未保存,旧配置已保留。',
|
|
43
|
+
saved: '验证通过,已保存。', clear: '清除本机 Key', cleared: '本机 Key 已清除,已恢复匿名试用。',
|
|
44
|
+
retry: '重新读取状态', source: '来源', config: '本机配置', env: '环境变量',
|
|
45
|
+
'env-debug': '调试环境变量', none: '共享试用',
|
|
46
|
+
artifacts: 'GoTry 产物', artifactsHint: '查看已保存行程和规划交付物',
|
|
47
|
+
search: '搜索产物', searchPlaceholder: '标题或文件名', refresh: '刷新',
|
|
48
|
+
empty: '这个工作区尚无已保存产物。', noMatches: '没有匹配的产物。', noSession: '打开一个对话后即可查看其工作区产物。',
|
|
49
|
+
previous: '上一页', next: '下一页', html: '打开 HTML 预览', open: '打开预览',
|
|
50
|
+
htmlHint: 'HTML 预览可能运行文档中的脚本。', failed: '无法读取当前状态,请重试。',
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function webRequest(path, options) {
|
|
55
|
+
var response = await fetch('api/gotry/' + path, Object.assign({ credentials: 'same-origin' }, options))
|
|
56
|
+
var value = await response.json()
|
|
57
|
+
if (!value || typeof value !== 'object') throw new Error('Invalid Web response')
|
|
58
|
+
if (!response.ok || value.ok === false) throw new Error(value.error || 'Request failed')
|
|
59
|
+
return value
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function FlyaiSettings(props) {
|
|
63
|
+
var t = props.t
|
|
64
|
+
var state = React.useState(null), status = state[0], setStatus = state[1]
|
|
65
|
+
var draft = React.useState(''), key = draft[0], setKey = draft[1]
|
|
66
|
+
var noticeState = React.useState(''), notice = noticeState[0], setNotice = noticeState[1]
|
|
67
|
+
var errorState = React.useState(''), error = errorState[0], setError = errorState[1]
|
|
68
|
+
var busyState = React.useState(false), busy = busyState[0], setBusy = busyState[1]
|
|
69
|
+
var reloadState = React.useState(0), reload = reloadState[0], setReload = reloadState[1]
|
|
70
|
+
var lifetime = React.useRef(null), activeRequest = React.useRef(false)
|
|
71
|
+
React.useEffect(function () {
|
|
72
|
+
var controller = new AbortController()
|
|
73
|
+
lifetime.current = controller
|
|
74
|
+
setError('')
|
|
75
|
+
webRequest('flyai', { signal: controller.signal }).then(function (value) {
|
|
76
|
+
if (!controller.signal.aborted) setStatus(value)
|
|
77
|
+
}).catch(function () { if (!controller.signal.aborted) setError(t('failed')) })
|
|
78
|
+
return function () { controller.abort(); lifetime.current = null }
|
|
79
|
+
}, [reload])
|
|
80
|
+
async function write(action) {
|
|
81
|
+
if (activeRequest.current || !lifetime.current || !status?.writable) return
|
|
82
|
+
var controller = lifetime.current
|
|
83
|
+
activeRequest.current = true
|
|
84
|
+
setBusy(true); setError(''); setNotice('')
|
|
85
|
+
var candidate = key
|
|
86
|
+
setKey('')
|
|
87
|
+
try {
|
|
88
|
+
var value = await webRequest('flyai/write', { method: 'POST', signal: controller.signal,
|
|
89
|
+
headers: { 'content-type': 'application/json' }, body: JSON.stringify(action === 'save' ? { action: action, key: candidate } : { action: action }) })
|
|
90
|
+
if (!controller.signal.aborted) { setStatus(value); setNotice(t(action === 'save' ? 'saved' : 'cleared')) }
|
|
91
|
+
} catch (failure) {
|
|
92
|
+
if (!controller.signal.aborted) setError(failure.message || t('saveFailed'))
|
|
93
|
+
} finally {
|
|
94
|
+
activeRequest.current = false
|
|
95
|
+
if (!controller.signal.aborted) setBusy(false)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (props.view === 'summary') return t('description')
|
|
99
|
+
var disabled = busy || !status?.writable
|
|
100
|
+
var stateLabel = !status ? t('loading') : !status.configured ? t('anonymous') : status.verified ? t('verified') : t('unverified')
|
|
101
|
+
return element('section', { 'data-gotry-flyai-settings': '', style: { display: 'grid', gap: '16px', maxWidth: '640px' } }, [
|
|
102
|
+
element('h3', { key: 'heading', style: { margin: 0 } }, t('flyai')),
|
|
103
|
+
element('div', { key: 'status', role: 'status', 'data-gotry-flyai-status': '', style: { display: 'grid', gap: '6px' } }, [
|
|
104
|
+
element(UI.Tag, { key: 'badge', tone: status?.verified ? 'success' : 'neutral' }, stateLabel),
|
|
105
|
+
status ? element('span', { key: 'source', style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' } }, t('source') + ':' + t(status.source)) : null,
|
|
106
|
+
status?.endpointDebug ? element('span', { key: 'debug' }, t('debug') + ':' + status.endpoint) : null,
|
|
107
|
+
]),
|
|
108
|
+
element('a', { key: 'console', href: 'https://flyai.open.fliggy.com/console', target: '_blank', rel: 'noopener noreferrer' }, t('console')),
|
|
109
|
+
element(UI.SettingsForm, { key: 'form', labels: { unavailable: t('loading'), readOnly: t('readOnly'), saveFailed: t('saveFailed'), save: t('save'), saving: t('saving') },
|
|
110
|
+
state: { available: Boolean(status), writable: Boolean(status?.writable), dirty: key.trim().length > 0, invalid: false, saving: busy, failed: false },
|
|
111
|
+
onSave: function () { void write('save') }, onDiscard: function () { setKey('') } }, [
|
|
112
|
+
element(UI.SettingsSecretField, { key: 'key', id: 'gotry-flyai-key', label: t('key'), hint: t('keyHint'), text: key,
|
|
113
|
+
configured: Boolean(status?.configured), stateLabel: t(status?.configured ? 'configured' : 'anonymous'), disabled: disabled,
|
|
114
|
+
onEdit: function (value) { setKey(value); setError(''); setNotice('') } }),
|
|
115
|
+
]),
|
|
116
|
+
error ? element('p', { key: 'error', role: 'alert', style: { margin: 0, color: 'var(--dsw-alias-state-error-primary)' } }, error) : null,
|
|
117
|
+
notice ? element('p', { key: 'notice', role: 'status', style: { margin: 0 } }, notice) : null,
|
|
118
|
+
element('div', { key: 'actions', style: { display: 'flex', gap: '8px', flexWrap: 'wrap' } }, [
|
|
119
|
+
element(UI.Button, { key: 'retry', type: 'button', variant: 'outline', disabled: busy,
|
|
120
|
+
onClick: function () { setReload(function (value) { return value + 1 }) } }, t('retry')),
|
|
121
|
+
status?.configured && status.writable ? element(UI.Button, { key: 'clear', type: 'button', disabled: busy,
|
|
122
|
+
onClick: function () { void write('clear') } }, t('clear')) : null,
|
|
123
|
+
]),
|
|
124
|
+
])
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function ArtifactBrowser(props) {
|
|
128
|
+
var t = props.t, info = props.useTabInfo(), sessionId = props.sessionId
|
|
129
|
+
var cwd = props.useSessions(function (sessions) { return sessions.byId[sessionId]?.cwd })
|
|
130
|
+
var state = React.useState(null), result = state[0], setResult = state[1]
|
|
131
|
+
var queryState = React.useState(''), search = queryState[0], setSearch = queryState[1]
|
|
132
|
+
var pageState = React.useState(0), offset = pageState[0], setOffset = pageState[1]
|
|
133
|
+
var refreshState = React.useState(0), refresh = refreshState[0], setRefresh = refreshState[1]
|
|
134
|
+
var busyState = React.useState(false), busy = busyState[0], setBusy = busyState[1]
|
|
135
|
+
var errorState = React.useState(''), error = errorState[0], setError = errorState[1]
|
|
136
|
+
React.useEffect(function () {
|
|
137
|
+
if (!sessionId || !info.tab.visible) return
|
|
138
|
+
var controller = new AbortController()
|
|
139
|
+
setBusy(true); setError('')
|
|
140
|
+
var query = new URLSearchParams({ sessionId: sessionId, search: search, offset: String(offset), limit: '20' })
|
|
141
|
+
webRequest('artifacts?' + query, { signal: controller.signal }).then(function (value) {
|
|
142
|
+
if (!controller.signal.aborted) setResult(value)
|
|
143
|
+
}).catch(function () { if (!controller.signal.aborted) setError(t('failed')) })
|
|
144
|
+
.finally(function () { if (!controller.signal.aborted) setBusy(false) })
|
|
145
|
+
return function () { controller.abort() }
|
|
146
|
+
}, [sessionId, info.tab.visible, search, offset, refresh])
|
|
147
|
+
function open(path) {
|
|
148
|
+
var normalized = String(path).replace(/\\/g, '/')
|
|
149
|
+
var root = String(cwd || '').replace(/\\/g, '/').replace(/\/$/, '')
|
|
150
|
+
var relative = root && normalized.startsWith(root + '/') ? normalized.slice(root.length + 1) : normalized
|
|
151
|
+
info.tab.actions.openResource('dsh-resource://file/session/' + encodeURIComponent(sessionId) + '/' + relative.split('/').map(encodeURIComponent).join('/'))
|
|
152
|
+
}
|
|
153
|
+
var entries = result?.artifacts || []
|
|
154
|
+
return element('section', { 'data-gotry-artifacts-browser': '', style: { padding: '16px', display: 'grid', alignContent: 'start', gap: '12px', height: '100%', overflow: 'auto', boxSizing: 'border-box', minWidth: 0 } }, [
|
|
155
|
+
element('div', { key: 'controls', style: { display: 'flex', gap: '8px', alignItems: 'center', minWidth: 0 } }, [
|
|
156
|
+
element(UI.Input, { key: 'search', 'aria-label': t('search'), placeholder: t('searchPlaceholder'), value: search,
|
|
157
|
+
style: { minWidth: 0, width: '100%' }, onChange: function (event) { setSearch(event.target.value); setOffset(0) } }),
|
|
158
|
+
element(UI.Button, { key: 'refresh', size: 'sm', disabled: busy || !sessionId,
|
|
159
|
+
onClick: function () { setRefresh(function (value) { return value + 1 }) } }, busy ? t('loading') : t('refresh')),
|
|
160
|
+
]),
|
|
161
|
+
error ? element('p', { key: 'error', role: 'alert' }, error) : null,
|
|
162
|
+
!sessionId ? element('p', { key: 'no-session' }, t('noSession')) : !busy && entries.length === 0 ? element('p', { key: 'empty' }, t(search ? 'noMatches' : 'empty')) : null,
|
|
163
|
+
element('div', { key: 'list', 'aria-busy': busy, style: { display: 'grid', gap: '8px' } }, entries.map(function (entry) {
|
|
164
|
+
var html = /\.html?$/i.test(entry.path)
|
|
165
|
+
return element(UI.Button, { key: entry.path, variant: 'outline', 'data-gotry-artifact-entry': entry.path,
|
|
166
|
+
title: html ? t('htmlHint') : entry.path, onClick: function () { open(entry.path) },
|
|
167
|
+
style: { width: '100%', height: 'auto', padding: '12px', textAlign: 'left', justifyContent: 'flex-start', minWidth: 0 } }, [
|
|
168
|
+
element(UI.FileTypeIcon, { key: 'icon', path: entry.path }),
|
|
169
|
+
element('span', { key: 'body', style: { display: 'grid', gap: '4px', minWidth: 0, flex: 1 } }, [
|
|
170
|
+
element('span', { key: 'title', style: { overflowWrap: 'anywhere' } }, entry.title),
|
|
171
|
+
element('span', { key: 'meta', style: { fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)', overflowWrap: 'anywhere' } },
|
|
172
|
+
[entry.status, entry.updated ? new Date(entry.updated).toLocaleString() : '', html ? t('html') : t('open')].filter(Boolean).join(' · ')),
|
|
173
|
+
]),
|
|
174
|
+
])
|
|
175
|
+
})),
|
|
176
|
+
result?.total > 0 ? element('div', { key: 'pagination', style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap' } }, [
|
|
177
|
+
element(UI.Button, { key: 'previous', size: 'sm', disabled: busy || offset === 0, onClick: function () { setOffset(Math.max(0, offset - 20)) } }, t('previous')),
|
|
178
|
+
element('span', { key: 'count', role: 'status', style: { fontSize: '12px' } }, (entries.length ? offset + 1 : 0) + '–' + (offset + entries.length) + ' / ' + result.total),
|
|
179
|
+
element(UI.Button, { key: 'next', size: 'sm', disabled: busy || !result.truncated, onClick: function () { setOffset(result.nextOffset) } }, t('next')),
|
|
180
|
+
]) : null,
|
|
181
|
+
])
|
|
182
|
+
}
|
|
15
183
|
|
|
16
184
|
function element(type, props, children) {
|
|
17
185
|
return React.createElement(type, props, ...(Array.isArray(children) ? children : [children]))
|
|
@@ -194,6 +362,21 @@ window.__ModuleLoader__.load({
|
|
|
194
362
|
return scope.slots.register({ name: 'tool.call.toolview', key: 'gotry_artifacts_read' }, ArtifactToolView)
|
|
195
363
|
})
|
|
196
364
|
})
|
|
365
|
+
ctx.inject(['slots', 'locale'], function (scope) {
|
|
366
|
+
var ns = 'gotry.web'
|
|
367
|
+
var t = scope.locale.bind(ns)
|
|
368
|
+
scope.effect(function () { return scope.locale.register(ns, webLocale) })
|
|
369
|
+
scope.slots.inject('plugins.item', function () {
|
|
370
|
+
return scope.slots.register({ name: 'plugins.item', id: 'gotry', order: 50, label: function () { return t('title') }, locale: ns }, FlyaiSettings)
|
|
371
|
+
})
|
|
372
|
+
scope.inject(['sidebarRightTabs'], function (sidebar) {
|
|
373
|
+
sidebar.effect(function () { return sidebar.sidebarRightTabs.register({ id: '@danceiny/gotry:artifacts', kind: 'gotry-artifacts',
|
|
374
|
+
title: function () { return t('artifacts') }, guide: [{ id: 'gotry-artifacts', order: 15, title: function () { return t('artifacts') }, description: function () { return t('artifactsHint') } }] }) })
|
|
375
|
+
sidebar.slots.inject('sidebar.right.pane.tab', function () {
|
|
376
|
+
return sidebar.slots.register({ name: 'sidebar.right.pane.tab', key: '@danceiny/gotry:artifacts', locale: ns }, ArtifactBrowser)
|
|
377
|
+
})
|
|
378
|
+
})
|
|
379
|
+
})
|
|
197
380
|
}
|
|
198
381
|
|
|
199
382
|
exports.apply = apply
|
package/cordis.gotry-patch.yml
CHANGED
|
@@ -215,6 +215,32 @@ async function listCwdArtifacts(cwd) {
|
|
|
215
215
|
}
|
|
216
216
|
return entries;
|
|
217
217
|
}
|
|
218
|
+
/** Durable handoff outputs live outside async/, including honest failure notes. */
|
|
219
|
+
async function listHandoffArtifacts(root) {
|
|
220
|
+
const dir = join(root, 'gotry-state', 'turn-handoffs');
|
|
221
|
+
const names = await readdir(dir).catch(() => []);
|
|
222
|
+
const entries = [];
|
|
223
|
+
for (const name of names) {
|
|
224
|
+
if (!name.endsWith('.json'))
|
|
225
|
+
continue;
|
|
226
|
+
try {
|
|
227
|
+
const ticket = JSON.parse(await readFile(join(dir, name), 'utf8'));
|
|
228
|
+
if (ticket.schema !== 'gotry_turn_handoff.v1' || name !== `${ticket.id}.json`
|
|
229
|
+
|| !['settled', 'failed'].includes(ticket.status) || ticket.deliverableFile !== `${ticket.id}.deliverable.md`)
|
|
230
|
+
continue;
|
|
231
|
+
const path = await realpath(join(dir, ticket.deliverableFile));
|
|
232
|
+
if (!underRoot(path, root) || hasDeniedSegment(path))
|
|
233
|
+
continue;
|
|
234
|
+
const info = await stat(path);
|
|
235
|
+
if (!info.isFile())
|
|
236
|
+
continue;
|
|
237
|
+
entries.push({ source: 'turn-handoff', id: ticket.id, title: String(ticket.objective ?? ticket.id),
|
|
238
|
+
path, status: ticket.status, updated: new Date(info.mtimeMs).toISOString(), bytes: info.size });
|
|
239
|
+
}
|
|
240
|
+
catch { /* A corrupt or missing output cannot be advertised as a file. */ }
|
|
241
|
+
}
|
|
242
|
+
return entries;
|
|
243
|
+
}
|
|
218
244
|
export async function listArtifacts(opts) {
|
|
219
245
|
const limit = clampLimit(opts.limit);
|
|
220
246
|
const offset = normalizeOffset(opts.offset, 'offset');
|
|
@@ -223,12 +249,16 @@ export async function listArtifacts(opts) {
|
|
|
223
249
|
const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd();
|
|
224
250
|
const canonicalRoot = await realpath(root).catch(() => root);
|
|
225
251
|
const canonicalCwd = await realpath(cwd).catch(() => cwd);
|
|
252
|
+
const handoffRoot = rootOf(opts.handoffRoot ?? opts.cwd ?? opts.stateRoot);
|
|
253
|
+
const canonicalHandoffRoot = await realpath(handoffRoot).catch(() => handoffRoot);
|
|
226
254
|
// 1) collect all eligible entries per source, without per-source limits —
|
|
227
255
|
// pageSize is applied to the merged/deduped/sorted set so older cwd files
|
|
228
256
|
// stay reachable across pages.
|
|
229
257
|
const collected = [
|
|
230
258
|
...listRunsFromLedger(canonicalRoot, 'local'),
|
|
231
259
|
...(await listDeliverableFiles(canonicalRoot)),
|
|
260
|
+
...(await listHandoffArtifacts(canonicalRoot)),
|
|
261
|
+
...(canonicalHandoffRoot !== canonicalRoot ? await listHandoffArtifacts(canonicalHandoffRoot) : []),
|
|
232
262
|
...(await listCwdArtifacts(canonicalCwd)),
|
|
233
263
|
];
|
|
234
264
|
// 2) canonical-path dedupe (keep ledger authority when both sources point
|
|
@@ -262,6 +292,8 @@ export async function listArtifacts(opts) {
|
|
|
262
292
|
offset,
|
|
263
293
|
roots: [canonicalRoot, canonicalCwd],
|
|
264
294
|
};
|
|
295
|
+
if (!result.roots.includes(canonicalHandoffRoot))
|
|
296
|
+
result.roots.push(canonicalHandoffRoot);
|
|
265
297
|
if (hasMore)
|
|
266
298
|
result.nextOffset = start + page.length;
|
|
267
299
|
if (needle !== '')
|
|
@@ -280,6 +312,8 @@ export async function readArtifact(opts) {
|
|
|
280
312
|
const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd();
|
|
281
313
|
const canonicalRoot = await realpath(root).catch(() => root);
|
|
282
314
|
const canonicalCwd = await realpath(cwd).catch(() => cwd);
|
|
315
|
+
const handoffRoot = rootOf(opts.handoffRoot ?? opts.cwd ?? opts.stateRoot);
|
|
316
|
+
const canonicalHandoffRoot = await realpath(handoffRoot).catch(() => handoffRoot);
|
|
283
317
|
const raw = String(opts.path ?? '').trim();
|
|
284
318
|
if (!raw)
|
|
285
319
|
return { ok: false, error: 'path 必填(来自 gotry_artifacts_list 的 path,或异步工单 id)' };
|
|
@@ -296,10 +330,13 @@ export async function readArtifact(opts) {
|
|
|
296
330
|
filePath = asyncDeliverablePath(root, raw);
|
|
297
331
|
}
|
|
298
332
|
else {
|
|
299
|
-
const
|
|
333
|
+
const handoff = /^th-[a-z0-9-]+$/i.test(raw)
|
|
334
|
+
? [join(handoffRoot, 'gotry-state', 'turn-handoffs', `${raw}.deliverable.md`),
|
|
335
|
+
join(root, 'gotry-state', 'turn-handoffs', `${raw}.deliverable.md`)] : [];
|
|
336
|
+
const p = handoff.find(path => existsSync(path)) ?? asyncDeliverablePath(root, raw);
|
|
300
337
|
const canonical = await realpath(p).catch(() => null);
|
|
301
338
|
if (canonical) {
|
|
302
|
-
if (!underRoot(canonical, canonicalRoot) || hasDeniedSegment(canonical)) {
|
|
339
|
+
if (!(underRoot(canonical, canonicalRoot) || underRoot(canonical, canonicalHandoffRoot)) || hasDeniedSegment(canonical)) {
|
|
303
340
|
return { ok: false, error: `路径越界:${raw}`, hint: `只读 ${root} 与 dsh 工作目录内的文本产物` };
|
|
304
341
|
}
|
|
305
342
|
const ext = canonical.slice(canonical.lastIndexOf('.') + 1).toLowerCase();
|
|
@@ -325,7 +362,7 @@ export async function readArtifact(opts) {
|
|
|
325
362
|
// 路径(realpath 后的 cwd),而 macOS 上 /var 与 /private/var 这类符号链接会让
|
|
326
363
|
// canonical 路径不匹配字面 cwd,导致 list→read 串联断裂。边界权威仍是下方
|
|
327
364
|
// realpath 后的 canonical 复检,这里放宽不改变可读集合。
|
|
328
|
-
const inScope = (p) => (underRoot(p, cwd) || underRoot(p, root) || underRoot(p, canonicalCwd) || underRoot(p, canonicalRoot)) && !hasDeniedSegment(p);
|
|
365
|
+
const inScope = (p) => (underRoot(p, cwd) || underRoot(p, root) || underRoot(p, canonicalCwd) || underRoot(p, canonicalRoot) || underRoot(p, handoffRoot) || underRoot(p, canonicalHandoffRoot)) && !hasDeniedSegment(p);
|
|
329
366
|
// Prefer an existing candidate so a cwd miss does not mask a valid state-root file.
|
|
330
367
|
const allowed = candidates.find(p => inScope(p) && existsSync(p)) ?? candidates.find(inScope);
|
|
331
368
|
if (!allowed) {
|
|
@@ -335,7 +372,7 @@ export async function readArtifact(opts) {
|
|
|
335
372
|
if (!canonical) {
|
|
336
373
|
return { ok: false, error: `文件不存在:${raw}`, hint: '先 gotry_artifacts_list 看在册产物' };
|
|
337
374
|
}
|
|
338
|
-
if (!(underRoot(canonical, canonicalCwd) || underRoot(canonical, canonicalRoot)) || hasDeniedSegment(canonical)) {
|
|
375
|
+
if (!(underRoot(canonical, canonicalCwd) || underRoot(canonical, canonicalRoot) || underRoot(canonical, canonicalHandoffRoot)) || hasDeniedSegment(canonical)) {
|
|
339
376
|
return { ok: false, error: `路径越界:${raw}`, hint: `只读 ${root} 与 dsh 工作目录内的文本产物` };
|
|
340
377
|
}
|
|
341
378
|
const ext = canonical.slice(canonical.lastIndexOf('.') + 1).toLowerCase();
|
|
@@ -19,6 +19,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
19
19
|
import { createRequire } from 'node:module';
|
|
20
20
|
import { homedir } from 'node:os';
|
|
21
21
|
import { join, resolve } from 'node:path';
|
|
22
|
+
import { checkExtensionStatus } from '../../bin/gotry-extension-status.js';
|
|
22
23
|
import { readLatestChannelEvents } from './channel-health.js';
|
|
23
24
|
import { displayEndpoint, endpointFingerprint, readFlyaiVerification, resolveFlyaiEndpoint, resolveFlyaiKey, sha256Hex, } from './flyai-config.js';
|
|
24
25
|
/** hbcli 已知安装位(与 capabilities/hbcli.ts hbcliBinCandidates 同清单) */
|
|
@@ -124,17 +125,8 @@ export async function runDoctorChecks(opts = {}) {
|
|
|
124
125
|
items.push(nodeOk(nodeVersion)
|
|
125
126
|
? { id: 'node', label: 'Node 运行时', status: 'ok', detail: `Node ${nodeVersion}(≥22.15)` }
|
|
126
127
|
: { id: 'node', label: 'Node 运行时', status: 'missing', detail: `Node ${nodeVersion} 过旧(gotry 需 ≥22.15)`, fix: '升级 Node.js 至 22.15+(https://nodejs.org)' });
|
|
127
|
-
// 1.
|
|
128
|
-
|
|
129
|
-
// missing detail 明示「商店版用户可忽略本项」,不再把商店版用户误导成本地通道缺失
|
|
130
|
-
const extManifest = join(home, '.gotry', 'extension', 'manifest.json');
|
|
131
|
-
items.push(existsSync(extManifest)
|
|
132
|
-
? { id: 'extension', label: 'Stai Travel Bridge 扩展', status: 'ok', detail: `已就位(本地通道:${extManifest})` }
|
|
133
|
-
: {
|
|
134
|
-
id: 'extension', label: 'Stai Travel Bridge 扩展', status: 'missing',
|
|
135
|
-
detail: '本地通道未落位(~/.gotry/extension/manifest.json 不存在)。影响面:gotry_session_search / gotry_session_login(账号会话通道)不可用;携程机票/酒店实时检索、12306 余票、Dida 实时报价都依赖此通道。其它工具(机票 FlyAI、酒店 hbcli、地图、天气等)不受影响。若你已从 Chrome 商店安装(自动更新,商店版 ID oeajpicc…),本项可忽略——会话检索可用性以运行时为准(gotry_session_search 的 verdict)。若未安装:应用商店一键装即可',
|
|
136
|
-
fix: '在 Chrome 应用商店一键安装(自动更新): https://chromewebstore.google.com/detail/gotry-session-bridge/oeajpiccmonococjcegddlooeeohlbgd',
|
|
137
|
-
});
|
|
128
|
+
// 1. CLI 与工具层共用只读检测:安装、落位、连接是不同证据。
|
|
129
|
+
items.push({ id: 'extension', label: 'Stai Travel Bridge 扩展', ...await checkExtensionStatus({ homeDir: home, env, ports: opts.extensionPorts }) });
|
|
138
130
|
// 2. agent-reach(网页/社媒读取;.venv 装在包内,与 agent-reach.ts venvPython 同位)
|
|
139
131
|
const venvPython = join(repoRoot, '.venv/bin/python');
|
|
140
132
|
const reachBin = join(repoRoot, '.venv/bin/agent-reach');
|
|
@@ -229,7 +221,7 @@ export async function runDoctorChecks(opts = {}) {
|
|
|
229
221
|
items.push({
|
|
230
222
|
id: 'flyai', label: 'FlyAI(飞猪官方检索:机/火/酒/景/关键词/AI/万豪)', status: 'degraded',
|
|
231
223
|
detail: `未配置 key——匿名试用中(共享额度易达限;达限报 Trial limit reached)${flyaiQuotaNote}。影响面:共享池额度小,频繁会话易触顶;达限本会话内 gotry_flyai_search(机票/酒店/票务/AI/万豪 8 类)失败,改走 gotry_session_search 账号会话通道。`,
|
|
232
|
-
fix: '
|
|
224
|
+
fix: 'Web 打开「插件 → GoTry → FlyAI」验证并保存;或运行 `npx @danceiny/gotry setup flyai`:① 打开 https://flyai.open.fliggy.com/console 复制 API Key;② 在终端隐藏粘贴;③ 验证后保存。也可运行 `npx @danceiny/gotry doctor --fix` 按提示配置',
|
|
233
225
|
});
|
|
234
226
|
}
|
|
235
227
|
else {
|
|
@@ -254,7 +246,7 @@ export async function runDoctorChecks(opts = {}) {
|
|
|
254
246
|
items.push({
|
|
255
247
|
id: 'flyai', label: 'FlyAI(飞猪官方检索:机/火/酒/景/关键词/AI/万豪)', status: 'degraded',
|
|
256
248
|
detail: `${reason}(${sourceText};endpoint ${displayEndpoint(flyaiEndpoint.url)}${flyaiEndpoint.debug ? '(DEBUG)' : ''})——非空不等于鉴权通过`,
|
|
257
|
-
fix: '
|
|
249
|
+
fix: 'Web 打开「插件 → GoTry → FlyAI」重新验证;或运行 `npx @danceiny/gotry setup flyai`;`npx @danceiny/gotry setup flyai --clear` 回退匿名',
|
|
258
250
|
});
|
|
259
251
|
}
|
|
260
252
|
}
|
|
@@ -264,7 +256,7 @@ export async function runDoctorChecks(opts = {}) {
|
|
|
264
256
|
? { id: 'sidebar', label: 'dsh-better-sidebar(侧栏工作台)', status: 'ok', detail: '已安装——web UI 右侧工作台可预览产物与 doctor 报告(gotry-state/doctor-report.md)' }
|
|
265
257
|
: {
|
|
266
258
|
id: 'sidebar', label: 'dsh-better-sidebar(侧栏工作台)', status: 'missing',
|
|
267
|
-
detail: '
|
|
259
|
+
detail: '未安装此增强组件——Web 自带 GoTry 产物页与原生预览;可选补装以增强文件浏览',
|
|
268
260
|
fix: 'npx @danceiny/gotry doctor --fix',
|
|
269
261
|
});
|
|
270
262
|
// 6. dsh-calendar(patch 分发面宿主插件;D-9 拍板:默认不挂载)
|
|
@@ -371,7 +363,7 @@ export async function runDoctorChecks(opts = {}) {
|
|
|
371
363
|
const broken = items.filter(i => i.status !== 'ok' && i.id !== 'llm-key');
|
|
372
364
|
const summary = broken.length === 0
|
|
373
365
|
? `体检通过:${items.length} 项全部就绪(可选依赖齐,LLM key 归 dsh 宿主管)。`
|
|
374
|
-
: `体检发现 ${broken.length} 项待处理:${broken.map(i => `${i.label}(${i.status === 'missing' ? '未装' : '半可用'})`).join('、')}。补装:终端跑 npx @danceiny/gotry doctor --fix,或按各项 fix 指引逐项处理。`;
|
|
366
|
+
: `体检发现 ${broken.length} 项待处理:${broken.map(i => `${i.label}(${i.status === 'missing' ? '未装' : i.id === 'extension' ? '待确认' : '半可用'})`).join('、')}。补装:终端跑 npx @danceiny/gotry doctor --fix,或按各项 fix 指引逐项处理。`;
|
|
375
367
|
return { ok: broken.length === 0, items, summary };
|
|
376
368
|
}
|
|
377
369
|
/** 修复指引入表:命令类才加反引号(prose 类如「到控制台申请 key」原样) */
|
|
@@ -206,6 +206,8 @@ async function main() {
|
|
|
206
206
|
cwd: TS_DIR,
|
|
207
207
|
env: {
|
|
208
208
|
...process.env,
|
|
209
|
+
HOME: dshHome,
|
|
210
|
+
DSH_HOME: dshHome,
|
|
209
211
|
GOTRY_HANDOFF_PLANNER_BIN: configuredBin,
|
|
210
212
|
LLM_API_KEY: 'synthetic-e2e-key',
|
|
211
213
|
LLM_BASE_URL: 'http://127.0.0.1:' + relay.port + '/v1',
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
-
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { Context } from '@deepseek-ai/cordis';
|
|
6
|
+
import { ToolRuntime, defineTool } from '@deepseek-ai/dsh-tools';
|
|
7
|
+
import { createScope } from '@deepseek-ai/dsh-scope';
|
|
5
8
|
import { installTurnDeadline, TURN_DEADLINE_EXHAUSTED, TURN_DEADLINE_HANDOFF, TURN_HANDOFF_SCHEMA, writeTurnHandoffTicket, } from '../src/turn-deadline.js';
|
|
6
9
|
const success = () => ({ isError: false, value: null, content: [] });
|
|
7
10
|
const handlers = new Map();
|
|
@@ -129,7 +132,7 @@ const turnEvent = (id, type) => sessionEvent({ id }, { type });
|
|
|
129
132
|
const text = result.content[0]?.text ?? '';
|
|
130
133
|
assert.match(text, /TURN_DEADLINE_HANDOFF/);
|
|
131
134
|
assert.match(text, /th-/, 'result names the persisted ticket id');
|
|
132
|
-
assert.match(text,
|
|
135
|
+
assert.match(text, /queued|not started/i, 'a persisted ticket alone must not claim a running worker');
|
|
133
136
|
}
|
|
134
137
|
// 同步抑制已生效
|
|
135
138
|
assert.deepEqual(restricted.at(-1), { agentId: agent.id, deny: ['gotry_alpha', 'gotry_beta'] });
|
|
@@ -181,5 +184,135 @@ function deepPlanningNoise() {
|
|
|
181
184
|
rmSync(root, { recursive: true, force: true });
|
|
182
185
|
}
|
|
183
186
|
sessionDisposed({ id: 'agent-handoff' });
|
|
187
|
+
// A deadline must let the pending output write complete and keep native present
|
|
188
|
+
// available. Start exactly one tracked job even if the model makes another call.
|
|
189
|
+
{
|
|
190
|
+
const hooks = new Map();
|
|
191
|
+
let started = 0;
|
|
192
|
+
let denied = [];
|
|
193
|
+
const agent = { id: 'delivery',
|
|
194
|
+
session: { id: 'delivery', header: { cwd: handoffRoot }, snapshotEvents: () => [
|
|
195
|
+
{ type: 'tool/call', data: { name: 'ask_user_question', callId: 'question' } },
|
|
196
|
+
{ type: 'tool/result', data: { message: { source: { kind: 'tool', callId: 'question' }, content: [{ text: 'China working hours 9-18 CST' }] } } },
|
|
197
|
+
{ type: 'tool/result', data: { message: { source: { kind: 'tool', callId: 'other' }, content: [{ text: 'large evidence '.repeat(3000) }] } } },
|
|
198
|
+
] },
|
|
199
|
+
ctx: { tools: { restrict: (filter) => { denied = filter.deny; return () => { }; } } } };
|
|
200
|
+
const deliveryCtx = {
|
|
201
|
+
tools: { schemas: () => [{ name: 'write' }, { name: 'present' }, { name: 'search' }] },
|
|
202
|
+
on: (name, hook) => { hooks.set(name, hook); },
|
|
203
|
+
};
|
|
204
|
+
installTurnDeadline(deliveryCtx, {
|
|
205
|
+
stateRoot: handoffRoot, now: clock.now,
|
|
206
|
+
startHandoff: async (ticket) => {
|
|
207
|
+
assert.equal(written, true, 'output finishes before the worker starts');
|
|
208
|
+
assert.ok(ticket.context?.includes('9-18 CST'), 'retain early clarification answers despite large recent evidence');
|
|
209
|
+
started++;
|
|
210
|
+
return { ...ticket, status: 'running', jobId: 'gotry-planning-1' };
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
hooks.get('session/event')({ id: agent.id }, { type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: deepMessageForDelivery() }] } });
|
|
214
|
+
clock.advance(240_001);
|
|
215
|
+
let written = false;
|
|
216
|
+
const draft = join(handoffRoot, 'exploration.md');
|
|
217
|
+
const pending = { token: Symbol(), name: 'write', agent };
|
|
218
|
+
const result = await hooks.get('tools/execute')(pending, async () => { writeFileSync(draft, '# Exploration'); written = true; return success(); });
|
|
219
|
+
const post = await hooks.get('tools/post-execute')(pending, result, async () => ({ kind: 'accept' }));
|
|
220
|
+
assert.equal(written, true, 'pending output write must really execute');
|
|
221
|
+
assert.equal(readFileSync(draft, 'utf8'), '# Exploration');
|
|
222
|
+
assert.equal(result.isError, false, 'write result must remain truthful');
|
|
223
|
+
assert.ok(!denied.includes('present'), 'native deliverable publication stays available');
|
|
224
|
+
assert.equal(started, 1, 'handoff starts a tracked job');
|
|
225
|
+
await hooks.get('tools/execute')({ token: Symbol(), name: 'present', agent }, async () => success());
|
|
226
|
+
assert.equal(started, 1, 'present must not queue another planner');
|
|
227
|
+
const text = JSON.stringify(post.additionalContexts);
|
|
228
|
+
assert.match(text, /gotry-planning-1/, 'handoff names the actual native job');
|
|
229
|
+
assert.match(text, /present/, 'successful writes must be presented before final reply');
|
|
230
|
+
let repeatedWrite = false;
|
|
231
|
+
await hooks.get('tools/execute')({ token: Symbol(), name: 'write', agent }, async () => { repeatedWrite = true; return success(); });
|
|
232
|
+
assert.equal(repeatedWrite, false, 'only the pending draft write gets the deadline exception');
|
|
233
|
+
assert.equal(started, 1, 'repeated finalization must not dispatch another worker');
|
|
234
|
+
}
|
|
235
|
+
function deepMessageForDelivery() { return '12月初有个阿联酋国庆,我打算借机 IRW远程办公(还有5天额度)+请假+放假 回国玩一趟,主要去东北,比如长春泡澡啊啥的,可以看看一些东北风光啥的(没去过),我女朋友从南京过去,请帮我探索一个可行的具体规划'; }
|
|
184
236
|
rmSync(handoffRoot, { recursive: true, force: true });
|
|
237
|
+
// Real DSH registry coverage: inherited and agent-scoped output writes must
|
|
238
|
+
// finish before deadline restrictions hide inherited registrations.
|
|
239
|
+
for (const [registration, writeFailure] of [['global', false], ['scoped', false], ['global', true], ['scoped', true]]) {
|
|
240
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-write-registry-'));
|
|
241
|
+
const runtimeCtx = new Context();
|
|
242
|
+
runtimeCtx.provide('systemPrompt', { tools() { } });
|
|
243
|
+
const tools = new ToolRuntime(runtimeCtx);
|
|
244
|
+
let time = 0;
|
|
245
|
+
const mutableAgent = { id: registration, ctx: runtimeCtx, session: { id: registration, header: { cwd: root }, snapshotEvents: () => [] } };
|
|
246
|
+
const agent = mutableAgent;
|
|
247
|
+
const scope = createScope(runtimeCtx, agent);
|
|
248
|
+
mutableAgent.ctx = scope.ctx;
|
|
249
|
+
installTurnDeadline(runtimeCtx, { stateRoot: root, now: () => time, fixedPolicy: { softMs: 5, hardMs: 10, exit: 'handoff' } });
|
|
250
|
+
const draft = join(root, 'draft.md');
|
|
251
|
+
let writes = 0;
|
|
252
|
+
(registration === 'global' ? tools : scope.ctx.tools).register(defineTool({
|
|
253
|
+
name: 'write', description: 'Write the draft', parameters: {}, output: { schema: { type: 'json' }, render: () => [{ type: 'text', text: 'saved' }] },
|
|
254
|
+
async execute() { writes++; if (writeFailure)
|
|
255
|
+
throw new Error('disk write failed'); writeFileSync(draft, 'saved draft'); return { saved: true }; },
|
|
256
|
+
}));
|
|
257
|
+
try {
|
|
258
|
+
assert.ok(tools.get('write', agent), 'write is visible before deadline dispatch');
|
|
259
|
+
runtimeCtx.emit('session/event', agent.session, { type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'plan my trip' }] } });
|
|
260
|
+
time = 11;
|
|
261
|
+
const call = () => tools.execute({ name: 'write', agent, arguments: {}, callId: 'draft', signal: new AbortController().signal });
|
|
262
|
+
const result = await call();
|
|
263
|
+
assert.equal(result.isError, writeFailure, `${registration} pending write keeps its actual registry outcome: ${JSON.stringify(result)}`);
|
|
264
|
+
if (writeFailure) {
|
|
265
|
+
assert.equal(existsSync(draft), false);
|
|
266
|
+
assert.match(JSON.stringify(result), /disk write failed/);
|
|
267
|
+
}
|
|
268
|
+
else
|
|
269
|
+
assert.equal(readFileSync(draft, 'utf8'), 'saved draft');
|
|
270
|
+
assert.match(JSON.stringify(result.additionalContexts), /queued/, 'registry post-execute carries handoff guidance');
|
|
271
|
+
await call();
|
|
272
|
+
assert.equal(writes, 1, 'only the pending write receives the exception');
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
await scope.dispose();
|
|
276
|
+
await runtimeCtx.fiber.dispose();
|
|
277
|
+
rmSync(root, { recursive: true, force: true });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// Long earlier prose and recent large search output cannot evict a mandatory
|
|
281
|
+
// answer. If user input itself exceeds the bounded snapshot, fail visibly.
|
|
282
|
+
for (const overflow of [false, true]) {
|
|
283
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-handoff-context-'));
|
|
284
|
+
const hooks = new Map();
|
|
285
|
+
let captured;
|
|
286
|
+
const agent = { id: 'constraints', ctx: { tools: { restrict: () => () => { } } }, session: {
|
|
287
|
+
id: 'constraints', header: { cwd: root }, snapshotEvents: () => [
|
|
288
|
+
{ type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'earlier prose '.repeat(overflow ? 6000 : 600) }] } },
|
|
289
|
+
{ type: 'tool/call', data: { name: 'ask_user_question', callId: 'access' } },
|
|
290
|
+
{ type: 'tool/result', data: { message: { source: { kind: 'tool', callId: 'access' }, content: [{ type: 'text', text: 'MUST_KEEP_WHEELCHAIR_ACCESS_AND_NO_STAIRS' }] } } },
|
|
291
|
+
{ type: 'tool/result', data: { message: { source: { kind: 'tool', callId: 'search' }, content: [{ type: 'text', text: 'recent search '.repeat(2000) }] } } },
|
|
292
|
+
],
|
|
293
|
+
} };
|
|
294
|
+
const localCtx = { tools: { schemas: () => [] }, on: (name, hook) => hooks.set(name, hook) };
|
|
295
|
+
let time = 0;
|
|
296
|
+
installTurnDeadline(localCtx, { stateRoot: root, now: () => time, fixedPolicy: { softMs: 5, hardMs: 10, exit: 'handoff' },
|
|
297
|
+
startHandoff: async (ticket) => { captured = ticket; return ticket; } });
|
|
298
|
+
try {
|
|
299
|
+
hooks.get('session/event')({ id: agent.id }, { type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'plan my trip' }] } });
|
|
300
|
+
time = 11;
|
|
301
|
+
await hooks.get('tools/execute')({ token: Symbol(), name: 'search', agent }, async () => success());
|
|
302
|
+
if (overflow) {
|
|
303
|
+
assert.equal(captured, undefined, 'do not start planning after silently losing user constraints');
|
|
304
|
+
const ticketFile = readdirSync(join(root, 'gotry-state', 'turn-handoffs')).find(name => name.endsWith('.json'));
|
|
305
|
+
const ticket = JSON.parse(readFileSync(join(root, 'gotry-state', 'turn-handoffs', ticketFile), 'utf8'));
|
|
306
|
+
assert.equal(ticket.status, 'failed');
|
|
307
|
+
assert.match(ticket.error, /context|answers|constraints/i);
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
assert.ok(captured?.context?.includes('MUST_KEEP_WHEELCHAIR_ACCESS_AND_NO_STAIRS'), 'mandatory answer survives large surrounding output');
|
|
311
|
+
assert.ok(captured.context.length < 90_000, 'context remains bounded');
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
finally {
|
|
315
|
+
rmSync(root, { recursive: true, force: true });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
185
318
|
console.log('agent turn deadline tests: OK (routing→converge/handoff exits, ticket persistence, plugin-message discipline, lifecycle)');
|
|
@@ -36,13 +36,14 @@ const fakeReact = {
|
|
|
36
36
|
useState(initial) { return [initial, () => { }]; },
|
|
37
37
|
};
|
|
38
38
|
const clientExports = registrations[0].factory(name => {
|
|
39
|
-
assert.
|
|
40
|
-
return fakeReact;
|
|
39
|
+
assert.ok(['react', '@deepseek-ai/dsh-client-ui-primitives'].includes(name));
|
|
40
|
+
return name === 'react' ? fakeReact : {};
|
|
41
41
|
});
|
|
42
42
|
assert.deepEqual(Object.keys(clientExports), ['apply', 'inject']);
|
|
43
43
|
const slotRegistrations = [];
|
|
44
44
|
const registrationContext = {
|
|
45
|
-
inject(
|
|
45
|
+
inject(deps, callback) { if (deps.every(name => name in registrationContext))
|
|
46
|
+
callback(registrationContext); },
|
|
46
47
|
slots: {
|
|
47
48
|
inject(_slot, callback) { callback(); },
|
|
48
49
|
register(definition, view) {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Successful bounded renders produce native delivery events; failures and escaped paths never do. */
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
const moduleUrl = new URL('../src/artifact-delivery.js', import.meta.url).href;
|
|
7
|
+
const delivery = await import(moduleUrl).catch(() => undefined);
|
|
8
|
+
assert.equal(typeof delivery?.registerArtifactDeliveries, 'function', 'bounded render tools must publish native file deliveries');
|
|
9
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-artifact-delivery-'));
|
|
10
|
+
try {
|
|
11
|
+
const path = join(root, 'trip.html');
|
|
12
|
+
writeFileSync(path, '<h1>Trip</h1>');
|
|
13
|
+
let observe = () => { throw new Error('observer absent'); };
|
|
14
|
+
const events = [];
|
|
15
|
+
const session = { header: { cwd: root }, view: () => ({ getSnapshot: () => ({ openTurnStartSeq: 1, lastTurn: 7 }) }),
|
|
16
|
+
append: (name, value) => events.push({ name, value }) };
|
|
17
|
+
delivery.registerArtifactDeliveries({ get: () => ({ stateOf: () => ({ openTurnStartSeq: 1, lastTurn: 7 }) }),
|
|
18
|
+
on: (name, fn) => { assert.equal(name, 'tools/result'); observe = fn; } });
|
|
19
|
+
const exec = { name: 'gotry_itinerary_render', callId: 'render-1', agent: { session }, signal: new AbortController().signal };
|
|
20
|
+
observe(exec, { isError: false, value: { ok: true, path } });
|
|
21
|
+
assert.deepEqual(events, [{ name: 'deliverables/presented', value: { turn: 7, callId: 'render-1', files: [{ path, description: 'GoTry itinerary' }] } }]);
|
|
22
|
+
for (const value of [{ ok: false, path }, { ok: true, path: join(root, 'missing.html') }, { ok: true, path: join(root, '..', 'private.html') }])
|
|
23
|
+
observe(exec, { isError: false, value });
|
|
24
|
+
observe({ ...exec, name: 'gotry_artifacts_read' }, { isError: false, value: { ok: true, path } });
|
|
25
|
+
observe(exec, { isError: true, value: { ok: true, path } });
|
|
26
|
+
assert.equal(events.length, 1);
|
|
27
|
+
console.log('GoTry native artifact deliveries: real bounded render only PASS');
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
rmSync(root, { recursive: true, force: true });
|
|
31
|
+
}
|