tui-scripted-llm.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  1. import type { Context } from 'cordis'
  2. import type { GenerateOptions, LlmModelContext, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
  3. import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
  4. const CONTROL_PROBE = '\u001b]2;MODEL_CONTROLLED\u0007\u001b[999CMODEL_CURSOR\u009b31mMODEL_C1'
  5. const INITIAL_TEXT = `I need one decision before I continue. ${CONTROL_PROBE}`
  6. const FINAL_TEXT = 'Decision received. Scripted TUI run complete.'
  7. const DEFAULT_MODE_PROBE = 'Confirm the scripted run left plan mode.'
  8. const DEFAULT_MODE_TEXT = 'Default mode confirmed.'
  9. // The `skill` scenario types `/skill:scripted-skill`; the manual-invocation front
  10. // door delivers the loaded skill as a user turn wrapped in `<skill name="…">`. The
  11. // body marker below lives in the fixture skill, so echoing it back proves the whole
  12. // block (name attribute plus body) reached the model, not just the command text.
  13. const SKILL_BLOCK_OPEN = '<skill name="scripted-skill">'
  14. const SKILL_BODY_MARKER = 'SCRIPTED SKILL BODY MARKER'
  15. const SKILL_RECEIVED_TEXT = 'Scripted skill body received.'
  16. const TITLE_TEXT = 'scripted session title'
  17. function textChunks(text: string): StreamChunk[] {
  18. return [
  19. { type: 'block-start', index: 0, blockType: 'text' },
  20. ...Array.from(text, (char): StreamChunk => ({ type: 'text-delta', index: 0, text: char })),
  21. { type: 'block-end', index: 0, block: { type: 'text', text } },
  22. { type: 'usage', usage: { inputTokens: 20, outputTokens: text.length } },
  23. { type: 'finish', reason: { kind: 'stop' } },
  24. ]
  25. }
  26. /** Keyless adapter for the real-PTY TUI tests: the two-step conversation and the `/skill:` round-trip. */
  27. class ScriptedTuiAdapter extends LlmAdapter {
  28. override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
  29. return Promise.resolve([
  30. { provider, id: 'tui-scripted-model', name: 'Scripted Base' },
  31. { provider, id: 'tui-scripted-model-pro', name: 'Scripted Pro' },
  32. ])
  33. }
  34. override resolveModelContext(_provider: string, _model: string): Promise<LlmModelContext> {
  35. return Promise.resolve({ contextWindow: 128_000 })
  36. }
  37. override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  38. // The session-title provider's auxiliary request carries no tool schemas,
  39. // unlike every agent turn; answer it with a fixed title so the PTY test can
  40. // assert the logged title reaches the terminal window title.
  41. if ((options.tools?.length ?? 0) === 0) {
  42. for (const chunk of textChunks(TITLE_TEXT)) yield chunk
  43. return
  44. }
  45. if (options.model !== 'tui-scripted-model-pro' || !options.system?.includes('tui-scripted-model-pro')) {
  46. throw new Error('the scripted TUI request did not apply the selected model to routing and prompt variables')
  47. }
  48. const lastMessage = options.messages.at(-1)
  49. // The loop appends plugin-sourced context (the plan-mode notice, the
  50. // tool-skill catalog) AFTER the admitted prompt, so the scripted trigger
  51. // may sit one or more user messages back: scan the whole trailing run of
  52. // user-role messages since the last assistant message.
  53. const trailingUserTexts: string[] = []
  54. for (let index = options.messages.length - 1; index >= 0; index--) {
  55. const message = options.messages[index]
  56. if (message?.role !== 'user') break
  57. for (const block of message.content) {
  58. if (block.type === 'text') trailingUserTexts.push(block.text)
  59. }
  60. }
  61. const lastText = trailingUserTexts.join('\n')
  62. if (lastText.includes(DEFAULT_MODE_PROBE)) {
  63. if (options.system?.includes('Stay in plan mode for this scripted TUI test.')) {
  64. throw new Error('the scripted TUI request retained plan guidance after /plan off')
  65. }
  66. for (const chunk of textChunks(DEFAULT_MODE_TEXT)) yield chunk
  67. return
  68. }
  69. if (lastText.includes(SKILL_BLOCK_OPEN)) {
  70. const ack = lastText.includes(SKILL_BODY_MARKER)
  71. ? SKILL_RECEIVED_TEXT
  72. : 'Scripted skill block arrived without its body.'
  73. for (const chunk of textChunks(ack)) yield chunk
  74. return
  75. }
  76. const hasToolResult = lastMessage?.content.some(block => block.type === 'tool-result') ?? false
  77. if (hasToolResult) {
  78. for (const chunk of textChunks(FINAL_TEXT)) yield chunk
  79. return
  80. }
  81. const args = JSON.stringify({
  82. questions: [{
  83. id: 'mode',
  84. header: 'Execution mode',
  85. question: 'How should the scripted run proceed?',
  86. options: [
  87. { label: 'Safe', description: 'Use the guarded path.' },
  88. { label: 'Fast', description: 'Use the shorter path.' },
  89. ],
  90. }],
  91. })
  92. const callId = CallId('call-ask-mode')
  93. yield { type: 'block-start', index: 0, blockType: 'text' }
  94. for (const char of INITIAL_TEXT) yield { type: 'text-delta', index: 0, text: char }
  95. yield { type: 'block-end', index: 0, block: { type: 'text', text: INITIAL_TEXT } }
  96. yield { type: 'block-start', index: 1, blockType: 'tool-call' }
  97. yield { type: 'tool-call-delta', index: 1, id: callId, name: 'ask_user_question', argumentsDelta: args }
  98. yield {
  99. type: 'block-end',
  100. index: 1,
  101. block: { type: 'tool-call', id: callId, name: 'ask_user_question', arguments: args },
  102. }
  103. yield { type: 'usage', usage: { inputTokens: 20, outputTokens: 10 } }
  104. yield { type: 'finish', reason: { kind: 'tool-calls' } }
  105. }
  106. }
  107. export const name = 'tui-scripted-llm'
  108. export const inject = ['llm']
  109. /** Register the network-free adapter used by the PTY fixture. */
  110. export function apply(ctx: Context): void {
  111. ctx.llm.registerAdapter(['tui-scripted'], new ScriptedTuiAdapter())
  112. }