mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
openspec/lightweight-tasks/task1-2-4-1.md
Implement control request handling and refactor related functions - Added `handleIncomingControlRequest` method to `StreamJsonController` for processing control requests. - Created `input.test.ts` and `session.test.ts` to test control request handling. - Refactored `runStreamJsonSession` to delegate control requests to the controller. - Moved `extractUserMessageText` and `writeStreamJsonEnvelope` to a new `io.ts` file for better organization. - Updated tests to ensure proper functionality of control responses and message extraction.
This commit is contained in:
@@ -5,9 +5,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { Config } from '@qwen-code/qwen-code-core';
|
||||||
import type { StreamJsonWriter } from './writer.js';
|
import type { StreamJsonWriter } from './writer.js';
|
||||||
import type {
|
import type {
|
||||||
StreamJsonControlCancelRequestEnvelope,
|
StreamJsonControlCancelRequestEnvelope,
|
||||||
|
StreamJsonControlRequestEnvelope,
|
||||||
StreamJsonControlResponseEnvelope,
|
StreamJsonControlResponseEnvelope,
|
||||||
StreamJsonOutputEnvelope,
|
StreamJsonOutputEnvelope,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
@@ -28,6 +30,43 @@ export class StreamJsonController {
|
|||||||
|
|
||||||
constructor(private readonly writer: StreamJsonWriter) {}
|
constructor(private readonly writer: StreamJsonWriter) {}
|
||||||
|
|
||||||
|
handleIncomingControlRequest(
|
||||||
|
config: Config,
|
||||||
|
envelope: StreamJsonControlRequestEnvelope,
|
||||||
|
): boolean {
|
||||||
|
const subtype = envelope.request?.subtype;
|
||||||
|
switch (subtype) {
|
||||||
|
case 'initialize':
|
||||||
|
this.writer.emitSystemMessage('session_initialized', {
|
||||||
|
session_id: config.getSessionId(),
|
||||||
|
});
|
||||||
|
this.writer.writeEnvelope({
|
||||||
|
type: 'control_response',
|
||||||
|
request_id: envelope.request_id,
|
||||||
|
success: true,
|
||||||
|
response: { subtype: 'initialize' },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
case 'interrupt':
|
||||||
|
this.interruptActiveRun();
|
||||||
|
this.writer.writeEnvelope({
|
||||||
|
type: 'control_response',
|
||||||
|
request_id: envelope.request_id,
|
||||||
|
success: true,
|
||||||
|
response: { subtype: 'interrupt' },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
this.writer.writeEnvelope({
|
||||||
|
type: 'control_response',
|
||||||
|
request_id: envelope.request_id,
|
||||||
|
success: false,
|
||||||
|
error: `Unsupported control_request subtype: ${subtype ?? 'unknown'}`,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sendControlRequest(
|
sendControlRequest(
|
||||||
subtype: string,
|
subtype: string,
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
|
|||||||
47
packages/cli/src/streamJson/input.test.ts
Normal file
47
packages/cli/src/streamJson/input.test.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { parseStreamJsonInputFromIterable } from './input.js';
|
||||||
|
import * as ioModule from './io.js';
|
||||||
|
|
||||||
|
describe('parseStreamJsonInputFromIterable', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the shared stream writer for control responses', async () => {
|
||||||
|
const writeSpy = vi
|
||||||
|
.spyOn(ioModule, 'writeStreamJsonEnvelope')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
|
||||||
|
async function* makeLines(): AsyncGenerator<string> {
|
||||||
|
yield JSON.stringify({
|
||||||
|
type: 'control_request',
|
||||||
|
request_id: 'req-init',
|
||||||
|
request: { subtype: 'initialize' },
|
||||||
|
});
|
||||||
|
yield JSON.stringify({
|
||||||
|
type: 'user',
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'text', text: 'hello world' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await parseStreamJsonInputFromIterable(makeLines());
|
||||||
|
|
||||||
|
expect(result.prompt).toBe('hello world');
|
||||||
|
expect(writeSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'control_response',
|
||||||
|
request_id: 'req-init',
|
||||||
|
success: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,12 +8,11 @@ import { createInterface } from 'node:readline/promises';
|
|||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import {
|
import {
|
||||||
parseStreamJsonEnvelope,
|
parseStreamJsonEnvelope,
|
||||||
serializeStreamJsonEnvelope,
|
|
||||||
type StreamJsonControlRequestEnvelope,
|
type StreamJsonControlRequestEnvelope,
|
||||||
type StreamJsonOutputEnvelope,
|
type StreamJsonOutputEnvelope,
|
||||||
type StreamJsonUserEnvelope,
|
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { FatalInputError } from '@qwen-code/qwen-code-core';
|
import { FatalInputError } from '@qwen-code/qwen-code-core';
|
||||||
|
import { extractUserMessageText, writeStreamJsonEnvelope } from './io.js';
|
||||||
|
|
||||||
export interface ParsedStreamJsonInput {
|
export interface ParsedStreamJsonInput {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
@@ -35,7 +34,9 @@ export async function readStreamJsonInput(): Promise<ParsedStreamJsonInput> {
|
|||||||
|
|
||||||
export async function parseStreamJsonInputFromIterable(
|
export async function parseStreamJsonInputFromIterable(
|
||||||
lines: AsyncIterable<string>,
|
lines: AsyncIterable<string>,
|
||||||
emitEnvelope: (envelope: StreamJsonOutputEnvelope) => void = writeEnvelope,
|
emitEnvelope: (
|
||||||
|
envelope: StreamJsonOutputEnvelope,
|
||||||
|
) => void = writeStreamJsonEnvelope,
|
||||||
): Promise<ParsedStreamJsonInput> {
|
): Promise<ParsedStreamJsonInput> {
|
||||||
const promptParts: string[] = [];
|
const promptParts: string[] = [];
|
||||||
let receivedUserMessage = false;
|
let receivedUserMessage = false;
|
||||||
@@ -104,29 +105,4 @@ function handleControlRequest(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractUserMessageText(
|
export { extractUserMessageText } from './io.js';
|
||||||
envelope: StreamJsonUserEnvelope,
|
|
||||||
): string {
|
|
||||||
const content = envelope.message?.content;
|
|
||||||
if (typeof content === 'string') {
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
if (Array.isArray(content)) {
|
|
||||||
return content
|
|
||||||
.map((block) => {
|
|
||||||
if (block && typeof block === 'object' && 'type' in block) {
|
|
||||||
if (block.type === 'text' && 'text' in block) {
|
|
||||||
return block.text ?? '';
|
|
||||||
}
|
|
||||||
return JSON.stringify(block);
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
})
|
|
||||||
.join('\n');
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeEnvelope(envelope: StreamJsonOutputEnvelope): void {
|
|
||||||
process.stdout.write(`${serializeStreamJsonEnvelope(envelope)}\n`);
|
|
||||||
}
|
|
||||||
|
|||||||
41
packages/cli/src/streamJson/io.ts
Normal file
41
packages/cli/src/streamJson/io.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import process from 'node:process';
|
||||||
|
import {
|
||||||
|
serializeStreamJsonEnvelope,
|
||||||
|
type StreamJsonOutputEnvelope,
|
||||||
|
type StreamJsonUserEnvelope,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
export function writeStreamJsonEnvelope(
|
||||||
|
envelope: StreamJsonOutputEnvelope,
|
||||||
|
): void {
|
||||||
|
process.stdout.write(`${serializeStreamJsonEnvelope(envelope)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractUserMessageText(
|
||||||
|
envelope: StreamJsonUserEnvelope,
|
||||||
|
): string {
|
||||||
|
const content = envelope.message?.content;
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
return content
|
||||||
|
.map((block) => {
|
||||||
|
if (block && typeof block === 'object' && 'type' in block) {
|
||||||
|
if (block.type === 'text' && 'text' in block) {
|
||||||
|
return block.text ?? '';
|
||||||
|
}
|
||||||
|
return JSON.stringify(block);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
68
packages/cli/src/streamJson/session.test.ts
Normal file
68
packages/cli/src/streamJson/session.test.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PassThrough } from 'node:stream';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { Config } from '@qwen-code/qwen-code-core';
|
||||||
|
import { runStreamJsonSession } from './session.js';
|
||||||
|
import { StreamJsonController } from './controller.js';
|
||||||
|
import { StreamJsonWriter } from './writer.js';
|
||||||
|
import type { LoadedSettings } from '../config/settings.js';
|
||||||
|
|
||||||
|
vi.mock('../nonInteractiveCli.js', () => ({
|
||||||
|
runNonInteractive: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createConfig(): Config {
|
||||||
|
return {
|
||||||
|
getIncludePartialMessages: () => false,
|
||||||
|
getSessionId: () => 'session-test',
|
||||||
|
getModel: () => 'model-test',
|
||||||
|
} as unknown as Config;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('runStreamJsonSession', () => {
|
||||||
|
let settings: LoadedSettings;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
||||||
|
settings = {} as LoadedSettings;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delegates incoming control requests to the controller', async () => {
|
||||||
|
const controllerPrototype = StreamJsonController.prototype as unknown as {
|
||||||
|
handleIncomingControlRequest: (...args: unknown[]) => unknown;
|
||||||
|
};
|
||||||
|
const handleSpy = vi.spyOn(
|
||||||
|
controllerPrototype,
|
||||||
|
'handleIncomingControlRequest',
|
||||||
|
);
|
||||||
|
|
||||||
|
const inputStream = new PassThrough();
|
||||||
|
const config = createConfig();
|
||||||
|
|
||||||
|
const controlRequest = {
|
||||||
|
type: 'control_request',
|
||||||
|
request_id: 'req-1',
|
||||||
|
request: { subtype: 'initialize' },
|
||||||
|
};
|
||||||
|
|
||||||
|
inputStream.end(`${JSON.stringify(controlRequest)}\n`);
|
||||||
|
|
||||||
|
await runStreamJsonSession(config, settings, undefined, {
|
||||||
|
input: inputStream,
|
||||||
|
writer: new StreamJsonWriter(config, false),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handleSpy).toHaveBeenCalledTimes(1);
|
||||||
|
const firstCall = handleSpy.mock.calls[0] as unknown[] | undefined;
|
||||||
|
expect(firstCall?.[1]).toMatchObject(controlRequest);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,10 +9,9 @@ import type { Config } from '@qwen-code/qwen-code-core';
|
|||||||
import {
|
import {
|
||||||
parseStreamJsonEnvelope,
|
parseStreamJsonEnvelope,
|
||||||
type StreamJsonEnvelope,
|
type StreamJsonEnvelope,
|
||||||
type StreamJsonControlRequestEnvelope,
|
|
||||||
type StreamJsonUserEnvelope,
|
type StreamJsonUserEnvelope,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { extractUserMessageText } from './input.js';
|
import { extractUserMessageText } from './io.js';
|
||||||
import { StreamJsonWriter } from './writer.js';
|
import { StreamJsonWriter } from './writer.js';
|
||||||
import { StreamJsonController } from './controller.js';
|
import { StreamJsonController } from './controller.js';
|
||||||
import { runNonInteractive } from '../nonInteractiveCli.js';
|
import { runNonInteractive } from '../nonInteractiveCli.js';
|
||||||
@@ -124,7 +123,7 @@ export async function runStreamJsonSession(
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'control_request':
|
case 'control_request':
|
||||||
await handleControlRequest(config, controller, envelope, writer);
|
controller.handleIncomingControlRequest(config, envelope);
|
||||||
break;
|
break;
|
||||||
case 'control_response':
|
case 'control_response':
|
||||||
controller.handleControlResponse(envelope);
|
controller.handleControlResponse(envelope);
|
||||||
@@ -174,41 +173,3 @@ async function handleUserPrompt(
|
|||||||
userEnvelope: job.envelope,
|
userEnvelope: job.envelope,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleControlRequest(
|
|
||||||
config: Config,
|
|
||||||
controller: StreamJsonController,
|
|
||||||
envelope: StreamJsonControlRequestEnvelope,
|
|
||||||
writer: StreamJsonWriter,
|
|
||||||
): Promise<void> {
|
|
||||||
const subtype = envelope.request?.subtype;
|
|
||||||
switch (subtype) {
|
|
||||||
case 'initialize':
|
|
||||||
writer.emitSystemMessage('session_initialized', {
|
|
||||||
session_id: config.getSessionId(),
|
|
||||||
});
|
|
||||||
controller.handleControlResponse({
|
|
||||||
type: 'control_response',
|
|
||||||
request_id: envelope.request_id,
|
|
||||||
success: true,
|
|
||||||
response: { subtype: 'initialize' },
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'interrupt':
|
|
||||||
controller.interruptActiveRun();
|
|
||||||
controller.handleControlResponse({
|
|
||||||
type: 'control_response',
|
|
||||||
request_id: envelope.request_id,
|
|
||||||
success: true,
|
|
||||||
response: { subtype: 'interrupt' },
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
controller.handleControlResponse({
|
|
||||||
type: 'control_response',
|
|
||||||
request_id: envelope.request_id,
|
|
||||||
success: false,
|
|
||||||
error: `Unsupported control_request subtype: ${subtype ?? 'unknown'}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -90,14 +90,18 @@ describe('StreamJsonWriter', () => {
|
|||||||
|
|
||||||
const envelopes = parseEnvelopes(writes);
|
const envelopes = parseEnvelopes(writes);
|
||||||
|
|
||||||
expect(
|
const hasThinkingDelta = envelopes.some((env) => {
|
||||||
envelopes.some(
|
if (env.type !== 'stream_event') {
|
||||||
(env) =>
|
return false;
|
||||||
env.type === 'stream_event' &&
|
}
|
||||||
env.event?.type === 'content_block_delta' &&
|
if (env.event?.type !== 'content_block_delta') {
|
||||||
env.event?.delta?.type === 'thinking_delta',
|
return false;
|
||||||
),
|
}
|
||||||
).toBe(true);
|
const delta = env.event.delta as { type?: string } | undefined;
|
||||||
|
return delta?.type === 'thinking_delta';
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hasThinkingDelta).toBe(true);
|
||||||
|
|
||||||
const assistantEnvelope = envelopes.find((env) => env.type === 'assistant');
|
const assistantEnvelope = envelopes.find((env) => env.type === 'assistant');
|
||||||
expect(assistantEnvelope?.message.content?.[0]).toEqual({
|
expect(assistantEnvelope?.message.content?.[0]).toEqual({
|
||||||
@@ -122,14 +126,18 @@ describe('StreamJsonWriter', () => {
|
|||||||
|
|
||||||
const envelopes = parseEnvelopes(writes);
|
const envelopes = parseEnvelopes(writes);
|
||||||
|
|
||||||
expect(
|
const hasInputJsonDelta = envelopes.some((env) => {
|
||||||
envelopes.some(
|
if (env.type !== 'stream_event') {
|
||||||
(env) =>
|
return false;
|
||||||
env.type === 'stream_event' &&
|
}
|
||||||
env.event?.type === 'content_block_delta' &&
|
if (env.event?.type !== 'content_block_delta') {
|
||||||
env.event?.delta?.type === 'input_json_delta',
|
return false;
|
||||||
),
|
}
|
||||||
).toBe(true);
|
const delta = env.event.delta as { type?: string } | undefined;
|
||||||
|
return delta?.type === 'input_json_delta';
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hasInputJsonDelta).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes session id in system messages', () => {
|
it('includes session id in system messages', () => {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import type {
|
|||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import type { Part } from '@google/genai';
|
import type { Part } from '@google/genai';
|
||||||
import {
|
import {
|
||||||
serializeStreamJsonEnvelope,
|
|
||||||
type StreamJsonAssistantEnvelope,
|
type StreamJsonAssistantEnvelope,
|
||||||
type StreamJsonContentBlock,
|
type StreamJsonContentBlock,
|
||||||
type StreamJsonMessageStreamEvent,
|
type StreamJsonMessageStreamEvent,
|
||||||
@@ -21,6 +20,7 @@ import {
|
|||||||
type StreamJsonUsage,
|
type StreamJsonUsage,
|
||||||
type StreamJsonToolResultBlock,
|
type StreamJsonToolResultBlock,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
import { writeStreamJsonEnvelope } from './io.js';
|
||||||
|
|
||||||
export interface StreamJsonResultOptions {
|
export interface StreamJsonResultOptions {
|
||||||
readonly isError: boolean;
|
readonly isError: boolean;
|
||||||
@@ -146,8 +146,7 @@ export class StreamJsonWriter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
writeEnvelope(envelope: StreamJsonOutputEnvelope): void {
|
writeEnvelope(envelope: StreamJsonOutputEnvelope): void {
|
||||||
const line = serializeStreamJsonEnvelope(envelope);
|
writeStreamJsonEnvelope(envelope);
|
||||||
process.stdout.write(`${line}\n`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private toolResultContent(
|
private toolResultContent(
|
||||||
|
|||||||
Reference in New Issue
Block a user